about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorkibigo! <marrus-sh@users.noreply.github.com>2017-07-15 14:33:15 -0700
committerkibigo! <marrus-sh@users.noreply.github.com>2017-07-15 14:33:15 -0700
commit09cfc079b0958c42fe619e2d88c3f9fd1d7c7900 (patch)
tree156de790a5bec0fdf050e392bee8a64b220d3a9d /app
parent08d021916db9e350259b925d7e562aa13ba37422 (diff)
parent695439775eacea081c7257aabab39d0ec6b492dc (diff)
Merge upstream (#81)
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb5
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb28
-rw-r--r--app/controllers/api/activitypub/activities_controller.rb27
-rw-r--r--app/controllers/api/activitypub/notes_controller.rb19
-rw-r--r--app/controllers/api/activitypub/outbox_controller.rb69
-rw-r--r--app/controllers/api/push_controller.rb8
-rw-r--r--app/controllers/api/subscriptions_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/favourites_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/reblogs_controller.rb2
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb39
-rw-r--r--app/controllers/concerns/signature_verification.rb87
-rw-r--r--app/controllers/follower_accounts_controller.rb20
-rw-r--r--app/controllers/following_accounts_controller.rb20
-rw-r--r--app/controllers/home_controller.rb1
-rw-r--r--app/controllers/settings/preferences_controller.rb1
-rw-r--r--app/controllers/statuses_controller.rb18
-rw-r--r--app/controllers/stream_entries_controller.rb1
-rw-r--r--app/controllers/tags_controller.rb22
-rw-r--r--app/helpers/activitystreams2_builder_helper.rb8
-rw-r--r--app/helpers/emoji_helper.rb19
-rw-r--r--app/helpers/http_helper.rb17
-rw-r--r--app/javascript/mastodon/actions/compose.js6
-rw-r--r--app/javascript/mastodon/actions/push_notifications.js52
-rw-r--r--app/javascript/mastodon/components/extended_video_player.js4
-rw-r--r--app/javascript/mastodon/components/load_more.js9
-rw-r--r--app/javascript/mastodon/components/status_list.js6
-rw-r--r--app/javascript/mastodon/emoji.js39
-rw-r--r--app/javascript/mastodon/emojione_light.js11
-rw-r--r--app/javascript/mastodon/extra_polyfills.js3
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js3
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js5
-rw-r--r--app/javascript/mastodon/features/favourited_statuses/index.js64
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js23
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js4
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js9
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js38
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js20
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js10
-rw-r--r--app/javascript/mastodon/load_polyfills.js5
-rw-r--r--app/javascript/mastodon/locales/ar.json3
-rw-r--r--app/javascript/mastodon/locales/bg.json3
-rw-r--r--app/javascript/mastodon/locales/ca.json3
-rw-r--r--app/javascript/mastodon/locales/de.json3
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json17
-rw-r--r--app/javascript/mastodon/locales/en.json3
-rw-r--r--app/javascript/mastodon/locales/eo.json3
-rw-r--r--app/javascript/mastodon/locales/es.json3
-rw-r--r--app/javascript/mastodon/locales/fa.json3
-rw-r--r--app/javascript/mastodon/locales/fi.json3
-rw-r--r--app/javascript/mastodon/locales/fr.json13
-rw-r--r--app/javascript/mastodon/locales/he.json3
-rw-r--r--app/javascript/mastodon/locales/hr.json3
-rw-r--r--app/javascript/mastodon/locales/hu.json3
-rw-r--r--app/javascript/mastodon/locales/id.json3
-rw-r--r--app/javascript/mastodon/locales/io.json3
-rw-r--r--app/javascript/mastodon/locales/it.json3
-rw-r--r--app/javascript/mastodon/locales/ja.json3
-rw-r--r--app/javascript/mastodon/locales/ko.json3
-rw-r--r--app/javascript/mastodon/locales/nl.json3
-rw-r--r--app/javascript/mastodon/locales/no.json3
-rw-r--r--app/javascript/mastodon/locales/oc.json3
-rw-r--r--app/javascript/mastodon/locales/pl.json39
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json3
-rw-r--r--app/javascript/mastodon/locales/pt.json3
-rw-r--r--app/javascript/mastodon/locales/ru.json3
-rw-r--r--app/javascript/mastodon/locales/th.json3
-rw-r--r--app/javascript/mastodon/locales/tr.json3
-rw-r--r--app/javascript/mastodon/locales/uk.json3
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json3
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json3
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json3
-rw-r--r--app/javascript/mastodon/main.js20
-rw-r--r--app/javascript/mastodon/ready.js7
-rw-r--r--app/javascript/mastodon/reducers/compose.js2
-rw-r--r--app/javascript/mastodon/reducers/index.js5
-rw-r--r--app/javascript/mastodon/reducers/push_notifications.js51
-rw-r--r--app/javascript/mastodon/service_worker/entry.js1
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js86
-rw-r--r--app/javascript/mastodon/web_push_subscription.js109
-rw-r--r--app/javascript/packs/about.js24
-rw-r--r--app/javascript/packs/public.js17
-rw-r--r--app/javascript/styles/components.scss22
-rw-r--r--app/javascript/styles/rtl.scss4
-rw-r--r--app/lib/activitypub/adapter.rb13
-rw-r--r--app/lib/activitypub/tag_manager.rb69
-rw-r--r--app/lib/feed_manager.rb7
-rw-r--r--app/lib/provider_discovery.rb4
-rw-r--r--app/lib/request.rb70
-rw-r--r--app/lib/tag_manager.rb2
-rw-r--r--app/lib/user_settings_decorator.rb5
-rw-r--r--app/models/account.rb12
-rw-r--r--app/models/concerns/remotable.rb3
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/import.rb2
-rw-r--r--app/models/session_activation.rb12
-rw-r--r--app/models/status.rb16
-rw-r--r--app/models/subscription.rb6
-rw-r--r--app/models/tag.rb7
-rw-r--r--app/models/user.rb12
-rw-r--r--app/models/web/push_subscription.rb190
-rw-r--r--app/presenters/activitypub/collection_presenter.rb5
-rw-r--r--app/presenters/initial_state_presenter.rb2
-rw-r--r--app/presenters/status_relationships_presenter.rb8
-rw-r--r--app/serializers/activitypub/activity_serializer.rb27
-rw-r--r--app/serializers/activitypub/actor_serializer.rb53
-rw-r--r--app/serializers/activitypub/collection_serializer.rb26
-rw-r--r--app/serializers/activitypub/note_serializer.rb106
-rw-r--r--app/serializers/initial_state_serializer.rb2
-rw-r--r--app/services/concerns/author_extractor.rb2
-rw-r--r--app/services/fetch_atom_service.rb8
-rw-r--r--app/services/fetch_link_card_service.rb6
-rw-r--r--app/services/fetch_remote_status_service.rb4
-rw-r--r--app/services/notify_service.rb5
-rw-r--r--app/services/post_status_service.rb2
-rw-r--r--app/services/pubsubhubbub/subscribe_service.rb16
-rw-r--r--app/services/resolve_remote_account_service.rb3
-rw-r--r--app/services/send_interaction_service.rb14
-rw-r--r--app/services/subscribe_service.rb48
-rw-r--r--app/services/unsubscribe_service.rb31
-rw-r--r--app/views/about/show.html.haml7
-rw-r--r--app/views/accounts/show.activitystreams2.rabl9
-rw-r--r--app/views/accounts/show.html.haml3
-rw-r--r--app/views/activitypub/base.activitystreams2.rabl1
-rw-r--r--app/views/activitypub/intransient.activitystreams2.rabl3
-rw-r--r--app/views/activitypub/types/announce.activitystreams2.rabl3
-rw-r--r--app/views/activitypub/types/collection.activitystreams2.rabl3
-rw-r--r--app/views/activitypub/types/create.activitystreams2.rabl3
-rw-r--r--app/views/activitypub/types/note.activitystreams2.rabl3
-rw-r--r--app/views/activitypub/types/ordered_collection.activitystreams2.rabl3
-rw-r--r--app/views/activitypub/types/ordered_collection_page.activitystreams2.rabl3
-rw-r--r--app/views/activitypub/types/person.activitystreams2.rabl3
-rw-r--r--app/views/api/activitypub/activities/_show_status.activitystreams2.rabl4
-rw-r--r--app/views/api/activitypub/activities/show_status_announce.activitystreams2.rabl8
-rw-r--r--app/views/api/activitypub/activities/show_status_create.activitystreams2.rabl8
-rw-r--r--app/views/api/activitypub/notes/show.activitystreams2.rabl11
-rw-r--r--app/views/api/activitypub/outbox/show.activitystreams2.rabl12
-rw-r--r--app/views/api/activitypub/outbox/show_page.activitystreams2.rabl16
-rw-r--r--app/views/follower_accounts/index.html.haml3
-rw-r--r--app/views/following_accounts/index.html.haml3
-rw-r--r--app/views/home/index.html.haml1
-rwxr-xr-xapp/views/layouts/application.html.haml1
-rw-r--r--app/views/layouts/embedded.html.haml2
-rw-r--r--app/views/settings/preferences/show.html.haml3
-rw-r--r--app/views/stream_entries/show.html.haml3
-rw-r--r--app/views/well_known/webfinger/show.json.rabl6
-rw-r--r--app/views/well_known/webfinger/show.xml.ruby5
-rw-r--r--app/workers/pubsubhubbub/confirmation_worker.rb12
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb13
-rw-r--r--app/workers/pubsubhubbub/distribution_worker.rb8
-rw-r--r--app/workers/web_push_notification_worker.rb27
150 files changed, 1736 insertions, 484 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 11402ab79..a95aabf1d 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -2,6 +2,7 @@
 
 class AccountsController < ApplicationController
   include AccountControllerConcern
+  include SignatureVerification
 
   def show
     respond_to do |format|
@@ -15,7 +16,9 @@ class AccountsController < ApplicationController
         render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a))
       end
 
-      format.activitystreams2
+      format.json do
+        render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
+      end
     end
   end
 
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
new file mode 100644
index 000000000..6a58ccf24
--- /dev/null
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class ActivityPub::OutboxesController < Api::BaseController
+  before_action :set_account
+
+  def show
+    @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
+    @statuses = cache_collection(@statuses, Status)
+
+    render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+  end
+
+  private
+
+  def set_account
+    @account = Account.find_local!(params[:account_username])
+  end
+
+  def outbox_presenter
+    ActivityPub::CollectionPresenter.new(
+      id: account_outbox_url(@account),
+      type: :ordered,
+      current: account_outbox_url(@account),
+      size: @account.statuses_count,
+      items: @statuses
+    )
+  end
+end
diff --git a/app/controllers/api/activitypub/activities_controller.rb b/app/controllers/api/activitypub/activities_controller.rb
deleted file mode 100644
index a880ee92f..000000000
--- a/app/controllers/api/activitypub/activities_controller.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-class Api::ActivityPub::ActivitiesController < Api::BaseController
-  include Authorization
-
-  # before_action :set_follow, only: [:show_follow]
-  before_action :set_status, only: [:show_status]
-
-  respond_to :activitystreams2
-
-  # Show a status in AS2 format, as either an Announce (reblog) or a Create (post) activity.
-  def show_status
-    authorize @status, :show?
-
-    if @status.reblog?
-      render :show_status_announce
-    else
-      render :show_status_create
-    end
-  end
-
-  private
-
-  def set_status
-    @status = Status.find(params[:id])
-  end
-end
diff --git a/app/controllers/api/activitypub/notes_controller.rb b/app/controllers/api/activitypub/notes_controller.rb
deleted file mode 100644
index 96652b879..000000000
--- a/app/controllers/api/activitypub/notes_controller.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-class Api::ActivityPub::NotesController < Api::BaseController
-  include Authorization
-
-  before_action :set_status
-
-  respond_to :activitystreams2
-
-  def show
-    authorize @status, :show?
-  end
-
-  private
-
-  def set_status
-    @status = Status.find(params[:id])
-  end
-end
diff --git a/app/controllers/api/activitypub/outbox_controller.rb b/app/controllers/api/activitypub/outbox_controller.rb
deleted file mode 100644
index 1af04cb54..000000000
--- a/app/controllers/api/activitypub/outbox_controller.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-class Api::ActivityPub::OutboxController < Api::BaseController
-  before_action :set_account
-
-  respond_to :activitystreams2
-
-  def show
-    if params[:max_id] || params[:since_id]
-      show_outbox_page
-    else
-      show_base_outbox
-    end
-  end
-
-  private
-
-  def show_base_outbox
-    @statuses = Status.as_outbox_timeline(@account)
-    @statuses = cache_collection(@statuses)
-
-    set_maps(@statuses)
-
-    set_first_last_page(@statuses)
-
-    render :show
-  end
-
-  def show_outbox_page
-    all_statuses = Status.as_outbox_timeline(@account)
-    @statuses = all_statuses.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
-
-    all_statuses = cache_collection(all_statuses)
-    @statuses = cache_collection(@statuses)
-
-    set_maps(@statuses)
-
-    set_first_last_page(all_statuses)
-
-    @next_page_url = api_activitypub_outbox_url(pagination_params(max_id: @statuses.last.id))    unless @statuses.empty?
-    @prev_page_url = api_activitypub_outbox_url(pagination_params(since_id: @statuses.first.id)) unless @statuses.empty?
-
-    @paginated = @next_page_url || @prev_page_url
-    @part_of_url = api_activitypub_outbox_url
-
-    set_pagination_headers(@next_page_url, @prev_page_url)
-
-    render :show_page
-  end
-
-  def cache_collection(raw)
-    super(raw, Status)
-  end
-
-  def set_account
-    @account = Account.find(params[:id])
-  end
-
-  def set_first_last_page(statuses) # rubocop:disable Style/AccessorMethodName
-    return if statuses.empty?
-
-    @first_page_url = api_activitypub_outbox_url(max_id: statuses.first.id + 1)
-    @last_page_url = api_activitypub_outbox_url(since_id: statuses.last.id - 1)
-  end
-
-  def pagination_params(core_params)
-    params.permit(:local, :limit).merge(core_params)
-  end
-end
diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb
index 951867140..e04d19125 100644
--- a/app/controllers/api/push_controller.rb
+++ b/app/controllers/api/push_controller.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class Api::PushController < Api::BaseController
+  include SignatureVerification
+
   def update
     response, status = process_push_request
     render plain: response, status: status
@@ -11,7 +13,7 @@ class Api::PushController < Api::BaseController
   def process_push_request
     case hub_mode
     when 'subscribe'
-      Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds)
+      Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds, verified_domain)
     when 'unsubscribe'
       Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback)
     else
@@ -57,6 +59,10 @@ class Api::PushController < Api::BaseController
     TagManager.instance.web_domain?(hub_topic_domain)
   end
 
+  def verified_domain
+    return signed_request_account.domain if signed_request_account
+  end
+
   def hub_topic_domain
     hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '')
   end
diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb
index d3ea98676..89007f3d6 100644
--- a/app/controllers/api/subscriptions_controller.rb
+++ b/app/controllers/api/subscriptions_controller.rb
@@ -42,7 +42,7 @@ class Api::SubscriptionsController < Api::BaseController
   end
 
   def lease_seconds_or_default
-    (params['hub.lease_seconds'] || 86_400).to_i.seconds
+    (params['hub.lease_seconds'] || 1.day).to_i.seconds
   end
 
   def set_account
diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb
index 4c4b0c160..35f8a48cd 100644
--- a/app/controllers/api/v1/statuses/favourites_controller.rb
+++ b/app/controllers/api/v1/statuses/favourites_controller.rb
@@ -19,7 +19,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
 
     UnfavouriteWorker.perform_async(current_user.account_id, @status.id)
 
-    render json: @status, serializer: REST::StatusSerializer
+    render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, favourites_map: @favourites_map)
   end
 
   private
diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb
index f7f4b5a5c..634af474f 100644
--- a/app/controllers/api/v1/statuses/reblogs_controller.rb
+++ b/app/controllers/api/v1/statuses/reblogs_controller.rb
@@ -20,7 +20,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
     authorize status_for_destroy, :unreblog?
     RemovalWorker.perform_async(status_for_destroy.id)
 
-    render json: @status, serializer: REST::StatusSerializer
+    render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
   end
 
   private
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
new file mode 100644
index 000000000..8425db7b4
--- /dev/null
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class Api::Web::PushSubscriptionsController < Api::BaseController
+  respond_to :json
+
+  before_action :require_user!
+
+  def create
+    params.require(:data).require(:endpoint)
+    params.require(:data).require(:keys).require([:auth, :p256dh])
+
+    active_session = current_session
+
+    unless active_session.web_push_subscription.nil?
+      active_session.web_push_subscription.destroy!
+      active_session.update!(web_push_subscription: nil)
+    end
+
+    web_subscription = ::Web::PushSubscription.create!(
+      endpoint: params[:data][:endpoint],
+      key_p256dh: params[:data][:keys][:p256dh],
+      key_auth: params[:data][:keys][:auth]
+    )
+
+    active_session.update!(web_push_subscription: web_subscription)
+
+    render json: web_subscription.as_payload
+  end
+
+  def update
+    params.require([:id, :data])
+
+    web_subscription = ::Web::PushSubscription.find(params[:id])
+
+    web_subscription.update!(data: params[:data])
+
+    render json: web_subscription.as_payload
+  end
+end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
new file mode 100644
index 000000000..abe845d93
--- /dev/null
+++ b/app/controllers/concerns/signature_verification.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+# Implemented according to HTTP signatures (Draft 6)
+# <https://tools.ietf.org/html/draft-cavage-http-signatures-06>
+module SignatureVerification
+  extend ActiveSupport::Concern
+
+  def signed_request?
+    request.headers['Signature'].present?
+  end
+
+  def signed_request_account
+    return @signed_request_account if defined?(@signed_request_account)
+
+    unless signed_request?
+      @signed_request_account = nil
+      return
+    end
+
+    raw_signature    = request.headers['Signature']
+    signature_params = {}
+
+    raw_signature.split(',').each do |part|
+      parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
+      next if parsed_parts.nil? || parsed_parts.size != 3
+      signature_params[parsed_parts[1]] = parsed_parts[2]
+    end
+
+    if incompatible_signature?(signature_params)
+      @signed_request_account = nil
+      return
+    end
+
+    account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, ''))
+
+    if account.nil?
+      @signed_request_account = nil
+      return
+    end
+
+    signature             = Base64.decode64(signature_params['signature'])
+    compare_signed_string = build_signed_string(signature_params['headers'])
+
+    if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
+      @signed_request_account = account
+      @signed_request_account
+    else
+      @signed_request_account = nil
+    end
+  end
+
+  private
+
+  def build_signed_string(signed_headers)
+    signed_headers = 'date' if signed_headers.blank?
+
+    signed_headers.split(' ').map do |signed_header|
+      if signed_header == Request::REQUEST_TARGET
+        "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
+      else
+        "#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
+      end
+    end.join("\n")
+  end
+
+  def matches_time_window?
+    begin
+      time_sent = DateTime.httpdate(request.headers['Date'])
+    rescue ArgumentError
+      return false
+    end
+
+    (Time.now.utc - time_sent).abs <= 30
+  end
+
+  def to_header_name(name)
+    name.split(/-/).map(&:capitalize).join('-')
+  end
+
+  def incompatible_signature?(signature_params)
+    signature_params['keyId'].blank? ||
+      signature_params['signature'].blank? ||
+      signature_params['algorithm'].blank? ||
+      signature_params['algorithm'] != 'rsa-sha256' ||
+      !signature_params['keyId'].start_with?('acct:')
+  end
+end
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 1e7c7c406..e58c5ad46 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -5,5 +5,25 @@ class FollowerAccountsController < ApplicationController
 
   def index
     @follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
+
+    respond_to do |format|
+      format.html
+
+      format.json do
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+      end
+    end
+  end
+
+  private
+
+  def collection_presenter
+    ActivityPub::CollectionPresenter.new(
+      id: account_followers_url(@account),
+      type: :ordered,
+      current: account_followers_url(@account),
+      size: @account.followers_count,
+      items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }
+    )
   end
 end
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index f4488eef5..69f29cd70 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -5,5 +5,25 @@ class FollowingAccountsController < ApplicationController
 
   def index
     @follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
+
+    respond_to do |format|
+      format.html
+
+      format.json do
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+      end
+    end
+  end
+
+  private
+
+  def collection_presenter
+    ActivityPub::CollectionPresenter.new(
+      id: account_following_index_url(@account),
+      type: :ordered,
+      current: account_following_index_url(@account),
+      size: @account.following_count,
+      items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }
+    )
   end
 end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index 8a8b9ec76..1585bc810 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -22,6 +22,7 @@ class HomeController < ApplicationController
   def initial_state_params
     {
       settings: Web::Setting.find_by(user: current_user)&.data || {},
+      push_subscription: current_account.user.web_push_subscription(current_session),
       current_account: current_account,
       token: current_session.token,
       admin: Account.find_local(Setting.site_contact_username),
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index cac5b0ba8..a3f5a008b 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController
       :setting_delete_modal,
       :setting_auto_play_gif,
       :setting_system_font_ui,
+      :setting_noindex,
       notification_emails: %i(follow follow_request reblog favourite mention digest),
       interactions: %i(must_be_follower must_be_following)
     )
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 59c9d0a87..8e0ce0ec3 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -11,10 +11,22 @@ class StatusesController < ApplicationController
   before_action :check_account_suspension
 
   def show
-    @ancestors   = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
-    @descendants = cache_collection(@status.descendants(current_account), Status)
+    respond_to do |format|
+      format.html do
+        @ancestors   = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
+        @descendants = cache_collection(@status.descendants(current_account), Status)
+
+        render 'stream_entries/show'
+      end
+
+      format.json do
+        render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
+      end
+    end
+  end
 
-    render 'stream_entries/show'
+  def activity
+    render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
   end
 
   private
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index 314d59619..54a435238 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -2,6 +2,7 @@
 
 class StreamEntriesController < ApplicationController
   include Authorization
+  include SignatureVerification
 
   layout 'public'
 
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 53149edf0..8bcce9e13 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -5,7 +5,27 @@ class TagsController < ApplicationController
 
   def show
     @tag      = Tag.find_by!(name: params[:id].downcase)
-    @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
+    @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
     @statuses = cache_collection(@statuses, Status)
+
+    respond_to do |format|
+      format.html
+
+      format.json do
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+      end
+    end
+  end
+
+  private
+
+  def collection_presenter
+    ActivityPub::CollectionPresenter.new(
+      id: tag_url(@tag),
+      type: :ordered,
+      current: tag_url(@tag),
+      size: @tag.statuses.count,
+      items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
+    )
   end
 end
diff --git a/app/helpers/activitystreams2_builder_helper.rb b/app/helpers/activitystreams2_builder_helper.rb
deleted file mode 100644
index 717b470f0..000000000
--- a/app/helpers/activitystreams2_builder_helper.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-# frozen_string_literal: true
-
-module Activitystreams2BuilderHelper
-  # Gets a usable name for an account, using display name or username.
-  def account_name(account)
-    account.display_name.presence || account.username
-  end
-end
diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb
new file mode 100644
index 000000000..c1595851f
--- /dev/null
+++ b/app/helpers/emoji_helper.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module EmojiHelper
+  EMOJI_PATTERN = /(?<=[^[:alnum:]:]|\n|^):([\w+-]+):(?=[^[:alnum:]:]|$)/x
+
+  def emojify(text)
+    return text if text.blank?
+
+    text.gsub(EMOJI_PATTERN) do |match|
+      emoji = Emoji.find_by_alias($1) # rubocop:disable Rails/DynamicFindBy,Style/PerlBackrefs
+
+      if emoji
+        emoji.raw
+      else
+        match
+      end
+    end
+  end
+end
diff --git a/app/helpers/http_helper.rb b/app/helpers/http_helper.rb
deleted file mode 100644
index e39a52da0..000000000
--- a/app/helpers/http_helper.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module HttpHelper
-  def http_client(options = {})
-    timeout = { write: 10, connect: 10, read: 10 }.merge(options)
-
-    HTTP.headers(user_agent: user_agent)
-        .timeout(:per_operation, timeout)
-        .follow
-  end
-
-  private
-
-  def user_agent
-    @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +http://#{Rails.configuration.x.local_domain}/)"
-  end
-end
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index bce836b45..4b8e9e50d 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -2,8 +2,6 @@ import api from '../api';
 
 import { updateTimeline } from './timelines';
 
-import * as emojione from 'emojione';
-
 export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE';
 export const COMPOSE_SUBMIT_REQUEST  = 'COMPOSE_SUBMIT_REQUEST';
 export const COMPOSE_SUBMIT_SUCCESS  = 'COMPOSE_SUBMIT_SUCCESS';
@@ -74,10 +72,12 @@ export function mentionCompose(account, router) {
 
 export function submitCompose() {
   return function (dispatch, getState) {
-    let status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], ''));
+    const status = getState().getIn(['compose', 'text'], '');
+
     if (!status || !status.length) {
       return;
     }
+
     dispatch(submitComposeRequest());
     if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
       status = status + ' 👁️';
diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js
new file mode 100644
index 000000000..55661d2b0
--- /dev/null
+++ b/app/javascript/mastodon/actions/push_notifications.js
@@ -0,0 +1,52 @@
+import axios from 'axios';
+
+export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
+export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
+export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
+
+export function setBrowserSupport (value) {
+  return {
+    type: SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setSubscription (subscription) {
+  return {
+    type: SET_SUBSCRIPTION,
+    subscription,
+  };
+}
+
+export function clearSubscription () {
+  return {
+    type: CLEAR_SUBSCRIPTION,
+  };
+}
+
+export function changeAlerts(key, value) {
+  return dispatch => {
+    dispatch({
+      type: ALERTS_CHANGE,
+      key,
+      value,
+    });
+
+    dispatch(saveSettings());
+  };
+}
+
+export function saveSettings() {
+  return (_, getState) => {
+    const state = getState().get('push_notifications');
+    const subscription = state.get('subscription');
+    const alerts = state.get('alerts');
+
+    axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+      data: {
+        alerts,
+      },
+    });
+  };
+}
diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js
index 4c62fa7b3..b38a4b8ff 100644
--- a/app/javascript/mastodon/components/extended_video_player.js
+++ b/app/javascript/mastodon/components/extended_video_player.js
@@ -5,6 +5,8 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
 
   static propTypes = {
     src: PropTypes.string.isRequired,
+    width: PropTypes.number,
+    height: PropTypes.number,
     time: PropTypes.number,
     controls: PropTypes.bool.isRequired,
     muted: PropTypes.bool.isRequired,
@@ -30,7 +32,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
 
   render () {
     return (
-      <div className='extended-video-player'>
+      <div className='extended-video-player' style={{ width: this.props.width, height: this.props.height }}>
         <video
           ref={this.setRef}
           src={this.props.src}
diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js
index 2996d4dc8..e2fe1fed7 100644
--- a/app/javascript/mastodon/components/load_more.js
+++ b/app/javascript/mastodon/components/load_more.js
@@ -6,11 +6,18 @@ export default class LoadMore extends React.PureComponent {
 
   static propTypes = {
     onClick: PropTypes.func,
+    visible: PropTypes.bool,
+  }
+
+  static defaultProps = {
+    visible: true,
   }
 
   render() {
+    const { visible } = this.props;
+
     return (
-      <button className='load-more' onClick={this.props.onClick}>
+      <button className='load-more' disabled={!visible} style={{ opacity: visible ? 1 : 0 }} onClick={this.props.onClick}>
         <FormattedMessage id='status.load_more' defaultMessage='Load more' />
       </button>
     );
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 94b348f25..e7b38a07a 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -101,13 +101,9 @@ export default class StatusList extends ImmutablePureComponent {
   render () {
     const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
 
-    let loadMore       = null;
+    const loadMore     = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />;
     let scrollableArea = null;
 
-    if (!isLoading && statusIds.size > 0 && hasMore) {
-      loadMore = <LoadMore onClick={this.handleLoadMore} />;
-    }
-
     if (isLoading || statusIds.size > 0 || !emptyMessage) {
       scrollableArea = (
         <div className='scrollable' ref={this.setRef}>
diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js
index 7043d5f3a..1de41f572 100644
--- a/app/javascript/mastodon/emoji.js
+++ b/app/javascript/mastodon/emoji.js
@@ -1,49 +1,28 @@
-import emojione from 'emojione';
+import { unicodeToFilename } from './emojione_light';
 import Trie from 'substring-trie';
 
-const mappedUnicode = emojione.mapUnicodeToShort();
-const trie = new Trie(Object.keys(emojione.jsEscapeMap));
+const trie = new Trie(Object.keys(unicodeToFilename));
 
 function emojify(str) {
   // This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
-  // and replacing valid shortnames like :smile: and :wink: as well as unicode strings
+  // and replacing valid unicode strings
   // that _aren't_ within tags with an <img> version.
-  // The goal is to be the same as an emojione.regShortNames/regUnicode replacement, but faster.
+  // The goal is to be the same as an emojione.regUnicode replacement, but faster.
   let i = -1;
   let insideTag = false;
-  let insideShortname = false;
-  let shortnameStartIndex = -1;
   let match;
   while (++i < str.length) {
     const char = str.charAt(i);
-    if (insideShortname && char === ':') {
-      const shortname = str.substring(shortnameStartIndex, i + 1);
-      if (shortname in emojione.emojioneList) {
-        const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
-        const alt = emojione.convert(unicode.toUpperCase());
-        const replacement = `<img draggable="false" class="emojione" alt="${alt}" title="${shortname}" src="/emoji/${unicode}.svg" />`;
-        str = str.substring(0, shortnameStartIndex) + replacement + str.substring(i + 1);
-        i += (replacement.length - shortname.length - 1); // jump ahead the length we've added to the string
-      } else {
-        i--; // stray colon, try again
-      }
-      insideShortname = false;
-    } else if (insideTag && char === '>') {
+    if (insideTag && char === '>') {
       insideTag = false;
     } else if (char === '<') {
       insideTag = true;
-      insideShortname = false;
-    } else if (!insideTag && char === ':') {
-      insideShortname = true;
-      shortnameStartIndex = i;
     } else if (!insideTag && (match = trie.search(str.substring(i)))) {
       const unicodeStr = match;
-      if (unicodeStr in emojione.jsEscapeMap) {
-        const unicode  = emojione.jsEscapeMap[unicodeStr];
-        const short    = mappedUnicode[unicode];
-        const filename = emojione.emojioneList[short].fname;
-        const alt      = emojione.convert(unicode.toUpperCase());
-        const replacement =  `<img draggable="false" class="emojione" alt="${alt}" title="${short}" src="/emoji/${filename}.svg" />`;
+      if (unicodeStr in unicodeToFilename) {
+        const filename = unicodeToFilename[unicodeStr];
+        const alt      = unicodeStr;
+        const replacement =  `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`;
         str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length);
         i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string
       }
diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js
new file mode 100644
index 000000000..c75e10a98
--- /dev/null
+++ b/app/javascript/mastodon/emojione_light.js
@@ -0,0 +1,11 @@
+// @preval
+// Force tree shaking on emojione by exposing just a subset of its functionality
+
+const emojione = require('emojione');
+
+const mappedUnicode = emojione.mapUnicodeToShort();
+
+module.exports.unicodeToFilename = Object.keys(emojione.jsEscapeMap)
+  .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]])
+  .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: emojione.emojioneList[shortCode].fname }))
+  .reduce((x, y) => Object.assign(x, y), { });
diff --git a/app/javascript/mastodon/extra_polyfills.js b/app/javascript/mastodon/extra_polyfills.js
index 546b693b1..3acc55abd 100644
--- a/app/javascript/mastodon/extra_polyfills.js
+++ b/app/javascript/mastodon/extra_polyfills.js
@@ -1,2 +1,5 @@
 import 'intersection-observer';
 import 'requestidlecallback';
+import objectFitImages  from 'object-fit-images';
+
+objectFitImages();
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index de5b09834..7273edf48 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -140,7 +140,8 @@ export default class ComposeForm extends ImmutablePureComponent {
 
   handleEmojiPick = (data) => {
     const position     = this.autosuggestTextarea.textarea.selectionStart;
-    this._restoreCaret = position + data.shortname.length + 1;
+    const emojiChar    = String.fromCodePoint(parseInt(data.unicode, 16));
+    this._restoreCaret = position + emojiChar.length + 1;
     this.props.onPickEmoji(position, data);
   }
 
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 83c66a5d5..acc584f20 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -109,11 +109,12 @@ export default class EmojiPickerDropdown extends React.PureComponent {
       <Dropdown ref={this.setRef} className='emoji-picker__dropdown' onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
         <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)}>
           <img
-            draggable='false'
             className={`emojione ${active && loading ? 'pulse-loading' : ''}`}
-            alt='🙂' src='/emoji/1f602.svg'
+            alt='🙂'
+            src='/emoji/1f602.svg'
           />
         </DropdownTrigger>
+
         <DropdownContent className='dropdown__left'>
           {
             this.state.active && !this.state.loading &&
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
index 8cef6a1e4..d9ad9bc1f 100644
--- a/app/javascript/mastodon/features/favourited_statuses/index.js
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -2,11 +2,11 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import LoadingIndicator from '../../components/loading_indicator';
 import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
 import Column from '../ui/components/column';
+import ColumnHeader from '../../components/column_header';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import StatusList from '../../components/status_list';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
@@ -16,8 +16,6 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   statusIds: state.getIn(['status_lists', 'favourites', 'items']),
-  loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
-  me: state.getIn(['meta', 'me']),
 });
 
 @connect(mapStateToProps)
@@ -27,34 +25,64 @@ export default class Favourites extends ImmutablePureComponent {
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
     statusIds: ImmutablePropTypes.list.isRequired,
-    loaded: PropTypes.bool,
     intl: PropTypes.object.isRequired,
-    me: PropTypes.number.isRequired,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
   };
 
   componentWillMount () {
     this.props.dispatch(fetchFavouritedStatuses());
   }
 
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('FAVOURITES', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
   handleScrollToBottom = () => {
     this.props.dispatch(expandFavouritedStatuses());
   }
 
   render () {
-    const { loaded, intl } = this.props;
-
-    if (!loaded) {
-      return (
-        <Column>
-          <LoadingIndicator />
-        </Column>
-      );
-    }
+    const { intl, statusIds, columnId, multiColumn } = this.props;
+    const pinned = !!columnId;
 
     return (
-      <Column icon='star' heading={intl.formatMessage(messages.heading)}>
-        <ColumnBackButtonSlim />
-        <StatusList {...this.props} scrollKey='favourited_statuses' onScrollToBottom={this.handleScrollToBottom} />
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='star'
+          title={intl.formatMessage(messages.heading)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        />
+
+        <StatusList
+          trackScroll={!pinned}
+          statusIds={statusIds}
+          scrollKey={`favourited_statuses-${columnId}`}
+          onScrollToBottom={this.handleScrollToBottom}
+        />
       </Column>
     );
   }
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 260594894..31cac5bc7 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -9,18 +9,27 @@ export default class ColumnSettings extends React.PureComponent {
 
   static propTypes = {
     settings: ImmutablePropTypes.map.isRequired,
+    pushSettings: ImmutablePropTypes.map.isRequired,
     onChange: PropTypes.func.isRequired,
     onSave: PropTypes.func.isRequired,
     onClear: PropTypes.func.isRequired,
   };
 
+  onPushChange = (key, checked) => {
+    this.props.onChange(['push', ...key], checked);
+  }
+
   render () {
-    const { settings, onChange, onClear } = this.props;
+    const { settings, pushSettings, onChange, onClear } = this.props;
 
     const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
     const showStr  = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
     const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
 
+    const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
+    const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
+    const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
+
     return (
       <div>
         <div className='column-settings__row'>
@@ -30,7 +39,8 @@ export default class ColumnSettings extends React.PureComponent {
         <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 
         <div className='column-settings__row'>
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+          <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+          {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
           <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
           <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
         </div>
@@ -38,7 +48,8 @@ export default class ColumnSettings extends React.PureComponent {
         <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
 
         <div className='column-settings__row'>
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+          <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+          {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
           <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
           <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
         </div>
@@ -46,7 +57,8 @@ export default class ColumnSettings extends React.PureComponent {
         <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
 
         <div className='column-settings__row'>
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+          <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+          {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
           <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
           <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
         </div>
@@ -54,7 +66,8 @@ export default class ColumnSettings extends React.PureComponent {
         <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
 
         <div className='column-settings__row'>
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+          <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+          {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
           <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
           <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
         </div>
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
index 510820358..be1ff91d6 100644
--- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
@@ -10,6 +10,7 @@ export default class SettingToggle extends React.PureComponent {
     settings: ImmutablePropTypes.map.isRequired,
     settingKey: PropTypes.array.isRequired,
     label: PropTypes.node.isRequired,
+    meta: PropTypes.node,
     onChange: PropTypes.func.isRequired,
   }
 
@@ -18,13 +19,14 @@ export default class SettingToggle extends React.PureComponent {
   }
 
   render () {
-    const { prefix, settings, settingKey, label } = this.props;
+    const { prefix, settings, settingKey, label, meta } = this.props;
     const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
 
     return (
       <div className='setting-toggle'>
         <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} />
         <label htmlFor={id} className='setting-toggle__label'>{label}</label>
+        {meta && <span className='setting-meta__label'>{meta}</span>}
       </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 b139d4615..d4ead7881 100644
--- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -3,6 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl';
 import ColumnSettings from '../components/column_settings';
 import { changeSetting, saveSettings } from '../../../actions/settings';
 import { clearNotifications } from '../../../actions/notifications';
+import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications';
 import { openModal } from '../../../actions/modal';
 
 const messages = defineMessages({
@@ -12,16 +13,22 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   settings: state.getIn(['settings', 'notifications']),
+  pushSettings: state.get('push_notifications'),
 });
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onChange (key, checked) {
-    dispatch(changeSetting(['notifications', ...key], checked));
+    if (key[0] === 'push') {
+      dispatch(changePushNotifications(key.slice(1), checked));
+    } else {
+      dispatch(changeSetting(['notifications', ...key], checked));
+    }
   },
 
   onSave () {
     dispatch(saveSettings());
+    dispatch(savePushNotificationSettings());
   },
 
   onClear () {
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index cbc185a7d..515c377b9 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -9,7 +9,7 @@ import { links, getIndex, getLink } from './tabs_bar';
 import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import BundleColumnError from './bundle_column_error';
-import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline } from '../../ui/util/async-components';
+import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
 
 const componentMap = {
   'COMPOSE': Compose,
@@ -18,6 +18,7 @@ const componentMap = {
   'PUBLIC': PublicTimeline,
   'COMMUNITY': CommunityTimeline,
   'HASHTAG': HashtagTimeline,
+  'FAVOURITES': FavouritedStatuses,
 };
 
 export default class ColumnsArea extends ImmutablePureComponent {
@@ -32,12 +33,33 @@ export default class ColumnsArea extends ImmutablePureComponent {
     children: PropTypes.node,
   };
 
+  state = {
+    shouldAnimate: false,
+  }
+
+  componentWillReceiveProps() {
+    this.setState({ shouldAnimate: false });
+  }
+
+  componentDidMount() {
+    this.lastIndex = getIndex(this.context.router.history.location.pathname);
+    this.setState({ shouldAnimate: true });
+  }
+
+  componentDidUpdate() {
+    this.lastIndex = getIndex(this.context.router.history.location.pathname);
+    this.setState({ shouldAnimate: true });
+  }
+
   handleSwipe = (index) => {
-    window.requestAnimationFrame(() => {
-      window.requestAnimationFrame(() => {
-        this.context.router.history.push(getLink(index));
-      });
-    });
+    this.pendingIndex = index;
+  }
+
+  handleAnimationEnd = () => {
+    if (typeof this.pendingIndex === 'number') {
+      this.context.router.history.push(getLink(this.pendingIndex));
+      this.pendingIndex = null;
+    }
   }
 
   renderView = (link, index) => {
@@ -66,12 +88,14 @@ export default class ColumnsArea extends ImmutablePureComponent {
 
   render () {
     const { columns, children, singleColumn } = this.props;
+    const { shouldAnimate } = this.state;
 
     const columnIndex = getIndex(this.context.router.history.location.pathname);
+    this.pendingIndex = null;
 
     if (singleColumn) {
       return columnIndex !== -1 ? (
-        <ReactSwipeableViews index={columnIndex} onChangeIndex={this.handleSwipe} animateTransitions={false} style={{ height: '100%' }}>
+        <ReactSwipeableViews index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}>
           {links.map(this.renderView)}
         </ReactSwipeableViews>
       ) : <div className='columns-area'>{children}</div>;
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index d869fffa6..dcc9becd3 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -65,8 +65,6 @@ export default class MediaModal extends ImmutablePureComponent {
     const { media, intl, onClose } = this.props;
 
     const index = this.getIndex();
-    const attachment = media.get(index);
-    const url = attachment.get('url');
 
     let leftNav, rightNav, content;
 
@@ -77,16 +75,18 @@ export default class MediaModal extends ImmutablePureComponent {
       rightNav = <div role='button' tabIndex='0' className='modal-container__nav  modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
     }
 
-    if (attachment.get('type') === 'image') {
-      content = media.map((image) => {
-        const width  = image.getIn(['meta', 'original', 'width']) || null;
-        const height = image.getIn(['meta', 'original', 'height']) || null;
+    content = media.map((image) => {
+      const width  = image.getIn(['meta', 'original', 'width']) || null;
+      const height = image.getIn(['meta', 'original', 'height']) || null;
 
+      if (image.get('type') === 'image') {
         return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />;
-      }).toArray();
-    } else if (attachment.get('type') === 'gifv') {
-      content = <ExtendedVideoPlayer src={url} muted controls={false} />;
-    }
+      } else if (image.get('type') === 'gifv') {
+        return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />;
+      }
+
+      return null;
+    }).toArray();
 
     return (
       <div className='modal-root__modal media-modal'>
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index de4f44ce6..84461d9b5 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -56,12 +56,6 @@ export default class ModalRoot extends React.PureComponent {
     return { opacity: spring(0), scale: spring(0.98) };
   }
 
-  renderModal = (SpecificComponent) => {
-    const { props, onClose } = this.props;
-
-    return <SpecificComponent {...props} onClose={onClose} />;
-  }
-
   renderLoading = () => {
     return <ModalLoading />;
   }
@@ -97,7 +91,9 @@ export default class ModalRoot extends React.PureComponent {
               <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
                 <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
                 <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
-                  <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>{this.renderModal}</BundleContainer>
+                  <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
+                    {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
+                  </BundleContainer>
                 </div>
               </div>
             ))}
diff --git a/app/javascript/mastodon/load_polyfills.js b/app/javascript/mastodon/load_polyfills.js
index bc5468595..df7889118 100644
--- a/app/javascript/mastodon/load_polyfills.js
+++ b/app/javascript/mastodon/load_polyfills.js
@@ -20,11 +20,12 @@ function loadPolyfills() {
   );
 
   // Latest version of Firefox and Safari do not have IntersectionObserver.
-  // Edge does not have requestIdleCallback.
+  // Edge does not have requestIdleCallback and object-fit CSS property.
   // This avoids shipping them all the polyfills.
   const needsExtraPolyfills = !(
     window.IntersectionObserver &&
-    window.requestIdleCallback
+    window.requestIdleCallback &&
+    'object-fit' in (new Image()).style
   );
 
   return Promise.all([
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 6992e7e0f..7b890ce64 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "المُفَضَّلة :",
   "notifications.column_settings.follow": "متابعُون جُدُد :",
   "notifications.column_settings.mention": "الإشارات :",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "الترقيّات:",
   "notifications.column_settings.show": "إعرِضها في عمود",
   "notifications.column_settings.sound": "أصدر صوتا",
@@ -147,6 +149,7 @@
   "report.target": "إبلاغ",
   "search.placeholder": "ابحث",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
   "status.delete": "إحذف",
   "status.favourite": "أضف إلى المفضلة",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 7a56e1446..0cf6bf3ac 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Предпочитани:",
   "notifications.column_settings.follow": "Нови последователи:",
   "notifications.column_settings.mention": "Споменавания:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Споделяния:",
   "notifications.column_settings.show": "Покажи в колона",
   "notifications.column_settings.sound": "Play sound",
@@ -147,6 +149,7 @@
   "report.target": "Reporting",
   "search.placeholder": "Търсене",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Изтриване",
   "status.favourite": "Предпочитани",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index b2673915a..1e44d6fa5 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favorits:",
   "notifications.column_settings.follow": "Nous seguidors:",
   "notifications.column_settings.mention": "Mencions:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Mostrar en la columna",
   "notifications.column_settings.sound": "Reproduïr so",
@@ -147,6 +149,7 @@
   "report.target": "Informes",
   "search.placeholder": "Cercar",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
   "status.delete": "Esborrar",
   "status.favourite": "Favorit",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 4b62403c3..f73011e73 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favorisierungen:",
   "notifications.column_settings.follow": "Neue Folgende:",
   "notifications.column_settings.mention": "Erwähnungen:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Geteilte Beiträge:",
   "notifications.column_settings.show": "In der Spalte anzeigen",
   "notifications.column_settings.sound": "Ton abspielen",
@@ -147,6 +149,7 @@
   "report.target": "Melden",
   "search.placeholder": "Suche",
   "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Löschen",
   "status.favourite": "Favorisieren",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 36d82ec1a..368f68193 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -890,6 +890,14 @@
         "id": "notifications.column_settings.sound"
       },
       {
+        "defaultMessage": "Push notifications",
+        "id": "notifications.column_settings.push"
+      },
+      {
+        "defaultMessage": "This device",
+        "id": "notifications.column_settings.push_meta"
+      },
+      {
         "defaultMessage": "New followers:",
         "id": "notifications.column_settings.follow"
       },
@@ -967,6 +975,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "A look inside...",
+        "id": "standalone.public_title"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/standalone/public_timeline/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Delete",
         "id": "status.delete"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index d2e5f90ea..1d553d514 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -114,6 +114,8 @@
   "notifications.column_settings.favourite": "Favourites:",
   "notifications.column_settings.follow": "New followers:",
   "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Show in column",
   "notifications.column_settings.sound": "Play sound",
@@ -170,6 +172,7 @@
   "settings.media_fullwidth": "Full-width media previews",
   "settings.preferences": "User preferences",
   "settings.wide_view": "Wide view (Desktop mode only)",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.collapse": "Collapse",
   "status.delete": "Delete",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 2648a6840..4f9e26c25 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favoroj:",
   "notifications.column_settings.follow": "Novaj sekvantoj:",
   "notifications.column_settings.mention": "Mencioj:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Diskonigoj:",
   "notifications.column_settings.show": "Montri en kolono",
   "notifications.column_settings.sound": "Play sound",
@@ -147,6 +149,7 @@
   "report.target": "Reporting",
   "search.placeholder": "Serĉi",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Forigi",
   "status.favourite": "Favori",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index c42930380..64ba78716 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favoritos:",
   "notifications.column_settings.follow": "Nuevos seguidores:",
   "notifications.column_settings.mention": "Menciones:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Retoots:",
   "notifications.column_settings.show": "Mostrar en columna",
   "notifications.column_settings.sound": "Play sound",
@@ -147,6 +149,7 @@
   "report.target": "Reporting",
   "search.placeholder": "Buscar",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Borrar",
   "status.favourite": "Favorito",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index c9f1888b5..306937cc2 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "پسندیده‌ها:",
   "notifications.column_settings.follow": "پیگیران تازه:",
   "notifications.column_settings.mention": "نام‌بردن‌ها:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "بازبوق‌ها:",
   "notifications.column_settings.show": "نمایش در ستون",
   "notifications.column_settings.sound": "پخش صدا",
@@ -147,6 +149,7 @@
   "report.target": "گزارش‌دادن",
   "search.placeholder": "جستجو",
   "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
   "status.delete": "پاک‌کردن",
   "status.favourite": "پسندیدن",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index b836d2f5d..1b17fb155 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Tykkäyksiä:",
   "notifications.column_settings.follow": "Uusia seuraajia:",
   "notifications.column_settings.mention": "Mainintoja:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Buusteja:",
   "notifications.column_settings.show": "Näytä sarakkeessa",
   "notifications.column_settings.sound": "Play sound",
@@ -147,6 +149,7 @@
   "report.target": "Reporting",
   "search.placeholder": "Hae",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Poista",
   "status.favourite": "Tykkää",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index eaa01638c..b6605295b 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -29,7 +29,7 @@
   "column.favourites": "Favoris",
   "column.follow_requests": "Demandes de suivi",
   "column.home": "Accueil",
-  "column.mutes": "Comptes silencés",
+  "column.mutes": "Comptes masqués",
   "column.notifications": "Notifications",
   "column.public": "Fil public global",
   "column_back_button.label": "Retour",
@@ -52,9 +52,9 @@
   "confirmations.delete.confirm": "Supprimer",
   "confirmations.delete.message": "Confirmez vous la suppression de ce pouet ?",
   "confirmations.domain_block.confirm": "Masquer le domaine entier",
-  "confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou silenciations ciblés sont suffisants et préférables.",
-  "confirmations.mute.confirm": "Silencer",
-  "confirmations.mute.message": "Confirmez vous la silenciation {name} ?",
+  "confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou masquages ciblés sont suffisants et préférables.",
+  "confirmations.mute.confirm": "Masquer",
+  "confirmations.mute.message": "Confirmez vous le masquage de {name} ?",
   "emoji_button.activity": "Activités",
   "emoji_button.flags": "Drapeaux",
   "emoji_button.food": "Boire et manger",
@@ -96,7 +96,7 @@
   "navigation_bar.follow_requests": "Demandes de suivi",
   "navigation_bar.info": "Plus d’informations",
   "navigation_bar.logout": "Déconnexion",
-  "navigation_bar.mutes": "Comptes silencés",
+  "navigation_bar.mutes": "Comptes masqués",
   "navigation_bar.preferences": "Préférences",
   "navigation_bar.public_timeline": "Fil public global",
   "notification.favourite": "{name} a ajouté à ses favoris :",
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favoris :",
   "notifications.column_settings.follow": "Nouveaux⋅elles abonn⋅é⋅s :",
   "notifications.column_settings.mention": "Mentions :",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Partages :",
   "notifications.column_settings.show": "Afficher dans la colonne",
   "notifications.column_settings.sound": "Émettre un son",
@@ -147,6 +149,7 @@
   "report.target": "Signalement",
   "search.placeholder": "Rechercher",
   "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Cette publication ne peut être boostée",
   "status.delete": "Effacer",
   "status.favourite": "Ajouter aux favoris",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 98c7ea021..8b63bd26b 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "מחובבים:",
   "notifications.column_settings.follow": "עוקבים חדשים:",
   "notifications.column_settings.mention": "פניות:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "הדהודים:",
   "notifications.column_settings.show": "הצגה בטור",
   "notifications.column_settings.sound": "שמע מופעל",
@@ -147,6 +149,7 @@
   "report.target": "דיווח",
   "search.placeholder": "חיפוש",
   "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "לא ניתן להדהד הודעה זו",
   "status.delete": "מחיקה",
   "status.favourite": "חיבוב",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index fdf5c11c0..165e3088f 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favoriti:",
   "notifications.column_settings.follow": "Novi sljedbenici:",
   "notifications.column_settings.mention": "Spominjanja:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Prikaži u stupcu",
   "notifications.column_settings.sound": "Sviraj zvuk",
@@ -147,6 +149,7 @@
   "report.target": "Prijavljivanje",
   "search.placeholder": "Traži",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Ovaj post ne može biti podignut",
   "status.delete": "Obriši",
   "status.favourite": "Označi omiljenim",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index baf762c8d..71dcce505 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favourites:",
   "notifications.column_settings.follow": "New followers:",
   "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Show in column",
   "notifications.column_settings.sound": "Play sound",
@@ -147,6 +149,7 @@
   "report.target": "Reporting",
   "search.placeholder": "Keresés",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Törlés",
   "status.favourite": "Kedvenc",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 6f6d688e9..0c21877d8 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favorit:",
   "notifications.column_settings.follow": "Pengikut baru:",
   "notifications.column_settings.mention": "Balasan:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Boost:",
   "notifications.column_settings.show": "Tampilkan dalam kolom",
   "notifications.column_settings.sound": "Mainkan suara",
@@ -147,6 +149,7 @@
   "report.target": "Melaporkan",
   "search.placeholder": "Pencarian",
   "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Hapus",
   "status.favourite": "Difavoritkan",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index 25e0adc8a..788d09f34 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favorati:",
   "notifications.column_settings.follow": "Nova sequanti:",
   "notifications.column_settings.mention": "Mencioni:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Repeti:",
   "notifications.column_settings.show": "Montrar en kolumno",
   "notifications.column_settings.sound": "Plear sono",
@@ -147,6 +149,7 @@
   "report.target": "Denuncante",
   "search.placeholder": "Serchez",
   "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Efacar",
   "status.favourite": "Favorizar",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 4881b0f08..9176bfaaf 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Apprezzati:",
   "notifications.column_settings.follow": "Nuovi seguaci:",
   "notifications.column_settings.mention": "Menzioni:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Post condivisi:",
   "notifications.column_settings.show": "Mostra in colonna",
   "notifications.column_settings.sound": "Riproduci suono",
@@ -147,6 +149,7 @@
   "report.target": "Invio la segnalazione",
   "search.placeholder": "Cerca",
   "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Elimina",
   "status.favourite": "Apprezzato",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index f62072852..a686cdc03 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "お気に入り",
   "notifications.column_settings.follow": "新しいフォロワー",
   "notifications.column_settings.mention": "返信",
+  "notifications.column_settings.push": "プッシュ通知",
+  "notifications.column_settings.push_meta": "このデバイス",
   "notifications.column_settings.reblog": "ブースト",
   "notifications.column_settings.show": "カラムに表示",
   "notifications.column_settings.sound": "通知音を再生",
@@ -147,6 +149,7 @@
   "report.target": "問題のユーザー",
   "search.placeholder": "検索",
   "search_results.total": "{count, number}件の結果",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "この投稿はブーストできません",
   "status.delete": "削除",
   "status.favourite": "お気に入り",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 5e1aaac85..0b47cc990 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "즐겨찾기",
   "notifications.column_settings.follow": "새 팔로워",
   "notifications.column_settings.mention": "답글",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "부스트",
   "notifications.column_settings.show": "컬럼에 표시",
   "notifications.column_settings.sound": "효과음 재생",
@@ -147,6 +149,7 @@
   "report.target": "문제가 된 사용자",
   "search.placeholder": "검색",
   "search_results.total": "{count, number}건의 결과",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
   "status.delete": "삭제",
   "status.favourite": "즐겨찾기",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 479d157f3..cf6a8bd31 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favorieten:",
   "notifications.column_settings.follow": "Nieuwe volgers:",
   "notifications.column_settings.mention": "Vermeldingen:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "In kolom tonen",
   "notifications.column_settings.sound": "Geluid afspelen",
@@ -147,6 +149,7 @@
   "report.target": "Rapporteren van",
   "search.placeholder": "Zoeken",
   "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Deze toot kan niet geboost worden",
   "status.delete": "Verwijderen",
   "status.favourite": "Favoriet",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 4bbf14938..1f4082d7b 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Likt:",
   "notifications.column_settings.follow": "Nye følgere:",
   "notifications.column_settings.mention": "Nevnt:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Fremhevet:",
   "notifications.column_settings.show": "Vis i kolonne",
   "notifications.column_settings.sound": "Spill lyd",
@@ -147,6 +149,7 @@
   "report.target": "Rapporterer",
   "search.placeholder": "Søk",
   "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Denne posten kan ikke fremheves",
   "status.delete": "Slett",
   "status.favourite": "Lik",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 2c119ef41..dc6dd5e32 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favorits :",
   "notifications.column_settings.follow": "Nòus seguidors :",
   "notifications.column_settings.mention": "Mencions :",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Partatges :",
   "notifications.column_settings.show": "Mostrar dins la colomna",
   "notifications.column_settings.sound": "Emetre un son",
@@ -147,6 +149,7 @@
   "report.target": "Senhalar {target}",
   "search.placeholder": "Recercar",
   "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
   "status.delete": "Escafar",
   "status.favourite": "Apondre als favorits",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index ac63ec40f..233d61995 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -3,10 +3,10 @@
   "account.block_domain": "Blokuj wszystko z {domain}",
   "account.disclaimer": "Ten użytkownik pochodzi z innej instancji. Ta liczba może być większa.",
   "account.edit_profile": "Edytuj profil",
-  "account.follow": "Obserwuj",
-  "account.followers": "Obserwujący",
-  "account.follows": "Obserwacje",
-  "account.follows_you": "Obserwuje cię",
+  "account.follow": "Śledź",
+  "account.followers": "Śledzący",
+  "account.follows": "Śledzeni",
+  "account.follows_you": "Śledzi Cię",
   "account.media": "Media",
   "account.mention": "Wspomnij o @{name}",
   "account.mute": "Wycisz @{name}",
@@ -15,7 +15,7 @@
   "account.requested": "Oczekująca prośba",
   "account.unblock": "Odblokuj @{name}",
   "account.unblock_domain": "Odblokuj domenę {domain}",
-  "account.unfollow": "Przestań obserwować",
+  "account.unfollow": "Przestań śledzić",
   "account.unmute": "Cofnij wyciszenie @{name}",
   "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
   "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.",
@@ -27,7 +27,7 @@
   "column.blocks": "Zablokowani użytkownicy",
   "column.community": "Lokalna oś czasu",
   "column.favourites": "Ulubione",
-  "column.follow_requests": "Prośby o obserwację",
+  "column.follow_requests": "Prośby o śledzenie",
   "column.home": "Strona główna",
   "column.mutes": "Wyciszeni użytkownicy",
   "column.notifications": "Powiadomienia",
@@ -37,9 +37,9 @@
   "column_header.unpin": "Cofnij przypięcie",
   "column_subheading.navigation": "Nawigacja",
   "column_subheading.settings": "Ustawienia",
-  "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto cię obserwuje, może wyświetlać twoje posty przeznaczone tylko dla obserwujących.",
+  "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje posty przeznaczone tylko dla śledzących.",
   "compose_form.lock_disclaimer.lock": "zablokowane",
-  "compose_form.placeholder": "Co ci chodzi po głowie?",
+  "compose_form.placeholder": "Co Ci chodzi po głowie?",
   "compose_form.privacy_disclaimer": "Twój post zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność postów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, post może być widoczny dla niewłaściwych osób.",
   "compose_form.publish": "Wyślij",
   "compose_form.publish_loud": "{publish}!",
@@ -67,7 +67,7 @@
   "emoji_button.travel": "Podróże i miejsca",
   "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby odbić piłeczkę!",
   "empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
-  "empty_column.home": "Nie obserwujesz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć ciekawych ludzi.",
+  "empty_column.home": "Nie śledzisz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
   "empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.",
   "empty_column.home.public_timeline": "publiczna oś czasu",
   "empty_column.notifications": "Nie masz żadnych powiadomień. Rozpocznij interakcje z innymi użytkownikami.",
@@ -93,32 +93,34 @@
   "navigation_bar.community_timeline": "Lokalna oś czasu",
   "navigation_bar.edit_profile": "Edytuj profil",
   "navigation_bar.favourites": "Ulubione",
-  "navigation_bar.follow_requests": "Prośby o obserwację",
+  "navigation_bar.follow_requests": "Prośby o śledzenie",
   "navigation_bar.info": "Szczegółowe informacje",
   "navigation_bar.logout": "Wyloguj",
   "navigation_bar.mutes": "Wyciszeni użytkownicy",
   "navigation_bar.preferences": "Preferencje",
   "navigation_bar.public_timeline": "Oś czasu federacji",
-  "notification.favourite": "{name} dodał twój status do ulubionych",
-  "notification.follow": "{name} zaczął cię obserwować",
+  "notification.favourite": "{name} dodał Twój status do ulubionych",
+  "notification.follow": "{name} zaczął Cię śledzić",
   "notification.mention": "{name} wspomniał o tobie",
-  "notification.reblog": "{name} podbił twój status",
+  "notification.reblog": "{name} podbił Twój status",
   "notifications.clear": "Wyczyść powiadomienia",
   "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
   "notifications.column_settings.alert": "Powiadomienia na pulpicie",
   "notifications.column_settings.favourite": "Ulubione:",
-  "notifications.column_settings.follow": "Nowi obserwujący:",
+  "notifications.column_settings.follow": "Nowi śledzący:",
   "notifications.column_settings.mention": "Wspomniali:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Podbili:",
   "notifications.column_settings.show": "Pokaż w kolumnie",
   "notifications.column_settings.sound": "Odtwarzaj dźwięk",
   "onboarding.done": "Gotowe",
   "onboarding.next": "Dalej",
-  "onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Federalna oś czasu wyświetla publiczne wpisy obserwowanych przez członków {domain}. Są to publiczne osie czasu – najlepszy sposób na poznanie nowych osób.",
+  "onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Federalna oś czasu wyświetla publiczne wpisy śledzonych przez członków {domain}. Są to publiczne osie czasu – najlepszy sposób na poznanie nowych osób.",
   "onboarding.page_four.home": "Główna oś czasu wyświetla publiczne wpisy.",
   "onboarding.page_four.notifications": "Kolumna powiadomień wyświetla, gdy ktoś dokonuje interakcji z tobą.",
   "onboarding.page_one.federation": "Mastodon jest siecią niezależnych serwerów połączonych w jeden portal społecznościowy. Nazywamy te serwery instancjami.",
-  "onboarding.page_one.handle": "Jesteś na domenie {domain}, więc twój pełny adres to {handle}",
+  "onboarding.page_one.handle": "Jesteś na domenie {domain}, więc Twój pełny adres to {handle}",
   "onboarding.page_one.welcome": "Witamy w Mastodon!",
   "onboarding.page_six.admin": "Administratorem tej instancji jest {admin}.",
   "onboarding.page_six.almost_done": "Prawie gotowe...",
@@ -135,8 +137,8 @@
   "privacy.change": "Dostosuj widoczność postów",
   "privacy.direct.long": "Widoczne tylko dla oznaczonych",
   "privacy.direct.short": "Bezpośrednio",
-  "privacy.private.long": "Widoczne tylko dla obserwujących",
-  "privacy.private.short": "Tylko obserwujący",
+  "privacy.private.long": "Widoczne tylko dla śledzących",
+  "privacy.private.short": "Tylko śledzący",
   "privacy.public.long": "Widoczne na publicznych osiach czasu",
   "privacy.public.short": "Publiczne",
   "privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
@@ -147,6 +149,7 @@
   "report.target": "Zgłaszanie {target}",
   "search.placeholder": "Szukaj",
   "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Ten post nie może zostać podbity",
   "status.delete": "Usuń",
   "status.favourite": "Ulubione",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index b199a39ce..cf2b911f2 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favoritos:",
   "notifications.column_settings.follow": "Novos seguidores:",
   "notifications.column_settings.mention": "Menções:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Partilhas:",
   "notifications.column_settings.show": "Mostrar nas colunas",
   "notifications.column_settings.sound": "Reproduzir som",
@@ -147,6 +149,7 @@
   "report.target": "Denunciar",
   "search.placeholder": "Pesquisar",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Eliminar",
   "status.favourite": "Adicionar aos favoritos",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index b199a39ce..cf2b911f2 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favoritos:",
   "notifications.column_settings.follow": "Novos seguidores:",
   "notifications.column_settings.mention": "Menções:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Partilhas:",
   "notifications.column_settings.show": "Mostrar nas colunas",
   "notifications.column_settings.sound": "Reproduzir som",
@@ -147,6 +149,7 @@
   "report.target": "Denunciar",
   "search.placeholder": "Pesquisar",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Eliminar",
   "status.favourite": "Adicionar aos favoritos",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index f9f48a48d..942a13ede 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Нравится:",
   "notifications.column_settings.follow": "Новые подписчики:",
   "notifications.column_settings.mention": "Упоминания:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Продвижения:",
   "notifications.column_settings.show": "Показывать в колонке",
   "notifications.column_settings.sound": "Проигрывать звук",
@@ -147,6 +149,7 @@
   "report.target": "Жалуемся на",
   "search.placeholder": "Поиск",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Этот статус не может быть продвинут",
   "status.delete": "Удалить",
   "status.favourite": "Нравится",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index 8a39beacb..e9e96c14f 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favourites:",
   "notifications.column_settings.follow": "New followers:",
   "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Show in column",
   "notifications.column_settings.sound": "Play sound",
@@ -147,6 +149,7 @@
   "report.target": "Reporting",
   "search.placeholder": "Search",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Delete",
   "status.favourite": "Favourite",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index 203e4a09e..adfa79cd9 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Favoriler:",
   "notifications.column_settings.follow": "Yeni takipçiler:",
   "notifications.column_settings.mention": "Bahsedilenler:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Boost’lar:",
   "notifications.column_settings.show": "Bildirimlerde göster",
   "notifications.column_settings.sound": "Ses çal",
@@ -147,6 +149,7 @@
   "report.target": "Raporlama",
   "search.placeholder": "Ara",
   "search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Bu gönderi boost edilemez",
   "status.delete": "Sil",
   "status.favourite": "Favorilere ekle",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index c0f4a8dbb..435067281 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "Вподобане:",
   "notifications.column_settings.follow": "Нові підписники:",
   "notifications.column_settings.mention": "Сповіщення:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "Передмухи:",
   "notifications.column_settings.show": "Показати в колонці",
   "notifications.column_settings.sound": "Відтворювати звук",
@@ -147,6 +149,7 @@
   "report.target": "Скаржимося на",
   "search.placeholder": "Пошук",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Цей допис не може бути передмухнутий",
   "status.delete": "Видалити",
   "status.favourite": "Подобається",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 998e1c8da..0f2c1fcec 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "你的嘟文被赞:",
   "notifications.column_settings.follow": "关注你:",
   "notifications.column_settings.mention": "提及你:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "你的嘟文被转嘟:",
   "notifications.column_settings.show": "在通知栏显示",
   "notifications.column_settings.sound": "播放音效",
@@ -147,6 +149,7 @@
   "report.target": "Reporting",
   "search.placeholder": "搜索",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "没法转嘟这条嘟文啦……",
   "status.delete": "删除",
   "status.favourite": "赞",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 1079d5429..c0b4cfce9 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "喜歡你的文章:",
   "notifications.column_settings.follow": "關注你:",
   "notifications.column_settings.mention": "提及你:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "轉推你的文章:",
   "notifications.column_settings.show": "在通知欄顯示",
   "notifications.column_settings.sound": "播放音效",
@@ -147,6 +149,7 @@
   "report.target": "舉報",
   "search.placeholder": "搜尋",
   "search_results.total": "{count, number} 項結果",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "這篇文章無法被轉推",
   "status.delete": "刪除",
   "status.favourite": "喜歡",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 6240b8879..772cc691c 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -109,6 +109,8 @@
   "notifications.column_settings.favourite": "最愛:",
   "notifications.column_settings.follow": "新的關注者:",
   "notifications.column_settings.mention": "提到:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
   "notifications.column_settings.reblog": "轉推:",
   "notifications.column_settings.show": "顯示在欄位中",
   "notifications.column_settings.sound": "播放音效",
@@ -147,6 +149,7 @@
   "report.target": "通報中",
   "search.placeholder": "搜尋",
   "search_results.total": "{count, number} 項結果",
+  "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "此貼文無法轉推",
   "status.delete": "刪除",
   "status.favourite": "喜愛",
diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js
index 90c2c5da2..b237e9aee 100644
--- a/app/javascript/mastodon/main.js
+++ b/app/javascript/mastodon/main.js
@@ -1,12 +1,6 @@
-const perf = require('./performance');
+import ready from './ready';
 
-function onDomContentLoaded(callback) {
-  if (document.readyState !== 'loading') {
-    callback();
-  } else {
-    document.addEventListener('DOMContentLoaded', callback);
-  }
-}
+const perf = require('./performance');
 
 function main() {
   perf.start('main()');
@@ -24,11 +18,19 @@ function main() {
     }
   }
 
-  onDomContentLoaded(() => {
+  ready(() => {
     const mountNode = document.getElementById('mastodon');
     const props = JSON.parse(mountNode.getAttribute('data-props'));
 
     ReactDOM.render(<Mastodon {...props} />, mountNode);
+    if (process.env.NODE_ENV === 'production') {
+      // avoid offline in dev mode because it's harder to debug
+      const OfflinePluginRuntime = require('offline-plugin/runtime');
+      const WebPushSubscription = require('./web_push_subscription');
+
+      OfflinePluginRuntime.install();
+      WebPushSubscription.register();
+    }
     perf.stop('main()');
 
     // remember the initial URL
diff --git a/app/javascript/mastodon/ready.js b/app/javascript/mastodon/ready.js
new file mode 100644
index 000000000..dd543910b
--- /dev/null
+++ b/app/javascript/mastodon/ready.js
@@ -0,0 +1,7 @@
+export default function ready(loaded) {
+  if (['interactive', 'complete'].includes(document.readyState)) {
+    loaded();
+  } else {
+    document.addEventListener('DOMContentLoaded', loaded);
+  }
+}
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 6ac7b4b4a..0c5dbccab 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -126,7 +126,7 @@ const insertSuggestion = (state, position, token, completion) => {
 };
 
 const insertEmoji = (state, position, emojiData) => {
-  const emoji = emojiData.shortname;
+  const emoji = String.fromCodePoint(parseInt(emojiData.unicode, 16));
 
   return state.withMutations(map => {
     map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 35f30f601..42b66d15f 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -11,6 +11,7 @@ import statuses from './statuses';
 import relationships from './relationships';
 import settings from './settings';
 import local_settings from '../../glitch/reducers/local_settings';
+import push_notifications from './push_notifications';
 import status_lists from './status_lists';
 import cards from './cards';
 import reports from './reports';
@@ -33,7 +34,11 @@ const reducers = {
   statuses,
   relationships,
   settings,
+<<<<<<< HEAD
   local_settings,
+=======
+  push_notifications,
+>>>>>>> upstream
   cards,
   reports,
   contexts,
diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js
new file mode 100644
index 000000000..31a40d246
--- /dev/null
+++ b/app/javascript/mastodon/reducers/push_notifications.js
@@ -0,0 +1,51 @@
+import { STORE_HYDRATE } from '../actions/store';
+import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from '../actions/push_notifications';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  subscription: null,
+  alerts: new Immutable.Map({
+    follow: false,
+    favourite: false,
+    reblog: false,
+    mention: false,
+  }),
+  isSubscribed: false,
+  browserSupport: false,
+});
+
+export default function push_subscriptions(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE: {
+    const push_subscription = action.state.get('push_subscription');
+
+    if (push_subscription) {
+      return state
+        .set('subscription', new Immutable.Map({
+          id: push_subscription.get('id'),
+          endpoint: push_subscription.get('endpoint'),
+        }))
+        .set('alerts', push_subscription.get('alerts') || initialState.get('alerts'))
+        .set('isSubscribed', true);
+    }
+
+    return state;
+  }
+  case SET_SUBSCRIPTION:
+    return state
+      .set('subscription', new Immutable.Map({
+        id: action.subscription.id,
+        endpoint: action.subscription.endpoint,
+      }))
+      .set('alerts', new Immutable.Map(action.subscription.alerts))
+      .set('isSubscribed', true);
+  case SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case CLEAR_SUBSCRIPTION:
+    return initialState;
+  case ALERTS_CHANGE:
+    return state.setIn(action.key, action.value);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js
new file mode 100644
index 000000000..364b67066
--- /dev/null
+++ b/app/javascript/mastodon/service_worker/entry.js
@@ -0,0 +1 @@
+import './web_push_notifications';
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
new file mode 100644
index 000000000..1708aa9f7
--- /dev/null
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -0,0 +1,86 @@
+const handlePush = (event) => {
+  const options = event.data.json();
+
+  options.body = options.data.nsfw || options.data.content;
+  options.image = options.image || undefined; // Null results in a network request (404)
+  options.timestamp = options.timestamp && new Date(options.timestamp);
+
+  const expandAction = options.data.actions.find(action => action.todo === 'expand');
+
+  if (expandAction) {
+    options.actions = [expandAction];
+    options.hiddenActions = options.data.actions.filter(action => action !== expandAction);
+
+    options.data.hiddenImage = options.image;
+    options.image = undefined;
+  } else {
+    options.actions = options.data.actions;
+  }
+
+  event.waitUntil(self.registration.showNotification(options.title, options));
+};
+
+const cloneNotification = (notification) => {
+  const clone = {  };
+
+  for(var k in notification) {
+    clone[k] = notification[k];
+  }
+
+  return clone;
+};
+
+const expandNotification = (notification) => {
+  const nextNotification = cloneNotification(notification);
+
+  nextNotification.body = notification.data.content;
+  nextNotification.image = notification.data.hiddenImage;
+  nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
+
+  return self.registration.showNotification(nextNotification.title, nextNotification);
+};
+
+const makeRequest = (notification, action) =>
+  fetch(action.action, {
+    headers: {
+      'Authorization': `Bearer ${notification.data.access_token}`,
+      'Content-Type': 'application/json',
+    },
+    method: action.method,
+    credentials: 'include',
+  });
+
+const removeActionFromNotification = (notification, action) => {
+  const actions = notification.actions.filter(act => act.action !== action.action);
+
+  const nextNotification = cloneNotification(notification);
+
+  nextNotification.actions = actions;
+
+  return self.registration.showNotification(nextNotification.title, nextNotification);
+};
+
+const handleNotificationClick = (event) => {
+  const reactToNotificationClick = new Promise((resolve, reject) => {
+    if (event.action) {
+      const action = event.notification.data.actions.find(({ action }) => action === event.action);
+
+      if (action.todo === 'expand') {
+        resolve(expandNotification(event.notification));
+      } else if (action.todo === 'request') {
+        resolve(makeRequest(event.notification, action)
+          .then(() => removeActionFromNotification(event.notification, action)));
+      } else {
+        reject(`Unknown action: ${action.todo}`);
+      }
+    } else {
+      event.notification.close();
+      resolve(self.clients.openWindow(event.notification.data.url));
+    }
+  });
+
+  event.waitUntil(reactToNotificationClick);
+};
+
+self.addEventListener('push', handlePush);
+self.addEventListener('notificationclick', handleNotificationClick);
diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js
new file mode 100644
index 000000000..391d3bcec
--- /dev/null
+++ b/app/javascript/mastodon/web_push_subscription.js
@@ -0,0 +1,109 @@
+import axios from 'axios';
+import { store } from './containers/mastodon';
+import { setBrowserSupport, setSubscription, clearSubscription } from './actions/push_notifications';
+
+// Taken from https://www.npmjs.com/package/web-push
+const urlBase64ToUint8Array = (base64String) => {
+  const padding = '='.repeat((4 - base64String.length % 4) % 4);
+  const base64 = (base64String + padding)
+    .replace(/\-/g, '+')
+    .replace(/_/g, '/');
+
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+
+  for (let i = 0; i < rawData.length; ++i) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+  return outputArray;
+};
+
+const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
+
+const getRegistration = () => navigator.serviceWorker.ready;
+
+const getPushSubscription = (registration) =>
+  registration.pushManager.getSubscription()
+    .then(subscription => ({ registration, subscription }));
+
+const subscribe = (registration) =>
+  registration.pushManager.subscribe({
+    userVisibleOnly: true,
+    applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
+  });
+
+const unsubscribe = ({ registration, subscription }) =>
+  subscription ? subscription.unsubscribe().then(() => registration) : registration;
+
+const sendSubscriptionToBackend = (subscription) =>
+  axios.post('/api/web/push_subscriptions', {
+    data: subscription,
+  }).then(response => response.data);
+
+// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
+const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
+
+export function register () {
+  store.dispatch(setBrowserSupport(supportsPushNotifications));
+
+  if (supportsPushNotifications) {
+    if (!getApplicationServerKey()) {
+      // eslint-disable-next-line no-console
+      console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
+      return;
+    }
+
+    getRegistration()
+      .then(getPushSubscription)
+      .then(({ registration, subscription }) => {
+        if (subscription !== null) {
+          // We have a subscription, check if it is still valid
+          const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
+          const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
+          const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']);
+
+          // If the VAPID public key did not change and the endpoint corresponds
+          // to the endpoint saved in the backend, the subscription is valid
+          if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
+            return subscription;
+          } else {
+            // Something went wrong, try to subscribe again
+            return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend);
+          }
+        }
+
+        // No subscription, try to subscribe
+        return subscribe(registration).then(sendSubscriptionToBackend);
+      })
+      .then(subscription => {
+        // If we got a PushSubscription (and not a subscription object from the backend)
+        // it means that the backend subscription is valid (and was set during hydration)
+        if (!(subscription instanceof PushSubscription)) {
+          store.dispatch(setSubscription(subscription));
+        }
+      })
+      .catch(error => {
+        if (error.code === 20 && error.name === 'AbortError') {
+          // eslint-disable-next-line no-console
+          console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
+        } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
+          // eslint-disable-next-line no-console
+          console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
+        }
+
+        // Clear alerts and hide UI settings
+        store.dispatch(clearSubscription());
+
+        try {
+          getRegistration()
+            .then(getPushSubscription)
+            .then(unsubscribe);
+        } catch (e) {
+
+        }
+      });
+  } else {
+    // eslint-disable-next-line no-console
+    console.warn('Your browser does not support Web Push Notifications.');
+  }
+}
diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js
new file mode 100644
index 000000000..7b8ab5e5d
--- /dev/null
+++ b/app/javascript/packs/about.js
@@ -0,0 +1,24 @@
+import TimelineContainer from '../mastodon/containers/timeline_container';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import loadPolyfills from '../mastodon/load_polyfills';
+import ready from '../mastodon/ready';
+
+require.context('../images/', true);
+
+function loaded() {
+  const mountNode = document.getElementById('mastodon-timeline');
+
+  if (mountNode !== null) {
+    const props = JSON.parse(mountNode.getAttribute('data-props'));
+    ReactDOM.render(<TimelineContainer {...props} />, mountNode);
+  }
+}
+
+function main() {
+  ready(loaded);
+}
+
+loadPolyfills().then(main).catch(error => {
+  console.error(error);
+});
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 06cc1b53a..4865f3ec0 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -5,9 +5,7 @@ import emojify from '../mastodon/emoji';
 import { getLocale } from '../mastodon/locales';
 import loadPolyfills from '../mastodon/load_polyfills';
 import { processBio } from '../glitch/util/bio_metadata';
-import TimelineContainer from '../mastodon/containers/timeline_container';
-import React from 'react';
-import ReactDOM from 'react-dom';
+import ready from '../mastodon/ready';
 
 require.context('../images/', true);
 
@@ -40,21 +38,10 @@ function loaded() {
     const datetime = new Date(content.getAttribute('datetime'));
     content.textContent = relativeFormat.format(datetime);;
   });
-
-  const mountNode = document.getElementById('mastodon-timeline');
-
-  if (mountNode !== null) {
-    const props = JSON.parse(mountNode.getAttribute('data-props'));
-    ReactDOM.render(<TimelineContainer {...props} />, mountNode);
-  }
 }
 
 function main() {
-  if (['interactive', 'complete'].includes(document.readyState)) {
-    loaded();
-  } else {
-    document.addEventListener('DOMContentLoaded', loaded);
-  }
+  ready(loaded);
 
   delegate(document, '.video-player video', 'click', ({ target }) => {
     if (target.paused) {
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 9602d31fa..f12c8fbd1 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -1554,6 +1554,9 @@
 }
 
 .react-swipeable-view-container > * {
+  display: flex;
+  align-items: center;
+  justify-content: center;
   height: 100%;
 }
 
@@ -2007,6 +2010,7 @@
   width: 100%;
   margin: 0;
   color: $ui-base-color;
+  background: $simple-background-color;
   padding: 10px;
   font-family: inherit;
   font-size: 14px;
@@ -2029,7 +2033,6 @@
 
 .autosuggest-textarea__textarea {
   min-height: 100px;
-  background: $simple-background-color;
   border-radius: 4px 4px 0 0;
   padding-bottom: 0;
   padding-right: 10px + 22px;
@@ -2620,7 +2623,8 @@ button.icon-button.active i.fa-retweet {
   line-height: 24px;
 }
 
-.setting-toggle__label {
+.setting-toggle__label,
+.setting-meta__label {
   color: $ui-primary-color;
   display: inline-block;
   margin-bottom: 14px;
@@ -2628,6 +2632,11 @@ button.icon-button.active i.fa-retweet {
   vertical-align: middle;
 }
 
+.setting-meta__label {
+  color: $ui-primary-color;
+  float: right;
+}
+
 .empty-column-indicator,
 .error-column {
   color: lighten($ui-base-color, 20%);
@@ -2968,6 +2977,7 @@ button.icon-button.active i.fa-retweet {
   margin-left: 2px;
   width: 24px;
   outline: 0;
+  cursor: pointer;
 
   &:active,
   &:focus {
@@ -3297,6 +3307,7 @@ button.icon-button.active i.fa-retweet {
   max-height: 80vh;
   position: relative;
 
+  .extended-video-player,
   img,
   canvas,
   video {
@@ -3306,6 +3317,13 @@ button.icon-button.active i.fa-retweet {
     height: auto;
   }
 
+  .extended-video-player,
+  video {
+    display: flex;
+    width: 80vw;
+    height: 80vh;
+  }
+
   img,
   canvas {
     display: block;
diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss
index a91d0d72a..4966fbc21 100644
--- a/app/javascript/styles/rtl.scss
+++ b/app/javascript/styles/rtl.scss
@@ -45,6 +45,10 @@ body.rtl {
     margin-right: 8px;
   }
 
+  .setting-meta__label {
+    float: left;
+  }
+
   .status__avatar {
     left: auto;
     right: 10px;
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
new file mode 100644
index 000000000..0a70207bc
--- /dev/null
+++ b/app/lib/activitypub/adapter.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
+  def self.default_key_transform
+    :camel_lower
+  end
+
+  def serializable_hash(options = nil)
+    options = serialization_options(options)
+    serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
+    self.class.transform_key_casing!(serialized_hash, instance_options)
+  end
+end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
new file mode 100644
index 000000000..ec42bcad3
--- /dev/null
+++ b/app/lib/activitypub/tag_manager.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'singleton'
+
+class ActivityPub::TagManager
+  include Singleton
+  include RoutingHelper
+
+  COLLECTIONS = {
+    public: 'https://www.w3.org/ns/activitystreams#Public',
+  }.freeze
+
+  def url_for(target)
+    return target.url if target.respond_to?(:local?) && !target.local?
+
+    case target.object_type
+    when :person
+      short_account_url(target)
+    when :note, :comment, :activity
+      short_account_status_url(target.account, target)
+    end
+  end
+
+  def uri_for(target)
+    return target.uri if target.respond_to?(:local?) && !target.local?
+
+    case target.object_type
+    when :person
+      account_url(target)
+    when :note, :comment, :activity
+      account_status_url(target.account, target)
+    end
+  end
+
+  # Primary audience of a status
+  # Public statuses go out to primarily the public collection
+  # Unlisted and private statuses go out primarily to the followers collection
+  # Others go out only to the people they mention
+  def to(status)
+    case status.visibility
+    when 'public'
+      [COLLECTIONS[:public]]
+    when 'unlisted', 'private'
+      [account_followers_url(status.account)]
+    when 'direct'
+      status.mentions.map { |mention| uri_for(mention.account) }
+    end
+  end
+
+  # Secondary audience of a status
+  # Public statuses go out to followers as well
+  # Unlisted statuses go to the public as well
+  # Both of those and private statuses also go to the people mentioned in them
+  # Direct ones don't have a secondary audience
+  def cc(status)
+    cc = []
+
+    case status.visibility
+    when 'public'
+      cc << account_followers_url(status.account)
+    when 'unlisted'
+      cc << COLLECTIONS[:public]
+    end
+
+    cc.concat(status.mentions.map { |mention| uri_for(mention.account) }) unless status.direct_visibility?
+
+    cc
+  end
+end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 35b18fa1b..3b6796142 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -99,7 +99,7 @@ class FeedManager
     #return true if reggie === status.content || reggie === status.spoiler_text
     # extremely violent filtering code END
 
-    return true if status.reply? && status.in_reply_to_id.nil?
+    return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
 
     check_for_mutes = [status.account_id]
     check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
@@ -126,12 +126,13 @@ class FeedManager
   end
 
   def filter_from_mentions?(status, receiver_id)
+    return true if receiver_id == status.account_id
+
     check_for_blocks = [status.account_id]
     check_for_blocks.concat(status.mentions.pluck(:account_id))
     check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
 
-    should_filter   = receiver_id == status.account_id                                                                                   # Filter if I'm mentioning myself
-    should_filter ||= Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?                                     # or it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
+    should_filter   = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?                                     # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
     should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
 
     should_filter
diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb
index 6d48cae2f..5e02e6806 100644
--- a/app/lib/provider_discovery.rb
+++ b/app/lib/provider_discovery.rb
@@ -1,11 +1,9 @@
 # frozen_string_literal: true
 
 class ProviderDiscovery < OEmbed::ProviderDiscovery
-  extend HttpHelper
-
   class << self
     def discover_provider(url, options = {})
-      res    = http_client.get(url)
+      res    = Request.new(:get, url).perform
       format = options[:format]
 
       raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html'
diff --git a/app/lib/request.rb b/app/lib/request.rb
new file mode 100644
index 000000000..e73c5ac20
--- /dev/null
+++ b/app/lib/request.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+class Request
+  REQUEST_TARGET = '(request-target)'
+
+  include RoutingHelper
+
+  def initialize(verb, url, options = {})
+    @verb    = verb
+    @url     = Addressable::URI.parse(url).normalize
+    @options = options
+    @headers = {}
+
+    set_common_headers!
+  end
+
+  def on_behalf_of(account)
+    raise ArgumentError unless account.local?
+    @account = account
+  end
+
+  def add_headers(new_headers)
+    @headers.merge!(new_headers)
+  end
+
+  def perform
+    http_client.headers(headers).public_send(@verb, @url.to_s, @options)
+  end
+
+  def headers
+    (@account ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET)
+  end
+
+  private
+
+  def set_common_headers!
+    @headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
+    @headers['User-Agent']   = user_agent
+    @headers['Host']         = @url.host
+    @headers['Date']         = Time.now.utc.httpdate
+  end
+
+  def signature
+    key_id    = @account.to_webfinger_s
+    algorithm = 'rsa-sha256'
+    signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
+
+    "keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers}\",signature=\"#{signature}\""
+  end
+
+  def signed_string
+    @headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
+  end
+
+  def signed_headers
+    @headers.keys.join(' ').downcase
+  end
+
+  def user_agent
+    @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
+  end
+
+  def timeout
+    { write: 10, connect: 10, read: 10 }
+  end
+
+  def http_client
+    HTTP.timeout(:per_operation, timeout).follow
+  end
+end
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index f1a2234dc..5f87a2a48 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -70,7 +70,7 @@ class TagManager
 
     uri = Addressable::URI.new
     uri.host = domain.gsub(/[\/]/, '')
-    uri.normalize.host
+    uri.normalized_host
   end
 
   def same_acct?(canonical, needle)
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index e0e92b19d..c5da18029 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -23,6 +23,7 @@ class UserSettingsDecorator
     user.settings['delete_modal'] = delete_modal_preference
     user.settings['auto_play_gif'] = auto_play_gif_preference
     user.settings['system_font_ui'] = system_font_ui_preference
+    user.settings['noindex'] = noindex_preference
   end
 
   def merged_notification_emails
@@ -57,6 +58,10 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_auto_play_gif'
   end
 
+  def noindex_preference
+    boolean_cast_setting 'setting_noindex'
+  end
+
   def boolean_cast_setting(key)
     settings[key] == '1'
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index 49d2c88f6..9f8e22adf 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -47,6 +47,7 @@ class Account < ApplicationRecord
   include AccountInteractions
   include Attachmentable
   include Remotable
+  include EmojiHelper
 
   # Local users
   has_one :user, inverse_of: :account
@@ -129,7 +130,7 @@ class Account < ApplicationRecord
   end
 
   def subscription(webhook_url)
-    OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 86_400 * 30, webhook: webhook_url, hub: hub_url)
+    OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url)
   end
 
   def save_with_optional_media!
@@ -240,9 +241,18 @@ class Account < ApplicationRecord
 
   before_create :generate_keys
   before_validation :normalize_domain
+  before_validation :prepare_contents, if: :local?
 
   private
 
+  def prepare_contents
+    display_name&.strip!
+    note&.strip!
+
+    self.display_name = emojify(display_name)
+    self.note         = emojify(note)
+  end
+
   def generate_keys
     return unless local?
 
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index b4f169649..1bd87a642 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -1,7 +1,6 @@
 # frozen_string_literal: true
 
 module Remotable
-  include HttpHelper
   extend ActiveSupport::Concern
 
   included do
@@ -20,7 +19,7 @@ module Remotable
         return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url
 
         begin
-          response = http_client.get(url)
+          response = Request.new(:get, url).perform
 
           return if response.code != 200
 
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 99dae9c1d..f26e8183f 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -8,7 +8,7 @@
 #  created_at   :datetime         not null
 #  updated_at   :datetime         not null
 #  severity     :integer          default("silence")
-#  reject_media :boolean
+#  reject_media :boolean          default(FALSE), not null
 #
 
 class DomainBlock < ApplicationRecord
diff --git a/app/models/import.rb b/app/models/import.rb
index 8c6253d49..815e02589 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -6,7 +6,7 @@
 #  id                :integer          not null, primary key
 #  account_id        :integer          not null
 #  type              :integer          not null
-#  approved          :boolean
+#  approved          :boolean          default(FALSE), not null
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
 #  data_file_name    :string
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 887e3e3bd..7eb16af8f 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -3,6 +3,17 @@
 #
 # Table name: session_activations
 #
+#  id                       :integer          not null, primary key
+#  user_id                  :integer          not null
+#  session_id               :string           not null
+#  created_at               :datetime         not null
+#  updated_at               :datetime         not null
+#  user_agent               :string           default(""), not null
+#  ip                       :inet
+#  access_token_id          :integer
+#  web_push_subscription_id :integer
+#
+
 #  id              :integer          not null, primary key
 #  user_id         :integer          not null
 #  session_id      :string           not null
@@ -15,6 +26,7 @@
 
 class SessionActivation < ApplicationRecord
   belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
+  belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy
 
   delegate :token,
            to: :access_token,
diff --git a/app/models/status.rb b/app/models/status.rb
index 791d96df1..24eaf7071 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -12,12 +12,12 @@
 #  in_reply_to_id         :integer
 #  reblog_of_id           :integer
 #  url                    :string
-#  sensitive              :boolean          default(FALSE)
+#  sensitive              :boolean          default(FALSE), not null
 #  visibility             :integer          default("public"), not null
 #  in_reply_to_account_id :integer
 #  application_id         :integer
 #  spoiler_text           :text             default(""), not null
-#  reply                  :boolean          default(FALSE)
+#  reply                  :boolean          default(FALSE), not null
 #  favourites_count       :integer          default(0), not null
 #  reblogs_count          :integer          default(0), not null
 #  language               :string
@@ -29,6 +29,7 @@ class Status < ApplicationRecord
   include Streamable
   include Cacheable
   include StatusThreadingConcern
+  include EmojiHelper
 
   enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility
 
@@ -120,10 +121,11 @@ class Status < ApplicationRecord
     !sensitive? && media_attachments.any?
   end
 
-  before_validation :prepare_contents
+  before_validation :prepare_contents, if: :local?
   before_validation :set_reblog
   before_validation :set_visibility
   before_validation :set_conversation
+  before_validation :set_sensitivity
 
   class << self
     def not_in_filtered_languages(account)
@@ -240,6 +242,9 @@ class Status < ApplicationRecord
   def prepare_contents
     text&.strip!
     spoiler_text&.strip!
+
+    self.text         = emojify(text)
+    self.spoiler_text = emojify(spoiler_text)
   end
 
   def set_reblog
@@ -248,6 +253,11 @@ class Status < ApplicationRecord
 
   def set_visibility
     self.visibility = (account.locked? ? :private : :public) if visibility.nil?
+    self.sensitive  = false if sensitive.nil?
+  end
+
+  def set_sensitivity
+    self.sensitive = sensitive || spoiler_text.present?
   end
 
   def set_conversation
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 35a228df0..bf643c1f9 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -1,5 +1,4 @@
 # frozen_string_literal: true
-
 # == Schema Information
 #
 # Table name: subscriptions
@@ -13,11 +12,12 @@
 #  created_at                  :datetime         not null
 #  updated_at                  :datetime         not null
 #  last_successful_delivery_at :datetime
+#  domain                      :string
 #
 
 class Subscription < ApplicationRecord
-  MIN_EXPIRATION = 7.days.seconds.to_i
-  MAX_EXPIRATION = 30.days.seconds.to_i
+  MIN_EXPIRATION = 1.day.to_i
+  MAX_EXPIRATION = 30.days.to_i
 
   belongs_to :account, required: true
 
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 08e3c1b03..0fa08e157 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -12,9 +12,10 @@
 class Tag < ApplicationRecord
   has_and_belongs_to_many :statuses
 
-  HASHTAG_RE = /(?:^|[^\/\)\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
+  HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_][[:word:]_]*'
+  HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
 
-  validates :name, presence: true, uniqueness: true
+  validates :name, presence: true, uniqueness: true, format: { with: /\A#{HASHTAG_NAME_RE}\z/i }
 
   def to_param
     name
@@ -23,7 +24,7 @@ class Tag < ApplicationRecord
   class << self
     def search_for(term, limit = 5)
       pattern = sanitize_sql_like(term) + '%'
-      Tag.where('name like ?', pattern).order(:name).limit(limit)
+      Tag.where('lower(name) like lower(?)', pattern).order(:name).limit(limit)
     end
   end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index c80115a08..becf0018f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -17,7 +17,7 @@
 #  last_sign_in_at           :datetime
 #  current_sign_in_ip        :inet
 #  last_sign_in_ip           :inet
-#  admin                     :boolean          default(FALSE)
+#  admin                     :boolean          default(FALSE), not null
 #  confirmation_token        :string
 #  confirmed_at              :datetime
 #  confirmation_sent_at      :datetime
@@ -27,7 +27,7 @@
 #  encrypted_otp_secret_iv   :string
 #  encrypted_otp_secret_salt :string
 #  consumed_timestep         :integer
-#  otp_required_for_login    :boolean
+#  otp_required_for_login    :boolean          default(FALSE), not null
 #  last_emailed_at           :datetime
 #  otp_backup_codes          :string           is an Array
 #  filtered_languages        :string           default([]), not null, is an Array
@@ -99,6 +99,10 @@ class User < ApplicationRecord
     settings.system_font_ui
   end
 
+  def setting_noindex
+    settings.noindex
+  end
+
   def activate_session(request)
     session_activations.activate(session_id: SecureRandom.hex,
                                  user_agent: request.user_agent,
@@ -113,6 +117,10 @@ class User < ApplicationRecord
     session_activations.active? id
   end
 
+  def web_push_subscription(session)
+    session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload
+  end
+
   protected
 
   def send_devise_notification(notification, *args)
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
new file mode 100644
index 000000000..4440706a6
--- /dev/null
+++ b/app/models/web/push_subscription.rb
@@ -0,0 +1,190 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: web_push_subscriptions
+#
+#  id         :integer          not null, primary key
+#  endpoint   :string           not null
+#  key_p256dh :string           not null
+#  key_auth   :string           not null
+#  data       :json
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class Web::PushSubscription < ApplicationRecord
+  include RoutingHelper
+  include StreamEntriesHelper
+  include ActionView::Helpers::TranslationHelper
+  include ActionView::Helpers::SanitizeHelper
+
+  has_one :session_activation
+
+  before_create :send_welcome_notification
+
+  def push(notification)
+    return unless pushable? notification
+
+    name = display_name notification.from_account
+    title = title_str(name, notification)
+    body = body_str notification
+    dir = dir_str body
+    url = url_str notification
+    image = image_str notification
+    actions = actions_arr notification
+
+    access_token = actions.empty? ? nil : find_or_create_access_token(notification).token
+    nsfw = notification.target_status.nil? || notification.target_status.spoiler_text.empty? ? nil : notification.target_status.spoiler_text
+
+    # TODO: Make sure that the payload does not exceed 4KB - Webpush::PayloadTooLarge
+    # TODO: Queue the requests - Webpush::TooManyRequests
+    Webpush.payload_send(
+      message: JSON.generate(
+        title: title,
+        dir: dir,
+        image: image,
+        badge: full_asset_url('badge.png'),
+        tag: notification.id,
+        timestamp: notification.created_at,
+        icon: notification.from_account.avatar_static_url,
+        data: {
+          content: decoder.decode(strip_tags(body)),
+          nsfw: nsfw.nil? ? nil : decoder.decode(strip_tags(nsfw)),
+          url: url,
+          actions: actions,
+          access_token: access_token,
+        }
+      ),
+      endpoint: endpoint,
+      p256dh: key_p256dh,
+      auth: key_auth,
+      vapid: {
+        # subject: "mailto:#{Setting.site_contact_email}",
+        private_key: Rails.configuration.x.vapid_private_key,
+        public_key: Rails.configuration.x.vapid_public_key,
+      },
+      ttl: 40 * 60 * 60 # 48 hours
+    )
+  end
+
+  def as_payload
+    payload = {
+      id: id,
+      endpoint: endpoint,
+    }
+
+    payload[:alerts] = data['alerts'] if data && data.key?('alerts')
+
+    payload
+  end
+
+  private
+
+  def title_str(name, notification)
+    case notification.type
+    when :mention then translate('push_notifications.mention.title', name: name)
+    when :follow then translate('push_notifications.follow.title', name: name)
+    when :favourite then translate('push_notifications.favourite.title', name: name)
+    when :reblog then translate('push_notifications.reblog.title', name: name)
+    end
+  end
+
+  def body_str(notification)
+    case notification.type
+    when :mention then notification.target_status.text
+    when :follow then notification.from_account.note
+    when :favourite then notification.target_status.text
+    when :reblog then notification.target_status.text
+    end
+  end
+
+  def url_str(notification)
+    case notification.type
+    when :mention then web_url("statuses/#{notification.target_status.id}")
+    when :follow then web_url("accounts/#{notification.from_account.id}")
+    when :favourite then web_url("statuses/#{notification.target_status.id}")
+    when :reblog then web_url("statuses/#{notification.target_status.id}")
+    end
+  end
+
+  def actions_arr(notification)
+    actions =
+      case notification.type
+      when :mention then [
+        {
+          title: translate('push_notifications.mention.action_favourite'),
+          icon: full_asset_url('emoji/2764.png'),
+          todo: 'request',
+          method: 'POST',
+          action: "/api/v1/statuses/#{notification.target_status.id}/favourite",
+        },
+      ]
+      else []
+      end
+
+    should_hide = notification.type.equal?(:mention) && !notification.target_status.nil? && (notification.target_status.sensitive || !notification.target_status.spoiler_text.empty?)
+    can_boost = notification.type.equal?(:mention) && !notification.target_status.nil? && !notification.target_status.hidden?
+
+    if should_hide
+      actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('emoji/1f441.png'), todo: 'expand', action: 'expand')
+    end
+
+    if can_boost
+      actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('emoji/1f504.png'), todo: 'request', method: 'POST', action: "/api/v1/statuses/#{notification.target_status.id}/reblog" }
+    end
+
+    actions
+  end
+
+  def image_str(notification)
+    return nil if notification.target_status.nil? || notification.target_status.media_attachments.empty?
+
+    full_asset_url(notification.target_status.media_attachments.first.file.url(:small))
+  end
+
+  def dir_str(body)
+    rtl?(body) ? 'rtl' : 'ltr'
+  end
+
+  def pushable?(notification)
+    data && data.key?('alerts') && data['alerts'][notification.type.to_s]
+  end
+
+  def send_welcome_notification
+    Webpush.payload_send(
+      message: JSON.generate(
+        title: translate('push_notifications.subscribed.title'),
+        icon: full_asset_url('android-chrome-192x192.png'),
+        badge: full_asset_url('badge.png'),
+        data: {
+          content: translate('push_notifications.subscribed.body'),
+          actions: [],
+          url: web_url('notifications'),
+        }
+      ),
+      endpoint: endpoint,
+      p256dh: key_p256dh,
+      auth: key_auth,
+      vapid: {
+        # subject: "mailto:#{Setting.site_contact_email}",
+        private_key: Rails.configuration.x.vapid_private_key,
+        public_key: Rails.configuration.x.vapid_public_key,
+      },
+      ttl: 5 * 60 # 5 minutes
+    )
+  end
+
+  def find_or_create_access_token(notification)
+    Doorkeeper::AccessToken.find_or_create_for(
+      Doorkeeper::Application.find_by(superapp: true),
+      notification.account.user.id,
+      Doorkeeper::OAuth::Scopes.from_string('read write follow'),
+      Doorkeeper.configuration.access_token_expires_in,
+      Doorkeeper.configuration.refresh_token_enabled?
+    )
+  end
+
+  def decoder
+    @decoder ||= HTMLEntities.new
+  end
+end
diff --git a/app/presenters/activitypub/collection_presenter.rb b/app/presenters/activitypub/collection_presenter.rb
new file mode 100644
index 000000000..6bae2955e
--- /dev/null
+++ b/app/presenters/activitypub/collection_presenter.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ActivityPub::CollectionPresenter < ActiveModelSerializers::Model
+  attributes :id, :type, :current, :size, :items
+end
diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb
index 75fef28a8..9507aad4a 100644
--- a/app/presenters/initial_state_presenter.rb
+++ b/app/presenters/initial_state_presenter.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 class InitialStatePresenter < ActiveModelSerializers::Model
-  attributes :settings, :token, :current_account, :admin
+  attributes :settings, :push_subscription, :token, :current_account, :admin
 end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index caf00791a..03294015f 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -3,7 +3,7 @@
 class StatusRelationshipsPresenter
   attr_reader :reblogs_map, :favourites_map, :mutes_map
 
-  def initialize(statuses, current_account_id = nil)
+  def initialize(statuses, current_account_id = nil, reblogs_map: {}, favourites_map: {}, mutes_map: {})
     if current_account_id.nil?
       @reblogs_map    = {}
       @favourites_map = {}
@@ -11,9 +11,9 @@ class StatusRelationshipsPresenter
     else
       status_ids       = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
       conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq
-      @reblogs_map     = Status.reblogs_map(status_ids, current_account_id)
-      @favourites_map  = Status.favourites_map(status_ids, current_account_id)
-      @mutes_map       = Status.mutes_map(conversation_ids, current_account_id)
+      @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(reblogs_map)
+      @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(favourites_map)
+      @mutes_map       = Status.mutes_map(conversation_ids, current_account_id).merge(mutes_map)
     end
   end
 end
diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb
new file mode 100644
index 000000000..69e2160c5
--- /dev/null
+++ b/app/serializers/activitypub/activity_serializer.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class ActivityPub::ActivitySerializer < ActiveModel::Serializer
+  attributes :id, :type, :actor, :to, :cc
+
+  has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer
+
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object), '/activity'].join
+  end
+
+  def type
+    object.reblog? ? 'Announce' : 'Create'
+  end
+
+  def actor
+    ActivityPub::TagManager.instance.uri_for(object.account)
+  end
+
+  def to
+    ActivityPub::TagManager.instance.to(object)
+  end
+
+  def cc
+    ActivityPub::TagManager.instance.cc(object)
+  end
+end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
new file mode 100644
index 000000000..56806152e
--- /dev/null
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class ActivityPub::ActorSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :id, :type, :following, :followers,
+             :inbox, :outbox, :preferred_username,
+             :name, :summary, :icon, :image
+
+  def id
+    account_url(object)
+  end
+
+  def type
+    'Person'
+  end
+
+  def following
+    account_following_index_url(object)
+  end
+
+  def followers
+    account_followers_url(object)
+  end
+
+  def inbox
+    nil
+  end
+
+  def outbox
+    account_outbox_url(object)
+  end
+
+  def preferred_username
+    object.username
+  end
+
+  def name
+    object.display_name
+  end
+
+  def summary
+    Formatter.instance.simplified_format(object)
+  end
+
+  def icon
+    full_asset_url(object.avatar.url(:original))
+  end
+
+  def image
+    full_asset_url(object.header.url(:original))
+  end
+end
diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb
new file mode 100644
index 000000000..baaba7654
--- /dev/null
+++ b/app/serializers/activitypub/collection_serializer.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class ActivityPub::CollectionSerializer < ActiveModel::Serializer
+  def self.serializer_for(model, options)
+    return ActivityPub::ActivitySerializer if model.class.name == 'Status'
+    super
+  end
+
+  attributes :id, :type, :total_items,
+             :current
+
+  has_many :items, key: :ordered_items
+
+  def type
+    case object.type
+    when :ordered
+      'OrderedCollection'
+    else
+      'Collection'
+    end
+  end
+
+  def total_items
+    object.size
+  end
+end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
new file mode 100644
index 000000000..ffdc6175d
--- /dev/null
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class ActivityPub::NoteSerializer < ActiveModel::Serializer
+  attributes :id, :type, :summary, :content,
+             :in_reply_to, :published, :url,
+             :actor, :to, :cc, :sensitive
+
+  has_many :media_attachments, key: :attachment
+  has_many :virtual_tags, key: :tag
+
+  def id
+    ActivityPub::TagManager.instance.uri_for(object)
+  end
+
+  def type
+    'Note'
+  end
+
+  def summary
+    object.spoiler_text.presence
+  end
+
+  def content
+    Formatter.instance.format(object)
+  end
+
+  def in_reply_to
+    ActivityPub::TagManager.instance.uri_for(object.thread) if object.reply?
+  end
+
+  def published
+    object.created_at.iso8601
+  end
+
+  def url
+    ActivityPub::TagManager.instance.url_for(object)
+  end
+
+  def actor
+    ActivityPub::TagManager.instance.uri_for(object.account)
+  end
+
+  def to
+    ActivityPub::TagManager.instance.to(object)
+  end
+
+  def cc
+    ActivityPub::TagManager.instance.cc(object)
+  end
+
+  def virtual_tags
+    object.mentions + object.tags
+  end
+
+  class MediaAttachmentSerializer < ActiveModel::Serializer
+    include RoutingHelper
+
+    attributes :type, :media_type, :url
+
+    def type
+      'Document'
+    end
+
+    def media_type
+      object.file_content_type
+    end
+
+    def url
+      object.local? ? full_asset_url(object.file.url(:original, false)) : object.remote_url
+    end
+  end
+
+  class MentionSerializer < ActiveModel::Serializer
+    attributes :type, :href, :name
+
+    def type
+      'Mention'
+    end
+
+    def href
+      ActivityPub::TagManager.instance.uri_for(object.account)
+    end
+
+    def name
+      "@#{object.account.acct}"
+    end
+  end
+
+  class TagSerializer < ActiveModel::Serializer
+    include RoutingHelper
+
+    attributes :type, :href, :name
+
+    def type
+      'Hashtag'
+    end
+
+    def href
+      tag_url(object)
+    end
+
+    def name
+      "##{object.name}"
+    end
+  end
+end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 6751c9411..704d29a57 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -2,7 +2,7 @@
 
 class InitialStateSerializer < ActiveModel::Serializer
   attributes :meta, :compose, :accounts,
-             :media_attachments, :settings
+             :media_attachments, :settings, :push_subscription
 
   def meta
     store = {
diff --git a/app/services/concerns/author_extractor.rb b/app/services/concerns/author_extractor.rb
index 00fe1c663..867d6dc25 100644
--- a/app/services/concerns/author_extractor.rb
+++ b/app/services/concerns/author_extractor.rb
@@ -14,7 +14,7 @@ module AuthorExtractor
 
       return nil if username.blank? || uri.blank?
 
-      domain = Addressable::URI.parse(uri).normalize.host
+      domain = Addressable::URI.parse(uri).normalized_host
       acct   = "#{username}@#{domain}"
     end
 
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index d430b22e9..3ac441e3e 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -1,16 +1,14 @@
 # frozen_string_literal: true
 
 class FetchAtomService < BaseService
-  include HttpHelper
-
   def call(url)
     return if url.blank?
 
-    response = http_client.head(url)
+    response = Request.new(:head, url).perform
 
     Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
 
-    response = http_client.get(url) if response.code == 405
+    response = Request.new(:get, url).perform if response.code == 405
 
     Rails.logger.debug "Remote status GET request returned code #{response.code}"
 
@@ -49,6 +47,6 @@ class FetchAtomService < BaseService
   end
 
   def fetch(url)
-    http_client.get(url).to_s
+    Request.new(:get, url).perform.to_s
   end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 6ef3abb66..20c85e0ea 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class FetchLinkCardService < BaseService
-  include HttpHelper
-
   URL_PATTERN = %r{https?://\S+}
 
   def call(status)
@@ -13,7 +11,7 @@ class FetchLinkCardService < BaseService
 
     url  = url.to_s
     card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
-    res  = http_client.head(url)
+    res  = Request.new(:head, url).perform
 
     return if res.code != 200 || res.mime_type != 'text/html'
 
@@ -80,7 +78,7 @@ class FetchLinkCardService < BaseService
   end
 
   def attempt_opengraph(card, url)
-    response = http_client.get(url)
+    response = Request.new(:get, url).perform
 
     return if response.code != 200 || response.mime_type != 'text/html'
 
diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb
index 4cfd33d90..6ac31e4d8 100644
--- a/app/services/fetch_remote_status_service.rb
+++ b/app/services/fetch_remote_status_service.rb
@@ -24,7 +24,7 @@ class FetchRemoteStatusService < BaseService
     xml.encoding = 'utf-8'
 
     account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS))
-    domain  = Addressable::URI.parse(url).normalize.host
+    domain  = Addressable::URI.parse(url).normalized_host
 
     return nil unless !account.nil? && confirmed_domain?(domain, account)
 
@@ -39,6 +39,6 @@ class FetchRemoteStatusService < BaseService
   end
 
   def confirmed_domain?(domain, account)
-    account.domain.nil? || domain.casecmp(account.domain).zero? || domain.casecmp(Addressable::URI.parse(account.remote_url).normalize.host).zero?
+    account.domain.nil? || domain.casecmp(account.domain).zero? || domain.casecmp(Addressable::URI.parse(account.remote_url).normalized_host).zero?
   end
 end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 407d385ea..0ab61b634 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -61,6 +61,11 @@ class NotifyService < BaseService
     @notification.save!
     return unless @notification.browserable?
     Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
+    send_push_notifications
+  end
+
+  def send_push_notifications
+    WebPushNotificationWorker.perform_async(@recipient.id, @notification.id)
   end
 
   def send_email
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 9fb1a2b12..0ecd8a9cd 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -21,6 +21,7 @@ class PostStatusService < BaseService
 
     media  = validate_media!(options[:media_ids])
     status = nil
+
     ApplicationRecord.transaction do
       status = account.statuses.create!(text: text,
                                         thread: in_reply_to,
@@ -31,6 +32,7 @@ class PostStatusService < BaseService
                                         application: options[:application])
       attach_media(status, media)
     end
+
     process_mentions_service.call(status)
     process_hashtags_service.call(status)
 
diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb
index eeb7ab258..2dba05b12 100644
--- a/app/services/pubsubhubbub/subscribe_service.rb
+++ b/app/services/pubsubhubbub/subscribe_service.rb
@@ -3,13 +3,15 @@
 class Pubsubhubbub::SubscribeService < BaseService
   URL_PATTERN = /\A#{URI.regexp(%w(http https))}\z/
 
-  attr_reader :account, :callback, :secret, :lease_seconds
+  attr_reader :account, :callback, :secret,
+              :lease_seconds, :domain
 
-  def call(account, callback, secret, lease_seconds)
+  def call(account, callback, secret, lease_seconds, verified_domain = nil)
     @account       = account
     @callback      = Addressable::URI.parse(callback).normalize.to_s
     @secret        = secret
     @lease_seconds = lease_seconds
+    @domain        = verified_domain
 
     process_subscribe
   end
@@ -56,6 +58,14 @@ class Pubsubhubbub::SubscribeService < BaseService
   end
 
   def locate_subscription
-    Subscription.where(account: account, callback_url: callback).first_or_create!(account: account, callback_url: callback)
+    subscription = Subscription.find_by(account: account, callback_url: callback)
+
+    if subscription.nil?
+      subscription = Subscription.new(account: account, callback_url: callback)
+    end
+
+    subscription.domain = domain
+    subscription.save!
+    subscription
   end
 end
diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb
index 362d0df98..d2dfda824 100644
--- a/app/services/resolve_remote_account_service.rb
+++ b/app/services/resolve_remote_account_service.rb
@@ -2,7 +2,6 @@
 
 class ResolveRemoteAccountService < BaseService
   include OStatus2::MagicKey
-  include HttpHelper
 
   DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
 
@@ -79,7 +78,7 @@ class ResolveRemoteAccountService < BaseService
   end
 
   def get_feed(url)
-    response = http_client(write: 20, connect: 20, read: 50).get(Addressable::URI.parse(url).normalize)
+    response = Request.new(:get, url).perform
     raise Goldfinger::Error, "Feed attempt failed for #{url}: HTTP #{response.code}" unless response.code == 200
     [response.to_s, Nokogiri::XML(response)]
   end
diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb
index 34c8f9e34..ef38a748b 100644
--- a/app/services/send_interaction_service.rb
+++ b/app/services/send_interaction_service.rb
@@ -12,13 +12,23 @@ class SendInteractionService < BaseService
 
     return if block_notification?
 
-    envelope = salmon.pack(@xml, @source_account.keypair)
-    delivery = salmon.post(@target_account.salmon_url, envelope)
+    delivery = build_request.perform
+
     raise "Delivery failed for #{target_account.salmon_url}: HTTP #{delivery.code}" unless delivery.code > 199 && delivery.code < 300
   end
 
   private
 
+  def build_request
+    request = Request.new(:post, @target_account.salmon_url, body: envelope)
+    request.add_headers('Content-Type' => 'application/magic-envelope+xml')
+    request
+  end
+
+  def envelope
+    salmon.pack(@xml, @source_account.keypair)
+  end
+
   def block_notification?
     DomainBlock.blocked?(@target_account.domain)
   end
diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb
index 1e7984a7f..f58067038 100644
--- a/app/services/subscribe_service.rb
+++ b/app/services/subscribe_service.rb
@@ -2,34 +2,54 @@
 
 class SubscribeService < BaseService
   def call(account)
-    account.secret = SecureRandom.hex
+    @account        = account
+    @account.secret = SecureRandom.hex
+    @response       = build_request.perform
 
-    subscription = account.subscription(api_subscription_url(account.id))
-    response     = subscription.subscribe
-
-    if response_failed_permanently?(response)
+    if response_failed_permanently?
       # We're not allowed to subscribe. Fail and move on.
-      account.secret = ''
-      account.save!
-    elsif response_successful?(response)
+      @account.secret = ''
+      @account.save!
+    elsif response_successful?
       # The subscription will be confirmed asynchronously.
-      account.save!
+      @account.save!
     else
       # The response was either a 429 rate limit, or a 5xx error.
       # We need to retry at a later time. Fail loudly!
-      raise "Subscription attempt failed for #{account.acct} (#{account.hub_url}): HTTP #{response.code}"
+      raise "Subscription attempt failed for #{@account.acct} (#{@account.hub_url}): HTTP #{@response.code}"
     end
   end
 
   private
 
+  def build_request
+    request = Request.new(:post, @account.hub_url, form: subscription_params)
+    request.on_behalf_of(some_local_account) if some_local_account
+    request
+  end
+
+  def subscription_params
+    {
+      'hub.topic': @account.remote_url,
+      'hub.mode': 'subscribe',
+      'hub.callback': api_subscription_url(@account.id),
+      'hub.verify': 'async',
+      'hub.secret': @account.secret,
+      'hub.lease_seconds': 7.days.seconds,
+    }
+  end
+
+  def some_local_account
+    @some_local_account ||= Account.local.first
+  end
+
   # Any response in the 3xx or 4xx range, except for 429 (rate limit)
-  def response_failed_permanently?(response)
-    (response.status.redirect? || response.status.client_error?) && !response.status.too_many_requests?
+  def response_failed_permanently?
+    (@response.status.redirect? || @response.status.client_error?) && !@response.status.too_many_requests?
   end
 
   # Any response in the 2xx range
-  def response_successful?(response)
-    response.status.success?
+  def response_successful?
+    @response.status.success?
   end
 end
diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb
index 6db8dbdc4..c2f022d7d 100644
--- a/app/services/unsubscribe_service.rb
+++ b/app/services/unsubscribe_service.rb
@@ -2,17 +2,30 @@
 
 class UnsubscribeService < BaseService
   def call(account)
-    subscription = account.subscription(api_subscription_url(account.id))
-    response = subscription.unsubscribe
+    @account  = account
+    @response = build_request.perform
 
-    unless response.status.success?
-      Rails.logger.debug "PuSH unsubscribe for #{account.acct} failed: #{response.status}"
-    end
+    Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success?
 
-    account.secret = ''
-    account.subscription_expires_at = nil
-    account.save!
+    @account.secret = ''
+    @account.subscription_expires_at = nil
+    @account.save!
   rescue HTTP::Error, OpenSSL::SSL::SSLError
-    Rails.logger.debug "PuSH subscription request for #{account.acct} could not be made due to HTTP or SSL error"
+    Rails.logger.debug "PuSH subscription request for #{@account.acct} could not be made due to HTTP or SSL error"
+  end
+
+  private
+
+  def build_request
+    Request.new(:post, @account.hub_url, form: subscription_params)
+  end
+
+  def subscription_params
+    {
+      'hub.topic': @account.remote_url,
+      'hub.mode': 'unsubscribe',
+      'hub.callback': api_subscription_url(@account.id),
+      'hub.verify': 'async',
+    }
   end
 end
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index f75f87c99..fd468bba0 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -1,11 +1,10 @@
-- content_for :header_tags do
-  %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
-  = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
-
 - content_for :page_title do
   = site_hostname
 
 - content_for :header_tags do
+  %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
+  = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
+
   %meta{ property: 'og:site_name', content: site_title }/
   %meta{ property: 'og:url', content: about_url }/
   %meta{ property: 'og:type', content: 'website' }/
diff --git a/app/views/accounts/show.activitystreams2.rabl b/app/views/accounts/show.activitystreams2.rabl
deleted file mode 100644
index 2c0a4ad3a..000000000
--- a/app/views/accounts/show.activitystreams2.rabl
+++ /dev/null
@@ -1,9 +0,0 @@
-extends 'activitypub/types/person.activitystreams2.rabl'
-
-object @account
-
-attributes display_name: :name, username: :preferredUsername, note: :summary
-
-node(:icon)   { |account| full_asset_url(account.avatar.url(:original)) }
-node(:image)  { |account| full_asset_url(account.header.url(:original)) }
-node(:outbox) { |account| api_activitypub_outbox_url(account.id) }
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index a19049103..7ed634e5d 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -2,6 +2,9 @@
   = display_name(@account)
 
 - content_for :header_tags do
+  - if @account.user&.setting_noindex
+    %meta{ name: 'robots', content: 'noindex' }/
+
   %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/
   %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
 
diff --git a/app/views/activitypub/base.activitystreams2.rabl b/app/views/activitypub/base.activitystreams2.rabl
deleted file mode 100644
index c5e94997a..000000000
--- a/app/views/activitypub/base.activitystreams2.rabl
+++ /dev/null
@@ -1 +0,0 @@
-node(:'@context') { 'https://www.w3.org/ns/activitystreams' }
diff --git a/app/views/activitypub/intransient.activitystreams2.rabl b/app/views/activitypub/intransient.activitystreams2.rabl
deleted file mode 100644
index 968e451c2..000000000
--- a/app/views/activitypub/intransient.activitystreams2.rabl
+++ /dev/null
@@ -1,3 +0,0 @@
-extends 'activitypub/base.activitystreams2.rabl'
-
-node(:id) { request.original_url }
diff --git a/app/views/activitypub/types/announce.activitystreams2.rabl b/app/views/activitypub/types/announce.activitystreams2.rabl
deleted file mode 100644
index 4a29aa134..000000000
--- a/app/views/activitypub/types/announce.activitystreams2.rabl
+++ /dev/null
@@ -1,3 +0,0 @@
-extends 'activitypub/intransient.activitystreams2.rabl'
-
-node(:type) { 'Announce' }
diff --git a/app/views/activitypub/types/collection.activitystreams2.rabl b/app/views/activitypub/types/collection.activitystreams2.rabl
deleted file mode 100644
index cc0e532b7..000000000
--- a/app/views/activitypub/types/collection.activitystreams2.rabl
+++ /dev/null
@@ -1,3 +0,0 @@
-extends 'activitypub/intransient.activitystreams2.rabl'
-
-node(:type) { 'Collection' }
diff --git a/app/views/activitypub/types/create.activitystreams2.rabl b/app/views/activitypub/types/create.activitystreams2.rabl
deleted file mode 100644
index e41a056a7..000000000
--- a/app/views/activitypub/types/create.activitystreams2.rabl
+++ /dev/null
@@ -1,3 +0,0 @@
-extends 'activitypub/intransient.activitystreams2.rabl'
-
-node(:type) { 'Create' }
diff --git a/app/views/activitypub/types/note.activitystreams2.rabl b/app/views/activitypub/types/note.activitystreams2.rabl
deleted file mode 100644
index 39c74d4ba..000000000
--- a/app/views/activitypub/types/note.activitystreams2.rabl
+++ /dev/null
@@ -1,3 +0,0 @@
-extends 'activitypub/intransient.activitystreams2.rabl'
-
-node(:type) { 'Note' }
diff --git a/app/views/activitypub/types/ordered_collection.activitystreams2.rabl b/app/views/activitypub/types/ordered_collection.activitystreams2.rabl
deleted file mode 100644
index 2cda6f4d0..000000000
--- a/app/views/activitypub/types/ordered_collection.activitystreams2.rabl
+++ /dev/null
@@ -1,3 +0,0 @@
-extends 'activitypub/types/collection.activitystreams2.rabl'
-
-node(:type) { 'OrderedCollection' }
diff --git a/app/views/activitypub/types/ordered_collection_page.activitystreams2.rabl b/app/views/activitypub/types/ordered_collection_page.activitystreams2.rabl
deleted file mode 100644
index 9937d11e9..000000000
--- a/app/views/activitypub/types/ordered_collection_page.activitystreams2.rabl
+++ /dev/null
@@ -1,3 +0,0 @@
-extends 'activitypub/types/ordered_collection.activitystreams2.rabl'
-
-node(:type) { 'OrderedCollectionPage' }
diff --git a/app/views/activitypub/types/person.activitystreams2.rabl b/app/views/activitypub/types/person.activitystreams2.rabl
deleted file mode 100644
index 487a60791..000000000
--- a/app/views/activitypub/types/person.activitystreams2.rabl
+++ /dev/null
@@ -1,3 +0,0 @@
-extends 'activitypub/intransient.activitystreams2.rabl'
-
-node(:type) { 'Person' }
diff --git a/app/views/api/activitypub/activities/_show_status.activitystreams2.rabl b/app/views/api/activitypub/activities/_show_status.activitystreams2.rabl
deleted file mode 100644
index 472bf5dbd..000000000
--- a/app/views/api/activitypub/activities/_show_status.activitystreams2.rabl
+++ /dev/null
@@ -1,4 +0,0 @@
-object @status
-
-node(:actor)     { |status| TagManager.instance.url_for(status.account) }
-node(:published) { |status| status.created_at.to_time.xmlschema }
\ No newline at end of file
diff --git a/app/views/api/activitypub/activities/show_status_announce.activitystreams2.rabl b/app/views/api/activitypub/activities/show_status_announce.activitystreams2.rabl
deleted file mode 100644
index 44ac1ba2f..000000000
--- a/app/views/api/activitypub/activities/show_status_announce.activitystreams2.rabl
+++ /dev/null
@@ -1,8 +0,0 @@
-extends 'activitypub/types/announce.activitystreams2.rabl'
-extends 'api/activitypub/activities/_show_status.activitystreams2.rabl'
-
-object @status
-
-node(:name)   { |status| t('activitypub.activity.announce.name', account_name: account_name(status.account)) }
-node(:url)    { |status| TagManager.instance.url_for(status) }
-node(:object) { |status| api_activitypub_status_url(status.reblog_of_id) }
diff --git a/app/views/api/activitypub/activities/show_status_create.activitystreams2.rabl b/app/views/api/activitypub/activities/show_status_create.activitystreams2.rabl
deleted file mode 100644
index ff4d39eca..000000000
--- a/app/views/api/activitypub/activities/show_status_create.activitystreams2.rabl
+++ /dev/null
@@ -1,8 +0,0 @@
-extends 'activitypub/types/create.activitystreams2.rabl'
-extends 'api/activitypub/activities/_show_status.activitystreams2.rabl'
-
-object @status
-
-node(:name)   { |status| t('activitypub.activity.create.name', account_name: account_name(status.account)) }
-node(:url)    { |status| TagManager.instance.url_for(status) }
-node(:object) { |status| api_activitypub_note_url(status) }
diff --git a/app/views/api/activitypub/notes/show.activitystreams2.rabl b/app/views/api/activitypub/notes/show.activitystreams2.rabl
deleted file mode 100644
index d962f4438..000000000
--- a/app/views/api/activitypub/notes/show.activitystreams2.rabl
+++ /dev/null
@@ -1,11 +0,0 @@
-extends 'activitypub/types/note.activitystreams2.rabl'
-
-object @status
-
-attributes :content
-
-node(:name)         { |status| status.content }
-node(:url)          { |status| TagManager.instance.url_for(status) }
-node(:attributedTo) { |status| TagManager.instance.url_for(status.account) }
-node(:inReplyTo)    { |status| api_activitypub_note_url(status.thread) } if @status.thread
-node(:published)    { |status| status.created_at.to_time.xmlschema }
diff --git a/app/views/api/activitypub/outbox/show.activitystreams2.rabl b/app/views/api/activitypub/outbox/show.activitystreams2.rabl
deleted file mode 100644
index 273b15e82..000000000
--- a/app/views/api/activitypub/outbox/show.activitystreams2.rabl
+++ /dev/null
@@ -1,12 +0,0 @@
-extends 'activitypub/types/ordered_collection.activitystreams2.rabl'
-
-object @account
-
-node(:totalItems) { @statuses.count }
-node(:current)    { @first_page_url } if @first_page_url
-node(:first)      { @first_page_url } if @first_page_url
-node(:last)       { @last_page_url } if @last_page_url
-
-node(:name)       { |account| t('activitypub.outbox.name', account_name: account_name(account)) }
-node(:summary)    { |account| t('activitypub.outbox.summary', account_name: account_name(account)) }
-node(:updated)    { |account| (@statuses.empty? ? account.created_at.to_time : @statuses.first.updated_at.to_time).xmlschema }
diff --git a/app/views/api/activitypub/outbox/show_page.activitystreams2.rabl b/app/views/api/activitypub/outbox/show_page.activitystreams2.rabl
deleted file mode 100644
index b6433ccf2..000000000
--- a/app/views/api/activitypub/outbox/show_page.activitystreams2.rabl
+++ /dev/null
@@ -1,16 +0,0 @@
-extends 'activitypub/types/ordered_collection_page.activitystreams2.rabl'
-
-object @account
-
-node(:items) do
-  @statuses.map { |status| api_activitypub_status_url(status) }
-end
-
-node(:next)       { @next_page_url } if @next_page_url
-node(:prev)       { @prev_page_url } if @prev_page_url
-node(:current)    { @first_page_url } if @first_page_url
-node(:first)      { @first_page_url } if @first_page_url
-node(:last)       { @last_page_url } if @last_page_url
-node(:partOf)     { @part_of_url } if @part_of_url
-
-node(:updated)    { |account| (@statuses.empty? ? account.created_at.to_time : @statuses.first.updated_at.to_time).xmlschema }
diff --git a/app/views/follower_accounts/index.html.haml b/app/views/follower_accounts/index.html.haml
index 89c7f3a29..738b31638 100644
--- a/app/views/follower_accounts/index.html.haml
+++ b/app/views/follower_accounts/index.html.haml
@@ -2,6 +2,9 @@
   = t('accounts.people_who_follow', name: display_name(@account))
 
 - content_for :header_tags do
+  - if @account.user&.setting_noindex
+    %meta{ name: 'robots', content: 'noindex' }/
+
   = render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false)
 
 = render 'accounts/header', account: @account
diff --git a/app/views/following_accounts/index.html.haml b/app/views/following_accounts/index.html.haml
index 6f0de7590..9637c689f 100644
--- a/app/views/following_accounts/index.html.haml
+++ b/app/views/following_accounts/index.html.haml
@@ -2,6 +2,9 @@
   = t('accounts.people_followed_by', name: display_name(@account))
 
 - content_for :header_tags do
+  - if @account.user&.setting_noindex
+    %meta{ name: 'robots', content: 'noindex' }/
+
   = render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false)
 
 = render 'accounts/header', account: @account
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index 71dcb54c6..13ca9ea79 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -1,4 +1,5 @@
 - content_for :header_tags do
+  %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key}
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
 
   = javascript_pack_tag 'application', integrity: true, crossorigin: 'anonymous'
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index ef97fb127..82b20810a 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -27,6 +27,7 @@
     = javascript_pack_tag 'features/notifications', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
     = javascript_pack_tag 'features/community_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
     = javascript_pack_tag 'features/public_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
+    = javascript_pack_tag 'emojione_picker', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script'
 
     = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
     = csrf_meta_tags
diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml
index 4826f32f7..46dab2d0f 100644
--- a/app/views/layouts/embedded.html.haml
+++ b/app/views/layouts/embedded.html.haml
@@ -2,6 +2,8 @@
 %html{ lang: I18n.locale }
   %head
     %meta{ charset: 'utf-8' }/
+    %meta{ name: 'robots', content: 'noindex' }/
+
     = stylesheet_pack_tag 'common', media: 'all'
     = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
     = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 56a261ab6..3b5d90942 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -41,6 +41,9 @@
       = ff.input :must_be_following, as: :boolean, wrapper: :with_label
 
   .fields-group
+    = f.input :setting_noindex, as: :boolean, wrapper: :with_label
+
+  .fields-group
     = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
     = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label
 
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index d01e82af8..80ea30eb1 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -1,4 +1,7 @@
 - content_for :header_tags do
+  - if @account.user&.setting_noindex
+    %meta{ name: 'robots', content: 'noindex' }/
+
   %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/
   %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/
 
diff --git a/app/views/well_known/webfinger/show.json.rabl b/app/views/well_known/webfinger/show.json.rabl
index 123d1d11a..af11cd207 100644
--- a/app/views/well_known/webfinger/show.json.rabl
+++ b/app/views/well_known/webfinger/show.json.rabl
@@ -3,14 +3,14 @@ object @account
 node(:subject) { @canonical_account_uri }
 
 node(:aliases) do
-  [TagManager.instance.url_for(@account), TagManager.instance.uri_for(@account)]
+  [short_account_url(@account), account_url(@account)]
 end
 
 node(:links) do
   [
-    { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: TagManager.instance.url_for(@account) },
+    { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: account_url(@account) },
     { rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(@account, format: 'atom') },
-    { rel: 'self', type: 'application/activity+json', href: TagManager.instance.url_for(@account) },
+    { rel: 'self', type: 'application/activity+json', href: account_url(@account) },
     { rel: 'salmon', href: api_salmon_url(@account.id) },
     { rel: 'magic-public-key', href: "data:application/magic-public-key,#{@magic_key}" },
     { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_follow_url}?acct={uri}" },
diff --git a/app/views/well_known/webfinger/show.xml.ruby b/app/views/well_known/webfinger/show.xml.ruby
index fc0ab5b84..844742d68 100644
--- a/app/views/well_known/webfinger/show.xml.ruby
+++ b/app/views/well_known/webfinger/show.xml.ruby
@@ -1,10 +1,11 @@
 Nokogiri::XML::Builder.new do |xml|
   xml.XRD(xmlns: 'http://docs.oasis-open.org/ns/xri/xrd-1.0') do
     xml.Subject @canonical_account_uri
-    xml.Alias TagManager.instance.url_for(@account)
-    xml.Alias TagManager.instance.uri_for(@account)
+    xml.Alias short_account_url(@account)
+    xml.Alias account_url(@account)
     xml.Link(rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: TagManager.instance.url_for(@account))
     xml.Link(rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(@account, format: 'atom'))
+    xml.Link(rel: 'self', type: 'application/activity+json', href: account_url(@account))
     xml.Link(rel: 'salmon', href: api_salmon_url(@account.id))
     xml.Link(rel: 'magic-public-key', href: "data:application/magic-public-key,#{@magic_key}")
     xml.Link(rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_follow_url}?acct={uri}")
diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb
index 9186c5d7d..e1ccfb99c 100644
--- a/app/workers/pubsubhubbub/confirmation_worker.rb
+++ b/app/workers/pubsubhubbub/confirmation_worker.rb
@@ -60,9 +60,7 @@ class Pubsubhubbub::ConfirmationWorker
   end
 
   def callback_get_with_params
-    HTTP.headers(user_agent: 'Mastodon/PubSubHubbub')
-        .timeout(:per_operation, write: 20, connect: 20, read: 50)
-        .get(subscription.callback_url, params: callback_params)
+    Request.new(:get, subscription.callback_url, params: callback_params).perform
   end
 
   def callback_response_body
@@ -71,10 +69,10 @@ class Pubsubhubbub::ConfirmationWorker
 
   def callback_params
     {
-      'hub.topic' => account_url(subscription.account, format: :atom),
-      'hub.mode' => mode,
-      'hub.challenge' => challenge,
-      'hub.lease_seconds' => subscription.lease_seconds,
+      'hub.topic': account_url(subscription.account, format: :atom),
+      'hub.mode': mode,
+      'hub.challenge': challenge,
+      'hub.lease_seconds': subscription.lease_seconds,
     }
   end
 
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index 981838f33..2e1101b93 100644
--- a/app/workers/pubsubhubbub/delivery_worker.rb
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -33,9 +33,9 @@ class Pubsubhubbub::DeliveryWorker
   end
 
   def callback_post_payload
-    HTTP.timeout(:per_operation, write: 50, connect: 20, read: 50)
-        .headers(headers)
-        .post(subscription.callback_url, body: payload)
+    request = Request.new(:post, subscription.callback_url, body: payload)
+    request.add_headers(headers)
+    request.perform
   end
 
   def blocked_domain?
@@ -43,18 +43,17 @@ class Pubsubhubbub::DeliveryWorker
   end
 
   def host
-    Addressable::URI.parse(subscription.callback_url).normalize.host
+    Addressable::URI.parse(subscription.callback_url).normalized_host
   end
 
   def headers
     {
-      'User-Agent' => 'Mastodon/PubSubHubbub',
       'Content-Type' => 'application/atom+xml',
-      'Link' => link_headers,
+      'Link' => link_header,
     }.merge(signature_headers.to_h)
   end
 
-  def link_headers
+  def link_header
     LinkHeader.new([hub_link_header, self_link_header]).to_s
   end
 
diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb
index b41cec90d..7592354cc 100644
--- a/app/workers/pubsubhubbub/distribution_worker.rb
+++ b/app/workers/pubsubhubbub/distribution_worker.rb
@@ -35,16 +35,16 @@ class Pubsubhubbub::DistributionWorker
     @payload = AtomSerializer.render(AtomSerializer.new.feed(@account, stream_entries))
     @domains = @account.followers.domains
 
-    Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions.reject { |s| !allowed_to_receive?(s.callback_url) }) do |subscription|
+    Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions.reject { |s| !allowed_to_receive?(s.callback_url, s.domain) }) do |subscription|
       [subscription.id, @payload]
     end
   end
 
   def active_subscriptions
-    Subscription.where(account: @account).active.select('id, callback_url')
+    Subscription.where(account: @account).active.select('id, callback_url, domain')
   end
 
-  def allowed_to_receive?(callback_url)
-    @domains.include?(Addressable::URI.parse(callback_url).host)
+  def allowed_to_receive?(callback_url, domain)
+    (!domain.nil? && @domains.include?(domain)) || @domains.include?(Addressable::URI.parse(callback_url).host)
   end
 end
diff --git a/app/workers/web_push_notification_worker.rb b/app/workers/web_push_notification_worker.rb
new file mode 100644
index 000000000..0568a3e02
--- /dev/null
+++ b/app/workers/web_push_notification_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class WebPushNotificationWorker
+  include Sidekiq::Worker
+
+  sidekiq_options backtrace: true
+
+  def perform(recipient_id, notification_id)
+    recipient = Account.find(recipient_id)
+    notification = Notification.find(notification_id)
+
+    sessions_with_subscriptions = recipient.user.session_activations.reject { |session| session.web_push_subscription.nil? }
+
+    sessions_with_subscriptions.each do |session|
+      begin
+        session.web_push_subscription.push(notification)
+      rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription
+        # Subscription expiration is not currently implemented in any browser
+        session.web_push_subscription.destroy!
+        session.web_push_subscription = nil
+        session.save!
+      rescue Webpush::PayloadTooLarge => e
+        Rails.logger.error(e)
+      end
+    end
+  end
+end