about summary refs log tree commit diff
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
parent08d021916db9e350259b925d7e562aa13ba37422 (diff)
parent695439775eacea081c7257aabab39d0ec6b492dc (diff)
Merge upstream (#81)
-rw-r--r--.babelrc3
-rw-r--r--.env.production.sample11
-rw-r--r--.gitignore1
-rw-r--r--.postcssrc.yml1
-rw-r--r--Gemfile5
-rw-r--r--Gemfile.lock14
-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
-rw-r--r--config/environments/development.rb5
-rw-r--r--config/environments/test.rb5
-rw-r--r--config/initializers/inflections.rb2
-rw-r--r--config/initializers/mime_types.rb5
-rw-r--r--config/initializers/vapid.rb17
-rw-r--r--config/locales/ca.yml9
-rw-r--r--config/locales/en.yml24
-rw-r--r--config/locales/fa.yml9
-rw-r--r--config/locales/fr.yml23
-rw-r--r--config/locales/he.yml9
-rw-r--r--config/locales/id.yml9
-rw-r--r--config/locales/ja.yml59
-rw-r--r--config/locales/ko.yml9
-rw-r--r--config/locales/no.yml9
-rw-r--r--config/locales/oc.yml9
-rw-r--r--config/locales/pl.yml78
-rw-r--r--config/locales/pt-BR.yml9
-rw-r--r--config/locales/pt.yml9
-rw-r--r--config/locales/simple_form.en.yml3
-rw-r--r--config/locales/th.yml9
-rw-r--r--config/locales/tr.yml9
-rw-r--r--config/locales/zh-CN.yml9
-rw-r--r--config/locales/zh-HK.yml9
-rw-r--r--config/routes.rb21
-rw-r--r--config/settings.yml1
-rw-r--r--config/webpack/production.js14
-rw-r--r--db/migrate/20170711225116_fix_null_booleans.rb17
-rw-r--r--db/migrate/20170713112503_make_tag_search_case_insensitive.rb11
-rw-r--r--db/migrate/20170713175513_create_web_push_subscriptions.rb12
-rw-r--r--db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb5
-rw-r--r--db/migrate/20170714184731_add_domain_to_subscriptions.rb5
-rw-r--r--db/schema.rb27
-rw-r--r--lib/tasks/mastodon.rake15
-rw-r--r--package.json34
-rw-r--r--public/badge.pngbin0 -> 31156 bytes
-rw-r--r--spec/controllers/accounts_controller_spec.rb2
-rw-r--r--spec/controllers/api/activitypub/activities_controller_spec.rb69
-rw-r--r--spec/controllers/api/activitypub/notes_controller_spec.rb73
-rw-r--r--spec/controllers/api/activitypub/outbox_controller_spec.rb156
-rw-r--r--spec/controllers/api/push_controller_spec.rb1
-rw-r--r--spec/controllers/api/web/push_subscriptions_controller_spec.rb81
-rw-r--r--spec/controllers/concerns/signature_verification_spec.rb74
-rw-r--r--spec/controllers/well_known/webfinger_controller_spec.rb39
-rw-r--r--spec/fabricators/web_push_subscription_fabricator.rb5
-rw-r--r--spec/helpers/activitystreams2_builder_helper_spec.rb15
-rw-r--r--spec/helpers/emoji_helper_spec.rb15
-rw-r--r--spec/helpers/http_helper_spec.rb13
-rw-r--r--spec/helpers/routing_helper.rb5
-rw-r--r--spec/javascript/components/emojify.test.js52
-rw-r--r--spec/lib/feed_manager_spec.rb7
-rw-r--r--spec/lib/request_spec.rb54
-rw-r--r--spec/models/tag_spec.rb27
-rw-r--r--spec/models/web/push_subscription_spec.rb28
-rw-r--r--spec/views/stream_entries/show.html.haml_spec.rb4
-rw-r--r--spec/workers/pubsubhubbub/confirmation_worker_spec.rb2
-rw-r--r--spec/workers/pubsubhubbub/delivery_worker_spec.rb2
-rw-r--r--yarn.lock599
213 files changed, 2714 insertions, 1364 deletions
diff --git a/.babelrc b/.babelrc
index 19968964e..de922f389 100644
--- a/.babelrc
+++ b/.babelrc
@@ -22,7 +22,8 @@
       {
         "messagesDir": "./build/messages"
       }
-    ]
+    ],
+    "preval"
   ],
   "env": {
     "development": {
diff --git a/.env.production.sample b/.env.production.sample
index 394cdedfe..eb1c5a48f 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -31,6 +31,17 @@ PAPERCLIP_SECRET=
 SECRET_KEY_BASE=
 OTP_SECRET=
 
+# VAPID keys (used for push notifications
+# You can generate the keys using the following command (first is the private key, second is the public one)
+# You should only generate this once per instance. If you later decide to change it, all push subscription will
+# be invalidated, requiring the users to access the website again to resubscribe.
+#
+# Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose)
+#
+# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
+VAPID_PRIVATE_KEY=
+VAPID_PUBLIC_KEY=
+
 # Registrations
 # Single user mode will disable registrations and redirect frontpage to the first profile
 # SINGLE_USER_MODE=true
diff --git a/.gitignore b/.gitignore
index 38ebc934f..868a84368 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ public/system
 public/assets
 public/packs
 public/packs-test
+public/sw.js
 .env
 .env.production
 node_modules/
diff --git a/.postcssrc.yml b/.postcssrc.yml
index 220fe0bb9..efffb39ba 100644
--- a/.postcssrc.yml
+++ b/.postcssrc.yml
@@ -6,3 +6,4 @@ plugins:
       - last 2 versions
       - IE >= 11
       - iOS >= 9
+  postcss-object-fit-images: {}
diff --git a/Gemfile b/Gemfile
index b52685cba..a6c2b2d65 100644
--- a/Gemfile
+++ b/Gemfile
@@ -28,6 +28,7 @@ gem 'devise', '~> 4.2'
 gem 'devise-two-factor', '~> 3.0'
 gem 'doorkeeper', '~> 4.2'
 gem 'fast_blank', '~> 1.0'
+gem 'gemoji', '~> 3.0'
 gem 'goldfinger', '~> 1.2'
 gem 'hiredis', '~> 0.6'
 gem 'redis-namespace', '~> 1.5'
@@ -35,6 +36,7 @@ gem 'htmlentities', '~> 4.3'
 gem 'http', '~> 2.2'
 gem 'http_accept_language', '~> 2.1'
 gem 'httplog', '~> 0.99'
+gem 'idn-ruby', require: 'idn'
 gem 'kaminari', '~> 1.0'
 gem 'link_header', '~> 0.0'
 gem 'mime-types', '~> 3.1'
@@ -64,6 +66,7 @@ gem 'statsd-instrument', '~> 2.1'
 gem 'twitter-text', '~> 1.14'
 gem 'tzinfo-data', '~> 1.2017'
 gem 'webpacker', '~> 2.0'
+gem 'webpush'
 
 group :development, :test do
   gem 'fabrication', '~> 2.16'
@@ -77,7 +80,7 @@ group :test do
   gem 'capybara', '~> 2.14'
   gem 'climate_control', '~> 0.2'
   gem 'faker', '~> 1.7'
-  gem 'microformats2', '~> 3.0'
+  gem 'microformats', '~> 4.0'
   gem 'rails-controller-testing', '~> 1.0'
   gem 'rspec-sidekiq', '~> 3.0'
   gem 'simplecov', '~> 0.14', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index ab430f4c3..f637c9bbe 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -163,6 +163,7 @@ GEM
     fuubar (2.2.0)
       rspec-core (~> 3.0)
       ruby-progressbar (~> 1.4)
+    gemoji (3.0.0)
     globalid (0.4.0)
       activesupport (>= 4.2.0)
     goldfinger (1.2.0)
@@ -181,6 +182,7 @@ GEM
     hashdiff (0.3.4)
     highline (1.7.8)
     hiredis (0.6.1)
+    hkdf (0.3.0)
     htmlentities (4.3.4)
     http (2.2.2)
       addressable (~> 2.3)
@@ -206,9 +208,11 @@ GEM
       parser (>= 2.2.3.0)
       rainbow (~> 2.2)
       terminal-table (>= 1.5.1)
+    idn-ruby (0.1.0)
     jmespath (1.3.1)
     json (2.1.0)
     jsonapi-renderer (0.1.2)
+    jwt (1.5.6)
     kaminari (1.0.1)
       activesupport (>= 4.1.0)
       kaminari-actionview (= 1.0.1)
@@ -239,7 +243,7 @@ GEM
     mail (2.6.6)
       mime-types (>= 1.16, < 4)
     method_source (0.8.2)
-    microformats2 (3.1.0)
+    microformats (4.0.7)
       json
       nokogiri
     mime-types (3.1)
@@ -475,6 +479,9 @@ GEM
       activesupport (>= 4.2)
       multi_json (~> 1.2)
       railties (>= 4.2)
+    webpush (0.3.2)
+      hkdf (~> 0.2)
+      jwt
     websocket-driver (0.6.5)
       websocket-extensions (>= 0.1.0)
     websocket-extensions (0.1.2)
@@ -513,6 +520,7 @@ DEPENDENCIES
   faker (~> 1.7)
   fast_blank (~> 1.0)
   fuubar (~> 2.2)
+  gemoji (~> 3.0)
   goldfinger (~> 1.2)
   hamlit-rails (~> 0.2)
   hiredis (~> 0.6)
@@ -521,12 +529,13 @@ DEPENDENCIES
   http_accept_language (~> 2.1)
   httplog (~> 0.99)
   i18n-tasks (~> 0.9)
+  idn-ruby
   kaminari (~> 1.0)
   letter_opener (~> 1.4)
   letter_opener_web (~> 1.3)
   link_header (~> 0.0)
   lograge (~> 0.5)
-  microformats2 (~> 3.0)
+  microformats (~> 4.0)
   mime-types (~> 3.1)
   nokogiri (~> 1.7)
   oj (~> 3.0)
@@ -573,6 +582,7 @@ DEPENDENCIES
   uglifier (~> 3.2)
   webmock (~> 3.0)
   webpacker (~> 2.0)
+  webpush
 
 RUBY VERSION
    ruby 2.4.1p111
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
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 406fa970b..4c60965c8 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -31,6 +31,11 @@ Rails.application.configure do
     config.logger = ActiveSupport::TaggedLogging.new(logger)
   end
 
+  # Generate random VAPID keys
+  vapid_key = Webpush.generate_key
+  config.x.vapid_private_key = vapid_key.private_key
+  config.x.vapid_public_key = vapid_key.public_key
+
   # Don't care if the mailer can't send.
   config.action_mailer.raise_delivery_errors = false
 
diff --git a/config/environments/test.rb b/config/environments/test.rb
index bde69eba1..e68cb156d 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -40,6 +40,11 @@ Rails.application.configure do
   # Print deprecation notices to the stderr.
   config.active_support.deprecation = :stderr
 
+  # Generate random VAPID keys
+  vapid_key = Webpush.generate_key
+  config.x.vapid_private_key = vapid_key.private_key
+  config.x.vapid_public_key = vapid_key.public_key
+
   # Raises error for missing translations
   # config.action_view.raise_on_missing_translations = true
 end
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index a7b1ef690..26275d092 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -14,4 +14,6 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
   inflect.acronym 'StatsD'
   inflect.acronym 'OEmbed'
   inflect.acronym 'ActivityPub'
+  inflect.acronym 'PubSubHubbub'
+  inflect.acronym 'ActivityStreams'
 end
diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb
index b1b73c846..30e91ad63 100644
--- a/config/initializers/mime_types.rb
+++ b/config/initializers/mime_types.rb
@@ -1,5 +1,4 @@
 # Be sure to restart your server when you modify this file.
 
-Mime::Type.register "application/json",           :json, %w( text/x-json application/jsonrequest application/jrd+json )
-Mime::Type.register "text/xml",                   :xml,  %w( application/xml application/atom+xml application/xrd+xml )
-Mime::Type.register "application/activity+json",  :activitystreams2
+Mime::Type.register 'application/json', :json, %w(text/x-json application/jsonrequest application/jrd+json application/activity+json)
+Mime::Type.register 'text/xml',         :xml,  %w(application/xml application/atom+xml application/xrd+xml)
diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb
new file mode 100644
index 000000000..618f5a3fb
--- /dev/null
+++ b/config/initializers/vapid.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+Rails.application.configure do
+
+  # You can generate the keys using the following command (first is the private key, second is the public one)
+  # You should only generate this once per instance. If you later decide to change it, all push subscription will
+  # be invalidated, requiring the users to access the website again to resubscribe.
+  #
+  # Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose)
+  #
+  # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
+
+  if Rails.env.production?
+    config.x.vapid_private_key = ENV['VAPID_PRIVATE_KEY']
+    config.x.vapid_public_key = ENV['VAPID_PUBLIC_KEY']
+  end
+end
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index f63aee3e6..0ba893a12 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -30,15 +30,6 @@ ca:
     remote_follow: Seguir
     reserved_username: El nom d'usuari està reservat
     unfollow: Deixar de seguir
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} shared an activity."
-      create:
-        name: "%{account_name} created a note."
-    outbox:
-      name: "%{account_name}'s Outbox"
-      summary: A collection of activities from user %{account_name}.
   admin:
     accounts:
       are_you_sure: Estàs segur?
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c9b5d9ab8..be1f15e25 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -44,15 +44,6 @@ en:
     remote_follow: Remote follow
     reserved_username: The username is reserved
     unfollow: Unfollow
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} shared an activity."
-      create:
-        name: "%{account_name} created a note."
-    outbox:
-      name: "%{account_name}'s Outbox"
-      summary: A collection of activities from user %{account_name}.
   admin:
     accounts:
       are_you_sure: Are you sure?
@@ -335,6 +326,21 @@ en:
     next: Next
     prev: Prev
     truncate: "&hellip;"
+  push_notifications:
+    favourite:
+      title: "%{name} favourited your status"
+    follow:
+      title: "%{name} is now following you"
+    mention:
+      action_boost: 'Boost'
+      action_expand: 'Show more'
+      action_favourite: 'Favourite'
+      title: "%{name} mentioned you"
+    reblog:
+      title: "%{name} boosted your status"
+    subscribed:
+      body: "You can now receive push notifications."
+      title: "Subscription registered!"
   remote_follow:
     acct: Enter your username@domain you want to follow from
     missing_resource: Could not find the required redirect URL for your account
diff --git a/config/locales/fa.yml b/config/locales/fa.yml
index ade76d670..218d859bb 100644
--- a/config/locales/fa.yml
+++ b/config/locales/fa.yml
@@ -29,15 +29,6 @@ fa:
     posts: نوشته
     remote_follow: پیگیری غیرمستقیم
     unfollow: پایان پیگیری
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} فعالیتی آغاز کرد."
-      create:
-        name: "%{account_name} یادداشتی نوشت."
-    outbox:
-      name: صندوق خروجی %{account_name}
-      summary: مجموعه‌ای از فعالیت‌های کاربر %{account_name}.
   admin:
     accounts:
       are_you_sure: آیا مطمئن هستید؟
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index c2efd0c85..65e681b20 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -30,15 +30,6 @@ fr:
     remote_follow: Suivre à distance
     reserved_username: Ce nom d’utilisateur⋅ice est réservé
     unfollow: Ne plus suivre
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} a partagé une activité."
-      create:
-        name: "%{account_name} a créé une note."
-    outbox:
-      name: Boîte d’envoi de %{account_name}
-      summary: Liste d’activités de %{account_name}
   admin:
     accounts:
       are_you_sure: Êtes-vous certain⋅e ?
@@ -61,7 +52,7 @@ fr:
       media_attachments: Fichiers médias
       moderation:
         all: Tous
-        silenced: Muets
+        silenced: Masqués
         suspended: Suspendus
         title: Modération
       most_recent_activity: Dernière activité
@@ -85,11 +76,11 @@ fr:
         created_reports: Signalements créés par ce compte
         report: signalement
         targeted_reports: Signalements créés visant ce compte
-      silence: Rendre muet
+      silence: Masquer
       statuses: Statuts
       subscribe: S’abonner
       title: Comptes
-      undo_silenced: Annuler le silence
+      undo_silenced: Démasquer
       undo_suspension: Annuler la suspension
       unsubscribe: Se désabonner
       username: Nom d’utilisateur⋅ice
@@ -104,13 +95,13 @@ fr:
         hint: Le blocage de domaine n’empêchera pas la création de comptes dans la base de données, mais il appliquera automatiquement et rétrospectivement des méthodes de modération spécifiques sur ces comptes.
         severity:
           desc_html: "<strong>Silence</strong> rendra les messages des comptes concernés invisibles à ceux qui ne les suivent pas. <strong>Suspend</strong> supprimera tout le contenu des comptes concernés, les médias, et les données du profil."
-          silence: Muet
+          silence: Masqué
           suspend: Suspendre
         title: Nouveau blocage de domaine
       reject_media: Fichiers média rejetés
       reject_media_hint: Supprime localement les fichiers média stockés et refuse d’en télécharger ultérieurement. Ne concerne pas les suspensions.
       severities:
-        silence: Rendre muet
+        silence: Masquer
         suspend: Suspendre
       severity: Séverité
       show:
@@ -118,7 +109,7 @@ fr:
           one: Un compte affecté dans la base de données
           other: "%{count} comptes affectés dans la base de données"
         retroactive:
-          silence: Annuler le silence sur tous les comptes existants pour ce domaine
+          silence: Annuler le masquage sur tous les comptes existants pour ce domaine
           suspend: Annuler la suspension sur tous les comptes existants pour ce domaine
         title: Annuler le blocage de domaine pour %{domain}
         undo: Annuler
@@ -145,7 +136,7 @@ fr:
       reported_account: Compte signalé
       reported_by: Signalé par
       resolved: Résolus
-      silence_account: Rendre le compte muet
+      silence_account: Masquer le compte
       status: Statut
       suspend_account: Suspendre le compte
       target: Cible
diff --git a/config/locales/he.yml b/config/locales/he.yml
index 21f8f1dc4..251b6914e 100644
--- a/config/locales/he.yml
+++ b/config/locales/he.yml
@@ -29,15 +29,6 @@ he:
     posts: הודעות
     remote_follow: מעקב מרחוק
     unfollow: הפסקת מעקב
-  activitypub:
-    activity:
-      announce:
-        name: הודעה שותפה על ידי %{account_name}.
-      create:
-        name: הודעה חדשה מאת %{account_name}.
-    outbox:
-      name: תיבת הדוא"ל היוצא של %{account_name}
-      summary: אוסף הפעילויות של %{account_name}.
   admin:
     accounts:
       are_you_sure: בטוח?
diff --git a/config/locales/id.yml b/config/locales/id.yml
index e3fe96331..7bda52c78 100644
--- a/config/locales/id.yml
+++ b/config/locales/id.yml
@@ -29,15 +29,6 @@ id:
     posts: Postingan
     remote_follow: Mengikuti
     unfollow: Berhenti mengikuti
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} membagikan aktivitas."
-      create:
-        name: "%{account_name} membuat catatan."
-    outbox:
-      name: "%{account_name} Outbox"
-      summary: Koleksi aktivitas dari pengguna %{account_name}.
   admin:
     accounts:
       are_you_sure: Anda yakin?
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index d57fe8da2..fda87526d 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -30,15 +30,6 @@ ja:
     remote_follow: リモートフォロー
     reserved_username: このユーザー名は予約されています。
     unfollow: フォロー解除
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} さんがアクティビティをシェアしました"
-      create:
-        name: "%{account_name} さんがノートを作成しました"
-    outbox:
-      name: "%{account_name} さんの送信トレイ"
-      summary: "%{account_name} さんからのアクティビティコレクション"
   admin:
     accounts:
       are_you_sure: 本当に実行しますか?
@@ -154,24 +145,31 @@ ja:
       view: 表示
     settings:
       contact_information:
-        email: 公開するメールアドレスを入力
-        username: ユーザー名を入力
+        email: ビジネスメールアドレス
+        username: 連絡先のユーザー名
       registrations:
         closed_message:
-          desc_html: 新規登録を停止しているときにフロントページに表示されます。<br>HTMLタグが利用可能です。
+          desc_html: 新規登録を停止しているときにフロントページに表示されます。HTMLタグが使えます
           title: 新規登録停止時のメッセージ
+        deletion:
+          desc_html: 誰でも自分のアカウントを削除できるようにします
+          title: アカウント削除を受け付ける
         open:
+          desc_html: 誰でも自由にアカウントを作成できるようにします
           title: 新規登録を受け付ける
       site_description:
-        desc_html: トップページへの表示と meta タグに使用されます。<br>HTMLタグ、特に<code>&lt;a&gt;</code> と <code>&lt;em&gt;</code>が利用可能です。
-        title: サイトの説明文
+        desc_html: フロントページへの表示と meta タグに使用される紹介文です。HTMLタグ、特に<code>&lt;a&gt;</code> と <code>&lt;em&gt;</code>が使えます。
+        title: インスタンスの説明
       site_description_extended:
-        desc_html: インスタンスについてのページに表示されます。<br>HTMLタグが利用可能です。
-        title: サイトの詳細な説明
+        desc_html: あなたのインスタンスにおける行動規範やルール、ガイドライン、そのほかの記述をする際に最適な場所です。HTMLタグが使えます
+        title: カスタム詳細説明
       site_terms:
-        desc_html: プライバシーポリシーのページに表示されます。<br>HTMLタグが利用可能です。
-        title: サイトのプライバシーポリシー
-      site_title: サイトのタイトル
+        desc_html: あなたは独自のプライバシーポリシーや利用規約、そのほかの法的根拠を書くことができます。HTMLタグが使えます
+        title: カスタム利用規約
+      site_title: インスタンスの名前
+      timeline_preview:
+        desc_html: ランディングページに公開タイムラインを表示します
+        title: タイムラインプレビュー
       title: サイト設定
     subscriptions:
       callback_url: コールバックURL
@@ -206,6 +204,12 @@ ja:
   authorize_follow:
     error: 残念ながら、リモートアカウントにエラーが発生しました。
     follow: フォロー
+    follow_request: 'あなたは以下のアカウントにフォローリクエストを送信しました:'
+    following: '成功! あなたは現在以下のアカウントをフォローしています:'
+    post_follow:
+      close: またはこのウィンドウを閉じます
+      return: ユーザーのプロフィールに戻る
+      web: Web を開く
     prompt_html: 'あなた(<strong>%{self}</strong>)は以下のアカウントのフォローをリクエストしました:'
     title: "%{acct} をフォロー"
   datetime:
@@ -307,6 +311,21 @@ ja:
     next: 次
     prev: 前
     truncate: "&hellip;"
+  push_notifications:
+    favourite:
+      title: あなたのトゥートが %{name} さんにお気に入り登録されました
+    follow:
+      title: '%{name} さんにフォローされました'
+    mention:
+      action_boost: ブースト
+      action_expand: もっと見る
+      action_favourite: お気に入り
+      title: '%{name} さんから返信がありました'
+    reblog:
+      title: あなたのトゥートが %{name} さんにブーストされました
+    subscribed:
+      body: あなたはプッシュ通知を受け取ることが出来ます
+      title: Subscription が登録されました
   remote_follow:
     acct: あなたの ユーザー名@ドメイン を入力してください
     missing_resource: リダイレクト先が見つかりませんでした
@@ -402,7 +421,7 @@ ja:
 
       <p>このサービスはあなたの個人情報の入力、送信、またはアクセスに際してあなたの個人情報の安全性を維持するために様々なセキュリティ手段をとっています。</p>
 
-      <h3 id="data-retention>データ保持のポリシーはどのようになっていますか?</h3>
+      <h3 id="data-retention">データ保持のポリシーはどのようになっていますか?</h3>
 
       <p>このサービスはデータ保持に関して次のことを行うよう努めます。:</p>
 
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index bafc19993..c7c310cfe 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -30,15 +30,6 @@ ko:
     remote_follow: 리모트 팔로우
     reserved_username: 이 아이디는 예약되어 있습니다.
     unfollow: 팔로우 해제
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} 님이 액티비티를 공유했습니다"
-      create:
-        name: "%{account_name} 님이 노트를 작성했습니다"
-    outbox:
-      name: "%{account_name} 님의 송신함"
-      summary: "%{account_name} 님의 액티비티 모음"
   admin:
     accounts:
       are_you_sure: 정말로 실행하시겠습니까?
diff --git a/config/locales/no.yml b/config/locales/no.yml
index 004e1ff80..cf94524d2 100644
--- a/config/locales/no.yml
+++ b/config/locales/no.yml
@@ -29,15 +29,6 @@
     posts: Poster
     remote_follow: Følg fra andre instanser
     unfollow: Avfølg
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} delte en aktivitet."
-      create:
-        name: "%{account_name} laget en aktivitet."
-    outbox:
-      name: "%{account_name} sin utboks"
-      summary: En samling aktiviteter fra brukeren %{account_name}.
   admin:
     accounts:
       are_you_sure: Er du sikker?
diff --git a/config/locales/oc.yml b/config/locales/oc.yml
index 91a6ca791..2eb85be58 100644
--- a/config/locales/oc.yml
+++ b/config/locales/oc.yml
@@ -29,15 +29,6 @@ oc:
     posts: Estatuts
     remote_follow: Sègre a distància
     unfollow: Quitar de sègre
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} a partejat una activitat."
-      create:
-        name: "%{account_name} a creat una nòta."
-    outbox:
-      name: "%{account_name}'s Outbox"
-      summary: A collection of activities from user %{account_name}.
   admin:
     accounts:
       are_you_sure: Sètz segur ?
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index c6588e846..6f2831670 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -1,17 +1,30 @@
 ---
 pl:
   about:
-    about_mastodon: Mastodon jest <em>wolną i otwartą</em> siecią społecznościową, <em>zdecentralizowaną</em> alternatywą dla zamkniętych, komercyjnych platform. Pozwala uniknąć ryzyka monopolizacji Twojej komunikacji przez jedną korporację. Wybierz serwer, któremu ufasz &mdash; nie ograniczy to Twoich możliwości komunikacji z innymi osobami w sieci. Każdy może też uruchomić własną instancję Mastodona i dołączyć do reszty tej <em>sieci społecznościowej</em>.
+    about_mastodon_html: Mastodon jest wolną i otwartą siecią społecznościową, zdecentralizowaną alternatywą dla zamkniętych, komercyjnych platform.
     about_this: O tej instancji
     business_email: 'Służbowy adres e-mail:'
-    closed_registrations: Rejestracja na tej instancji jest obecnie zamknięta.
+    closed_registrations: Rejestracja na tej instancji jest obecnie zamknięta. Możesz jednak zarejestrować się na innej instancji, uzyskując dostęp do tej samej sieci.
     contact: Kontakt
     description_headline: Czym jest %{domain}?
     domain_count_after: instancji
     domain_count_before: Serwer połączony z
+    features:
+      humane_approach_body: Nauczeni na błędach innych sieci społecznościowych, Mastodon został zaprojektowany tak, aby uniknąć częstych nadużyć.
+      humane_approach_title: Bardziej ludzkie podejście
+      not_a_product_body: Mastodon nie jest komercyjną siecią. Nie doświadczysz tu reklam, zbierania danych, ani centralnego ośrodka, tak jak w przypadku wielu rozwiązań.
+      not_a_product_title: Jesteś człowiekiem, nie produktem
+      real_conversation_body: Mając do dyspozycji 500 znaków na post, rozdrobnienie zawartości i ostrzeżenia o multimediach, możesz wyrażać siebie na wszystkie możliwe sposoby.
+      real_conversation_title: Zaprojektowany do prawdziwych rozmów
+      within_reach_body: Wiele aplikacji dla Androida, iOS i innych platform dzięki przyjaznemu programistom API sprawia, że możesz utrzymywać kontakt ze znajomymi praktycznie wszędzie.
+      within_reach_title: Zawsze w Twoim zasięgu
+    find_another_instance: Znajdź inną instancję
+    generic_description: "%{domain} jest jednym z serwerów sieci"
     get_started: Rozpocznijmy!
+    hosted_on: Mastodon uruchomiony na %{domain}
+    learn_more: Dowiedz się więcej
     links: Odnośniki
-    other_instances: Inne instancje
+    other_instances: Lista instancji
     source_code: Kod źródłowy
     status_count_after: wpisów
     status_count_before: Są autorami
@@ -19,6 +32,7 @@ pl:
     user_count_after: użytkowników
     user_count_before: Z serwera korzysta
     version: Wersja
+    what_is_mastodon: Czym jest Mastodon?
   accounts:
     follow: Śledź
     followers: Śledzących
@@ -30,15 +44,6 @@ pl:
     remote_follow: Zdalne śledzenie
     reserved_username: Ta nazwa użytkownika jest zarezerwowana.
     unfollow: Przestań śledzić
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} udostępnił(a) aktywność."
-      create:
-        name: "%{account_name} utworzył(a) wpis."
-    outbox:
-      name: Skrzynka %{account_name}
-      summary: Zbiór aktywności użytkownika %{account_name}.
   admin:
     accounts:
       are_you_sure: Jesteś tego pewien?
@@ -154,24 +159,31 @@ pl:
       view: Wyświetl
     settings:
       contact_information:
-        email: Wprowadź publiczny adres e-mail
-        username: Wprowadź nazwę użytkownika
+        email: Służbowy adres e-mail
+        username: Nazwa użytkownika do kontaktu
       registrations:
         closed_message:
-          desc_html: Wyświetlana na stronie głównej, gdy możliwość otwarej rejestracji<br>nie jest dostępna. Możesz korzystać z tagów HTML
+          desc_html: Wyświetlana na stronie głównej, gdy możliwość otwarej rejestracji nie jest dostępna. Możesz korzystać z tagów HTML
           title: Wiadomość o nieaktywnej rejestracji
+        deletion:
+          desc_html: Pozwól każdemu na usunięcie konta
+          title: Możliwość usunięcia
         open:
+          desc_html: Pozwól każdemu na założenie konta
           title: Otwarta rejestracja
       site_description:
-        desc_html: Wyświetlany jako nagłówek na stronie głównej oraz jako meta tag.<br>Możesz korzystać z tagów HTML, w szczególności z <code>&lt;a&gt;</code> i <code>&lt;em&gt;</code>.
-        title: Opis strony
+        desc_html: Akapit wprowadzający, widoczny na stronie głównej i znacznikach meta. Możesz korzystać z tagów HTML, w szczególności <code>&lt;a&gt;</code> i <code>&lt;em&gt;</code>.
+        title: Opis instancji
       site_description_extended:
-        desc_html: Wyświetlany w rozszerzonych informacjach o stronie<br>Możesz korzystać z tagów HTML
-        title: Extended site description
+        desc_html: Dobre miejsce na zasady użytkowania, wprowadzenie i inne rzeczy, które wyróżniają tą instancję. Możesz korzystać z tagów HTML
+        title: Niestandrdowy opis stronyv
       site_terms:
-        desc_html: Wyświetlana na stronie zasad użytkowania<br>Możesz używać tagów HTML
-        title: Polityka prywatności strony
-      site_title: Tytuł strony
+        desc_html: Miejsce na własną politykę prywatności, zasady użytkowania i inne unormowania prawne. Możesz używać tagów HTML
+        title: Niestandardowe zasady użytkowania
+      site_title: Nazwa instancji
+      timeline_preview:
+        desc_html: Wyświetlaj publiczną oś czasu na stronie widocznej dla niezalogowanych
+        title: Podgląd osi czasu
       title: Ustawienia strony
     subscriptions:
       callback_url: URL zwrotny
@@ -192,6 +204,7 @@ pl:
   applications:
     invalid_url: Ten URL jest nieprawidłowy
   auth:
+    agreement_html: Rejestrując się, oświadczasz, że zapoznałeś się z <a href="%{rules_path}">naszymi zasadami użytkowania</a> i <a href="%{terms_path}">polityką prywatności</a>.
     change_password: Bezpieczeństwo
     delete_account: Usunięcie konta
     delete_account_html: Jeżeli chcesz usunąć konto, <a href="%{path}">przejdź tutaj</a>. Otrzymasz prośbę o potwierdzenie.
@@ -206,7 +219,7 @@ pl:
   authorize_follow:
     error: Niestety, podczas sprawdzania zdalnego konta wystąpił błąd
     follow: Śledź
-    follow_request: 'Wysłano prośbę o pozwolenie na obserwację:'
+    follow_request: 'Wysłano prośbę o pozwolenie na śledzenie:'
     following: 'Pomyślnie! Od teraz śledzisz:'
     post_follow:
       close: Ewentualnie, możesz po prostu zamknąć tą stronę.
@@ -261,7 +274,7 @@ pl:
       one: W trakcie usuwania śledzących z jednej domeny…
       other: W trakcie usuwania śledzących z %{count} domen…
     true_privacy_html: Pamiętaj, że <strong>rzeczywista prywatność może zostać uzyskana wyłącznie dzięki szyfrowaniu end-to-end</strong>.
-    unlocked_warning_html: Każdy może cię śledzić, aby natychmiastowo zobaczyć twoje statusy. %{lock_link} aby móc kontrolować, kto Cię śledzi.
+    unlocked_warning_html: Każdy może Cię śledzić, aby natychmiastowo zobaczyć twoje statusy. %{lock_link} aby móc kontrolować, kto Cię śledzi.
     unlocked_warning_title: Twoje konto nie jest zablokowane
   generic:
     changes_saved_msg: Ustawienia zapisane!
@@ -278,7 +291,7 @@ pl:
       following: Lista śledzonych
       muting: Lista wyciszonych
     upload: Załaduj
-  landing_strip_html: "<strong>%{name}</strong> ma konto na %{link_to_root_path}. Możesz je śledzić i wejść z nim w interakcję jeśli masz konto gdziekolwiek w Fediwersie."
+  landing_strip_html: "<strong>%{name}</strong> ma konto na %{link_to_root_path}. Możesz je śledzić i wejść z nim w interakcję jeśli masz konto gdziekolwiek w Fediwersum."
   landing_strip_signup_html: Jeśli jeszcze go nie masz, możesz <a href="%{sign_up_path}">stworzyć konto</a>.
   media_attachments:
     validations:
@@ -317,6 +330,21 @@ pl:
     next: Następna
     prev: Poprzednia
     truncate: "&hellip;"
+  push_notifications:
+    favourite:
+      title: "%{name} dodał Twój status do ulubionych"
+    follow:
+      title: "%{name} zaczął Cię śledzić"
+    mention:
+      action_boost: 'Podbij'
+      action_expand: 'Pokaż więcej'
+      action_favourite: 'Dodaj do ulubionych'
+      title: "%{name} wspomniał o Tobie"
+    reblog:
+      title: "%{name} podbił Twój status"
+    subscribed:
+      body: "Otrzymujesz teraz powiadomienia push."
+      title: "Zarejestrowano subskrypcję!"
   remote_follow:
     acct: Podaj swój adres (nazwa@domena), z którego chcesz śledzić
     missing_resource: Nie udało się znaleźć adresu przekierowania z Twojej domeny
diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml
index 355c20d05..5ba763ae4 100644
--- a/config/locales/pt-BR.yml
+++ b/config/locales/pt-BR.yml
@@ -29,15 +29,6 @@ pt-BR:
     posts: Posts
     remote_follow: Acesso remoto
     unfollow: Unfollow
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} compartilhou uma atividade."
-      create:
-        name: "%{account_name} criou uma nota."
-    outbox:
-      name: "%{account_name}'s Outbox"
-      summary: Uma coleção de atividades do usuário %{account_name}.
   admin:
     accounts:
       are_you_sure: Você tem certeza?
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index 40be8a6c5..346fcdda8 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -29,15 +29,6 @@ pt:
     posts: Posts
     remote_follow: Seguir remotamente
     unfollow: Deixar de seguir
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} anunciou uma atividade."
-      create:
-        name: "%{account_name} criou uma nota."
-    outbox:
-      name: "%{account_name}'s Outbox"
-      summary: Uma coleção de atividades do usuário %{account_name}.
   admin:
     accounts:
       are_you_sure: Tens a certeza?
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index fc5ab5ec8..fbaf0ff68 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -12,6 +12,7 @@ en:
         note:
           one: <span class="note-counter">1</span> character left
           other: <span class="note-counter">%{count}</span> characters left
+        setting_noindex: Affects your public profile and status pages
       imports:
         data: CSV file exported from another Mastodon instance
       sessions:
@@ -27,6 +28,7 @@ en:
         data: Data
         display_name: Display name
         email: E-mail address
+        filtered_languages: Filtered languages
         header: Header
         locale: Language
         locked: Lock account
@@ -40,6 +42,7 @@ en:
         setting_default_sensitive: Always mark media as sensitive
         setting_delete_modal: Show confirmation dialog before deleting a toot
         setting_system_font_ui: Use system's default font
+        setting_noindex: Opt-out of search engine indexing
         severity: Severity
         type: Import type
         username: Username
diff --git a/config/locales/th.yml b/config/locales/th.yml
index 263babdd0..17eb96110 100644
--- a/config/locales/th.yml
+++ b/config/locales/th.yml
@@ -29,15 +29,6 @@ th:
     posts: โพสต์
     remote_follow: Remote follow
     unfollow: เลิกติดตาม
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} แชร์กิจกรรม."
-      create:
-        name: "%{account_name} สร้างโน๊ต."
-    outbox:
-      name: "%{account_name}'s Outbox"
-      summary: รวมกิจกรรมของผู้ใช้ %{account_name}.
   admin:
     accounts:
       are_you_sure: แน่ใจนะ?
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index e7864cc57..bb83991cd 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -29,15 +29,6 @@ tr:
     posts: Gönderiler
     remote_follow: Uzaktan takip et
     unfollow: Takibi bırak
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} bir aktivite paylaştı."
-      create:
-        name: "%{account_name} bir not oluşturdu."
-    outbox:
-      name: "%{account_name}'in Gönderdikleri"
-      summary: "%{account_name}'den gelen aktiviteler."
   admin:
     accounts:
       are_you_sure: Emin misiniz?
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index 650d4bd15..0526ec1ba 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -29,15 +29,6 @@ zh-CN:
     posts: 嘟文
     remote_follow: 跨站关注
     unfollow: 取消关注
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} 分享了一个活动。"
-      create:
-        name: "%{account_name} 创建了一个记事。"
-    outbox:
-      name: "%{account_name} 的集合"
-      summary: "%{account_name} 的活动集合"
   admin:
     accounts:
       are_you_sure: 你确定吗?
diff --git a/config/locales/zh-HK.yml b/config/locales/zh-HK.yml
index d2db78be1..06f9ab63d 100644
--- a/config/locales/zh-HK.yml
+++ b/config/locales/zh-HK.yml
@@ -29,15 +29,6 @@ zh-HK:
     posts: 文章
     remote_follow: 跨站關注
     unfollow: 取消關注
-  activitypub:
-    activity:
-      announce:
-        name: "%{account_name} 分享了一項活動。"
-      create:
-        name: "%{account_name} 新增了一篇筆記。"
-    outbox:
-      name: "%{account_name} 的活動"
-      summary: "%{account_name} 分享的活動列表。"
   admin:
     accounts:
       are_you_sure: 你確定嗎?
diff --git a/config/routes.rb b/config/routes.rb
index a63fb3ae6..33077986c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -26,7 +26,7 @@ Rails.application.routes.draw do
     confirmations:      'auth/confirmations',
   }
 
-  get '/users/:username', to: redirect('/@%{username}'), constraints: { format: :html }
+  get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? }
 
   resources :accounts, path: 'users', only: [:show], param: :username do
     resources :stream_entries, path: 'updates', only: [:show] do
@@ -38,10 +38,17 @@ Rails.application.routes.draw do
     get :remote_follow,  to: 'remote_follow#new'
     post :remote_follow, to: 'remote_follow#create'
 
+    resources :statuses, only: [:show] do
+      member do
+        get :activity
+      end
+    end
+
     resources :followers, only: [:index], controller: :follower_accounts
     resources :following, only: [:index], controller: :following_accounts
     resource :follow, only: [:create], controller: :account_follow
     resource :unfollow, only: [:create], controller: :account_unfollow
+    resource :outbox, only: [:show], module: :activitypub
   end
 
   get '/@:username', to: 'accounts#show', as: :short_account
@@ -119,13 +126,6 @@ Rails.application.routes.draw do
     # OEmbed
     get '/oembed', to: 'oembed#show', as: :oembed
 
-    # ActivityPub
-    namespace :activitypub do
-      get '/users/:id/outbox', to: 'outbox#show', as: :outbox
-      get '/statuses/:id', to: 'activities#show_status', as: :status
-      resources :notes, only: [:show]
-    end
-
     # JSON / REST API
     namespace :v1 do
       resources :statuses, only: [:create, :show, :destroy] do
@@ -206,6 +206,11 @@ Rails.application.routes.draw do
 
     namespace :web do
       resource :settings, only: [:update]
+      resources :push_subscriptions, only: [:create] do
+        member do
+          put :update
+        end
+      end
     end
   end
 
diff --git a/config/settings.yml b/config/settings.yml
index 32776515c..d677e1f84 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -21,6 +21,7 @@ defaults: &defaults
   auto_play_gif: false
   delete_modal: true
   system_font_ui: false
+  noindex: false
   notification_emails:
     follow: false
     reblog: false
diff --git a/config/webpack/production.js b/config/webpack/production.js
index 303fca81b..4592db89e 100644
--- a/config/webpack/production.js
+++ b/config/webpack/production.js
@@ -5,6 +5,9 @@ const merge = require('webpack-merge');
 const CompressionPlugin = require('compression-webpack-plugin');
 const sharedConfig = require('./shared.js');
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
+const OfflinePlugin = require('offline-plugin');
+const { publicPath } = require('./configuration.js');
+const path = require('path');
 
 module.exports = merge(sharedConfig, {
   output: { filename: '[name]-[chunkhash].js' },
@@ -39,5 +42,16 @@ module.exports = merge(sharedConfig, {
       openAnalyzer: false,
       logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout
     }),
+    new OfflinePlugin({
+      publicPath: publicPath, // sw.js must be served from the root to avoid scope issues
+      caches: { }, // do not cache things, we only use it for push notifications for now
+      ServiceWorker: {
+        entry: path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'),
+        cacheName: 'mastodon',
+        output: '../sw.js',
+        publicPath: '/sw.js',
+        minify: true,
+      },
+    }),
   ],
 });
diff --git a/db/migrate/20170711225116_fix_null_booleans.rb b/db/migrate/20170711225116_fix_null_booleans.rb
new file mode 100644
index 000000000..5b319471d
--- /dev/null
+++ b/db/migrate/20170711225116_fix_null_booleans.rb
@@ -0,0 +1,17 @@
+class FixNullBooleans < ActiveRecord::Migration[5.1]
+  def change
+    change_column_default :domain_blocks, :reject_media, false
+    change_column_null :domain_blocks, :reject_media, false, false
+
+    change_column_default :imports, :approved, false
+    change_column_null :imports, :approved, false, false
+
+    change_column_null :statuses, :sensitive, false, false
+    change_column_null :statuses, :reply, false, false
+
+    change_column_null :users, :admin, false, false
+
+    change_column_default :users, :otp_required_for_login, false
+    change_column_null :users, :otp_required_for_login, false, false
+  end
+end
diff --git a/db/migrate/20170713112503_make_tag_search_case_insensitive.rb b/db/migrate/20170713112503_make_tag_search_case_insensitive.rb
new file mode 100644
index 000000000..33ed6c005
--- /dev/null
+++ b/db/migrate/20170713112503_make_tag_search_case_insensitive.rb
@@ -0,0 +1,11 @@
+class MakeTagSearchCaseInsensitive < ActiveRecord::Migration[5.1]
+  def up
+    remove_index :tags, name: :hashtag_search_index
+    execute 'CREATE INDEX hashtag_search_index ON tags (lower(name) text_pattern_ops);'
+  end
+
+  def down
+    remove_index :tags, name: :hashtag_search_index
+    execute 'CREATE INDEX hashtag_search_index ON tags (name text_pattern_ops);'
+  end
+end
diff --git a/db/migrate/20170713175513_create_web_push_subscriptions.rb b/db/migrate/20170713175513_create_web_push_subscriptions.rb
new file mode 100644
index 000000000..4e5c2ba00
--- /dev/null
+++ b/db/migrate/20170713175513_create_web_push_subscriptions.rb
@@ -0,0 +1,12 @@
+class CreateWebPushSubscriptions < ActiveRecord::Migration[5.1]
+  def change
+    create_table :web_push_subscriptions do |t|
+      t.string :endpoint, null: false
+      t.string :key_p256dh, null: false
+      t.string :key_auth, null: false
+      t.json :data
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb b/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb
new file mode 100644
index 000000000..d69cdfa50
--- /dev/null
+++ b/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb
@@ -0,0 +1,5 @@
+class AddWebPushSubscriptionToSessionActivations < ActiveRecord::Migration[5.1]
+  def change
+    add_column :session_activations, :web_push_subscription_id, :integer
+  end
+end
diff --git a/db/migrate/20170714184731_add_domain_to_subscriptions.rb b/db/migrate/20170714184731_add_domain_to_subscriptions.rb
new file mode 100644
index 000000000..7c01a64f5
--- /dev/null
+++ b/db/migrate/20170714184731_add_domain_to_subscriptions.rb
@@ -0,0 +1,5 @@
+class AddDomainToSubscriptions < ActiveRecord::Migration[5.1]
+  def change
+    add_column :subscriptions, :domain, :string
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 159704c6a..5ec78a7c9 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20170625140443) do
+ActiveRecord::Schema.define(version: 20170714184731) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -89,7 +89,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
     t.integer "severity", default: 0
-    t.boolean "reject_media"
+    t.boolean "reject_media", default: false, null: false
     t.index ["domain"], name: "index_domain_blocks_on_domain", unique: true
   end
 
@@ -121,7 +121,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do
   create_table "imports", id: :serial, force: :cascade do |t|
     t.integer "account_id", null: false
     t.integer "type", null: false
-    t.boolean "approved"
+    t.boolean "approved", default: false, null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
     t.string "data_file_name"
@@ -258,6 +258,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do
     t.string "user_agent", default: "", null: false
     t.inet "ip"
     t.integer "access_token_id"
+    t.integer "web_push_subscription_id"
     t.index ["session_id"], name: "index_session_activations_on_session_id", unique: true
     t.index ["user_id"], name: "index_session_activations_on_user_id"
   end
@@ -281,12 +282,12 @@ ActiveRecord::Schema.define(version: 20170625140443) do
     t.bigint "in_reply_to_id"
     t.bigint "reblog_of_id"
     t.string "url"
-    t.boolean "sensitive", default: false
+    t.boolean "sensitive", default: false, null: false
     t.integer "visibility", default: 0, null: false
     t.integer "in_reply_to_account_id"
     t.integer "application_id"
     t.text "spoiler_text", default: "", null: false
-    t.boolean "reply", default: false
+    t.boolean "reply", default: false, null: false
     t.integer "favourites_count", default: 0, null: false
     t.integer "reblogs_count", default: 0, null: false
     t.string "language"
@@ -325,6 +326,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
     t.datetime "last_successful_delivery_at"
+    t.string "domain"
     t.index ["account_id", "callback_url"], name: "index_subscriptions_on_account_id_and_callback_url", unique: true
   end
 
@@ -332,7 +334,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do
     t.string "name", default: "", null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
-    t.index "name text_pattern_ops", name: "hashtag_search_index"
+    t.index "lower((name)::text) text_pattern_ops", name: "hashtag_search_index"
     t.index ["name"], name: "index_tags_on_name", unique: true
   end
 
@@ -350,7 +352,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do
     t.datetime "last_sign_in_at"
     t.inet "current_sign_in_ip"
     t.inet "last_sign_in_ip"
-    t.boolean "admin", default: false
+    t.boolean "admin", default: false, null: false
     t.string "confirmation_token"
     t.datetime "confirmed_at"
     t.datetime "confirmation_sent_at"
@@ -360,7 +362,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do
     t.string "encrypted_otp_secret_iv"
     t.string "encrypted_otp_secret_salt"
     t.integer "consumed_timestep"
-    t.boolean "otp_required_for_login"
+    t.boolean "otp_required_for_login", default: false, null: false
     t.datetime "last_emailed_at"
     t.string "otp_backup_codes", array: true
     t.string "filtered_languages", default: [], null: false, array: true
@@ -371,6 +373,15 @@ ActiveRecord::Schema.define(version: 20170625140443) do
     t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
   end
 
+  create_table "web_push_subscriptions", force: :cascade do |t|
+    t.string "endpoint", null: false
+    t.string "key_p256dh", null: false
+    t.string "key_auth", null: false
+    t.json "data"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+  end
+
   create_table "web_settings", id: :serial, force: :cascade do |t|
     t.integer "user_id"
     t.json "data"
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index b2b352858..010139e91 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -85,9 +85,11 @@ namespace :mastodon do
       MediaAttachment.where(account: Account.silenced).find_each(&:destroy)
     end
 
-    desc 'Remove cached remote media attachments that are older than a week'
+    desc 'Remove cached remote media attachments that are older than NUM_DAYS. By default 7 (week)'
     task remove_remote: :environment do
-      MediaAttachment.where.not(remote_url: '').where('created_at < ?', 1.week.ago).find_each do |media|
+      time_ago = ENV.fetch('NUM_DAYS') { 7 }.to_i.days.ago
+
+      MediaAttachment.where.not(remote_url: '').where('created_at < ?', time_ago).find_each do |media|
         media.file.destroy
         media.type = :unknown
         media.save
@@ -182,6 +184,15 @@ namespace :mastodon do
     end
   end
 
+  namespace :webpush do
+    desc 'Generate VAPID key'
+    task generate_vapid_key: :environment do
+      vapid_key = Webpush.generate_key
+      puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
+      puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
+    end
+  end
+
   namespace :maintenance do
     desc 'Update counter caches'
     task update_counter_caches: :environment do
diff --git a/package.json b/package.json
index 4c5a3f1d9..aede6df2e 100644
--- a/package.json
+++ b/package.json
@@ -20,11 +20,12 @@
   "private": true,
   "dependencies": {
     "array-includes": "^3.0.3",
-    "autoprefixer": "^7.1.0",
+    "autoprefixer": "^7.1.2",
     "axios": "^0.16.2",
     "babel-core": "^6.25.0",
-    "babel-loader": "^7.1.0",
+    "babel-loader": "^7.1.1",
     "babel-plugin-lodash": "^3.2.11",
+    "babel-plugin-preval": "^1.3.2",
     "babel-plugin-react-intl": "^2.3.1",
     "babel-plugin-react-transform": "^2.0.2",
     "babel-plugin-syntax-dynamic-import": "^6.18.0",
@@ -37,7 +38,7 @@
     "babel-plugin-transform-react-jsx-source": "^6.22.0",
     "babel-plugin-transform-react-remove-prop-types": "^0.4.6",
     "babel-plugin-transform-runtime": "^6.23.0",
-    "babel-preset-env": "^1.5.2",
+    "babel-preset-env": "^1.6.0",
     "babel-preset-react": "^6.24.1",
     "classnames": "^2.2.5",
     "compression-webpack-plugin": "^0.4.0",
@@ -49,7 +50,7 @@
     "es6-symbol": "^3.1.1",
     "escape-html": "^1.0.3",
     "express": "^4.15.2",
-    "extract-text-webpack-plugin": "^2.1.2",
+    "extract-text-webpack-plugin": "^3.0.0",
     "file-loader": "^0.11.2",
     "font-awesome": "^4.7.0",
     "glob": "^7.1.1",
@@ -57,9 +58,9 @@
     "immutable": "^3.8.1",
     "intersection-observer": "^0.3.2",
     "intl": "^1.2.5",
-    "intl-relativeformat": "^1.3.0",
+    "intl-relativeformat": "^2.0.0",
     "is-nan": "^1.2.1",
-    "js-yaml": "^3.8.4",
+    "js-yaml": "^3.9.0",
     "lodash": "^4.17.4",
     "mark-loader": "^0.1.6",
     "marky": "^1.2.0",
@@ -67,10 +68,13 @@
     "node-sass": "^4.5.2",
     "npmlog": "^4.1.2",
     "object-assign": "^4.1.1",
+    "object-fit-images": "^3.2.3",
+    "offline-plugin": "^4.8.3",
     "path-complete-extname": "^0.1.0",
     "pg": "^6.4.0",
     "postcss-loader": "^2.0.6",
-    "postcss-smart-import": "^0.7.4",
+    "postcss-object-fit-images": "^1.1.2",
+    "postcss-smart-import": "^0.7.5",
     "precss": "^2.0.0",
     "prop-types": "^15.5.10",
     "punycode": "^2.1.0",
@@ -103,32 +107,32 @@
     "sass-loader": "^6.0.6",
     "stringz": "^0.2.2",
     "style-loader": "^0.18.2",
-    "substring-trie": "^1.0.0",
+    "substring-trie": "^1.0.1",
     "throng": "^4.0.0",
     "tiny-queue": "^0.2.1",
     "uuid": "^3.1.0",
     "uws": "^8.14.0",
-    "webpack": "^3.0.0",
+    "webpack": "^3.2.0",
     "webpack-bundle-analyzer": "^2.8.2",
-    "webpack-manifest-plugin": "^1.1.0",
+    "webpack-manifest-plugin": "^1.1.2",
     "webpack-merge": "^4.1.0",
     "websocket.js": "^0.1.12"
   },
   "devDependencies": {
-    "@storybook/addon-actions": "^3.1.6",
-    "@storybook/react": "^3.1.6",
+    "@storybook/addon-actions": "^3.1.8",
+    "@storybook/react": "^3.1.8",
     "babel-eslint": "^7.2.3",
-    "chai": "^4.0.1",
+    "chai": "^4.1.0",
     "chai-enzyme": "^0.8.0",
     "enzyme": "^2.9.1",
     "eslint": "^3.19.0",
     "eslint-plugin-jsx-a11y": "^4.0.0",
     "eslint-plugin-react": "^6.10.3",
-    "jsdom": "^11.0.0",
+    "jsdom": "^11.1.0",
     "mocha": "^3.4.1",
     "react-intl-translations-manager": "^5.0.0",
     "react-test-renderer": "^15.6.1",
-    "sinon": "^2.3.5",
+    "sinon": "^2.3.7",
     "webpack-dev-server": "^2.5.1",
     "yargs": "^8.0.2"
   },
diff --git a/public/badge.png b/public/badge.png
new file mode 100644
index 000000000..fc1f42dca
--- /dev/null
+++ b/public/badge.png
Binary files differdiff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb
index 447e2dd53..d61c8c9bd 100644
--- a/spec/controllers/accounts_controller_spec.rb
+++ b/spec/controllers/accounts_controller_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe AccountsController, type: :controller do
 
     context 'activitystreams2' do
       before do
-        get :show, params: { username: alice.username }, format: 'activitystreams2'
+        get :show, params: { username: alice.username }, format: 'json'
       end
 
       it 'assigns @account' do
diff --git a/spec/controllers/api/activitypub/activities_controller_spec.rb b/spec/controllers/api/activitypub/activities_controller_spec.rb
deleted file mode 100644
index 07df28ac2..000000000
--- a/spec/controllers/api/activitypub/activities_controller_spec.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Api::ActivityPub::ActivitiesController, type: :controller do
-  render_views
-
-  let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-
-  describe 'GET #show' do
-    describe 'normal status' do
-      public_status = nil
-
-      before do
-        public_status = Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-
-        @request.env['HTTP_ACCEPT'] = 'application/activity+json'
-        get :show_status, params: { id: public_status.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns http success' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('type' => 'Create')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'Create')
-        expect(json_data).to include('object' => api_activitypub_note_url(public_status))
-        expect(json_data).to include('url' => TagManager.instance.url_for(public_status))
-      end
-    end
-
-    describe 'reblog' do
-      original = nil
-      reblog = nil
-
-      before do
-        original = Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        reblog = Fabricate(:status, account: user.account, reblog_of_id: original.id, visibility: :public)
-
-        @request.env['HTTP_ACCEPT'] = 'application/activity+json'
-        get :show_status, params: { id: reblog.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns http success' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('type' => 'Announce')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'Announce')
-        expect(json_data).to include('object' => api_activitypub_status_url(original))
-        expect(json_data).to include('url' => TagManager.instance.url_for(reblog))
-      end
-    end
-  end
-end
diff --git a/spec/controllers/api/activitypub/notes_controller_spec.rb b/spec/controllers/api/activitypub/notes_controller_spec.rb
deleted file mode 100644
index a0f05dc65..000000000
--- a/spec/controllers/api/activitypub/notes_controller_spec.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Api::ActivityPub::NotesController, type: :controller do
-  render_views
-
-  let(:user_alice)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-  let(:user_bob)  { Fabricate(:user, account: Fabricate(:account, username: 'bob')) }
-
-  describe 'GET #show' do
-    describe 'normal status' do
-      public_status = nil
-
-      before do
-        public_status = Fabricate(:status, account: user_alice.account, text: 'Hello world', visibility: :public)
-
-        @request.env['HTTP_ACCEPT'] = 'application/activity+json'
-        get :show, params: { id: public_status.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns http success' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('type' => 'Note')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('name' => 'Hello world')
-        expect(json_data).to include('content' => 'Hello world')
-        expect(json_data).to include('published')
-        expect(json_data).to include('url' => TagManager.instance.url_for(public_status))
-      end
-    end
-
-    describe 'reply' do
-      original = nil
-      reply = nil
-
-      before do
-        original = Fabricate(:status, account: user_alice.account, text: 'Hello world', visibility: :public)
-        reply = Fabricate(:status, account: user_bob.account, text: 'Hello world', in_reply_to_id: original.id, visibility: :public)
-
-        @request.env['HTTP_ACCEPT'] = 'application/activity+json'
-        get :show, params: { id: reply.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns http success' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('type' => 'Note')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('name' => 'Hello world')
-        expect(json_data).to include('content' => 'Hello world')
-        expect(json_data).to include('published')
-        expect(json_data).to include('url' => TagManager.instance.url_for(reply))
-        expect(json_data).to include('inReplyTo' => api_activitypub_note_url(original))
-      end
-    end
-  end
-end
diff --git a/spec/controllers/api/activitypub/outbox_controller_spec.rb b/spec/controllers/api/activitypub/outbox_controller_spec.rb
deleted file mode 100644
index 049cf451d..000000000
--- a/spec/controllers/api/activitypub/outbox_controller_spec.rb
+++ /dev/null
@@ -1,156 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Api::ActivityPub::OutboxController, type: :controller do
-  render_views
-
-  let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-
-  describe 'GET #show' do
-    before do
-      @request.headers['ACCEPT'] = 'application/activity+json'
-    end
-
-    describe 'collection with small number of statuses' do
-      public_status = nil
-
-      before do
-        public_status = Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct)
-
-        get :show, params: { id: user.account.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns AS2 JSON body' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'OrderedCollection')
-        expect(json_data).to include('totalItems' => 1)
-        expect(json_data).to include('current')
-        expect(json_data).to include('first')
-        expect(json_data).to include('last')
-      end
-    end
-
-    describe 'collection with large number of statuses' do
-      before do
-        30.times do
-          Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        end
-
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct)
-
-        get :show, params: { id: user.account.id }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns AS2 JSON body' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'OrderedCollection')
-        expect(json_data).to include('totalItems' => 30)
-        expect(json_data).to include('current')
-        expect(json_data).to include('first')
-        expect(json_data).to include('last')
-      end
-    end
-
-    describe 'page with small number of statuses' do
-      statuses = []
-
-      before do
-        5.times do
-          statuses << Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        end
-
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct)
-
-        get :show, params: { id: user.account.id, max_id: statuses.last.id + 1 }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns AS2 JSON body' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'OrderedCollectionPage')
-        expect(json_data).to include('partOf')
-        expect(json_data).to include('items')
-        expect(json_data['items'].length).to eq(5)
-        expect(json_data).to include('prev')
-        expect(json_data).to include('next')
-        expect(json_data).to include('current')
-        expect(json_data).to include('first')
-        expect(json_data).to include('last')
-      end
-    end
-
-    describe 'page with large number of statuses' do
-      statuses = []
-
-      before do
-        30.times do
-          statuses << Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public)
-        end
-
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted)
-        Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct)
-
-        get :show, params: { id: user.account.id, max_id: statuses.last.id + 1 }
-      end
-
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-
-      it 'sets Content-Type header to AS2' do
-        expect(response.header['Content-Type']).to include 'application/activity+json'
-      end
-
-      it 'returns AS2 JSON body' do
-        json_data = JSON.parse(response.body)
-        expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams')
-        expect(json_data).to include('id' => @request.url)
-        expect(json_data).to include('type' => 'OrderedCollectionPage')
-        expect(json_data).to include('partOf')
-        expect(json_data).to include('items')
-        expect(json_data['items'].length).to eq(20)
-        expect(json_data).to include('prev')
-        expect(json_data).to include('next')
-        expect(json_data).to include('current')
-        expect(json_data).to include('first')
-        expect(json_data).to include('last')
-      end
-    end
-  end
-end
diff --git a/spec/controllers/api/push_controller_spec.rb b/spec/controllers/api/push_controller_spec.rb
index 18bfa70e5..647698bd1 100644
--- a/spec/controllers/api/push_controller_spec.rb
+++ b/spec/controllers/api/push_controller_spec.rb
@@ -21,6 +21,7 @@ RSpec.describe Api::PushController, type: :controller do
           'https://callback.host/api',
           'as1234df',
           '3600',
+          nil
         )
         expect(response).to have_http_status(:success)
       end
diff --git a/spec/controllers/api/web/push_subscriptions_controller_spec.rb b/spec/controllers/api/web/push_subscriptions_controller_spec.rb
new file mode 100644
index 000000000..871176a07
--- /dev/null
+++ b/spec/controllers/api/web/push_subscriptions_controller_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Api::Web::PushSubscriptionsController do
+  render_views
+
+  let(:user) { Fabricate(:user) }
+
+  let(:create_payload) do
+    {
+      data: {
+        endpoint: 'https://fcm.googleapis.com/fcm/send/fiuH06a27qE:APA91bHnSiGcLwdaxdyqVXNDR9w1NlztsHb6lyt5WDKOC_Z_Q8BlFxQoR8tWFSXUIDdkyw0EdvxTu63iqamSaqVSevW5LfoFwojws8XYDXv_NRRLH6vo2CdgiN4jgHv5VLt2A8ah6lUX',
+        keys: {
+          p256dh: 'BEm_a0bdPDhf0SOsrnB2-ategf1hHoCnpXgQsFj5JCkcoMrMt2WHoPfEYOYPzOIs9mZE8ZUaD7VA5vouy0kEkr8=',
+          auth: 'eH_C8rq2raXqlcBVDa1gLg==',
+        },
+      }
+    }
+  end
+
+  let(:alerts_payload) do
+    {
+      data: {
+        alerts: {
+          follow: true,
+          favourite: false,
+          reblog: true,
+          mention: false,
+        }
+      }
+    }
+  end
+
+  describe 'POST #create' do
+    it 'saves push subscriptions' do
+      sign_in(user)
+
+      stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200)
+
+      post :create, format: :json, params: create_payload
+
+      user.reload
+
+      push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint])
+
+      expect(push_subscription['endpoint']).to eq(create_payload[:data][:endpoint])
+      expect(push_subscription['key_p256dh']).to eq(create_payload[:data][:keys][:p256dh])
+      expect(push_subscription['key_auth']).to eq(create_payload[:data][:keys][:auth])
+    end
+
+    it 'sends welcome notification' do
+      sign_in(user)
+
+      stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200)
+
+      post :create, format: :json, params: create_payload
+    end
+  end
+
+  describe 'PUT #update' do
+    it 'changes alert settings' do
+      sign_in(user)
+
+      stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200)
+
+      post :create, format: :json, params: create_payload
+
+      alerts_payload[:id] = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]).id
+
+      put :update, format: :json, params: alerts_payload
+
+      push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint])
+
+      expect(push_subscription.data['follow']).to eq(alerts_payload[:data][:follow])
+      expect(push_subscription.data['favourite']).to eq(alerts_payload[:data][:favourite])
+      expect(push_subscription.data['reblog']).to eq(alerts_payload[:data][:reblog])
+      expect(push_subscription.data['mention']).to eq(alerts_payload[:data][:mention])
+    end
+  end
+end
diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb
new file mode 100644
index 000000000..b371795ab
--- /dev/null
+++ b/spec/controllers/concerns/signature_verification_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ApplicationController, type: :controller do
+  controller do
+    include SignatureVerification
+
+    def success
+      head 200
+    end
+
+    def alternative_success
+      head 200
+    end
+  end
+
+  before do
+    routes.draw { get 'success' => 'anonymous#success' }
+  end
+
+  context 'without signature header' do
+    before do
+      get :success
+    end
+
+    describe '#signed_request?' do
+      it 'returns false' do
+        expect(controller.signed_request?).to be false
+      end
+    end
+
+    describe '#signed_request_account' do
+      it 'returns nil' do
+        expect(controller.signed_request_account).to be_nil
+      end
+    end
+  end
+
+  context 'with signature header' do
+    let!(:author) { Fabricate(:account) }
+
+    before do
+      get :success
+
+      fake_request = Request.new(:get, request.url)
+      fake_request.on_behalf_of(author)
+
+      request.headers.merge!(fake_request.headers)
+    end
+
+    describe '#signed_request?' do
+      it 'returns true' do
+        expect(controller.signed_request?).to be true
+      end
+    end
+
+    describe '#signed_request_account' do
+      it 'returns an account' do
+        expect(controller.signed_request_account).to eq author
+      end
+
+      it 'returns nil when path does not match' do
+        request.path = '/alternative-path'
+        expect(controller.signed_request_account).to be_nil
+      end
+
+      it 'returns nil when method does not match' do
+        post :success
+        expect(controller.signed_request_account).to be_nil
+      end
+    end
+  end
+end
diff --git a/spec/controllers/well_known/webfinger_controller_spec.rb b/spec/controllers/well_known/webfinger_controller_spec.rb
index 3699efb56..466f87c45 100644
--- a/spec/controllers/well_known/webfinger_controller_spec.rb
+++ b/spec/controllers/well_known/webfinger_controller_spec.rb
@@ -9,7 +9,7 @@ describe WellKnown::WebfingerController, type: :controller do
     end
 
     before do
-      alice.private_key = <<PEM
+      alice.private_key = <<-PEM
 -----BEGIN RSA PRIVATE KEY-----
 MIICXQIBAAKBgQDHgPoPJlrfMZrVcuF39UbVssa8r4ObLP3dYl9Y17Mgp5K4mSYD
 R/Y2ag58tSi6ar2zM3Ze3QYsNfTq0NqN1g89eAu0MbSjWqpOsgntRPJiFuj3hai2
@@ -27,7 +27,7 @@ FTX8IvYBNTbpEttc1VCf/0ccnNpfb0CrFNSPWxRj7t7D
 -----END RSA PRIVATE KEY-----
 PEM
 
-      alice.public_key = <<PEM
+      alice.public_key = <<-PEM
 -----BEGIN PUBLIC KEY-----
 MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHgPoPJlrfMZrVcuF39UbVssa8
 r4ObLP3dYl9Y17Mgp5K4mSYDR/Y2ag58tSi6ar2zM3Ze3QYsNfTq0NqN1g89eAu0
@@ -48,29 +48,23 @@ PEM
     it 'returns JSON when account can be found' do
       get :show, params: { resource: alice.to_webfinger_s }, format: :json
 
+      json = body_as_json
+
       expect(response).to have_http_status(:success)
       expect(response.content_type).to eq 'application/jrd+json'
-      expect(response.body).to eq "{\"subject\":\"acct:alice@cb6e6126.ngrok.io\",\"aliases\":[\"https://cb6e6126.ngrok.io/@alice\",\"https://cb6e6126.ngrok.io/users/alice\"],\"links\":[{\"rel\":\"http://webfinger.net/rel/profile-page\",\"type\":\"text/html\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"http://schemas.google.com/g/2010#updates-from\",\"type\":\"application/atom+xml\",\"href\":\"https://cb6e6126.ngrok.io/users/alice.atom\"},{\"rel\":\"self\",\"type\":\"application/activity+json\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"salmon\",\"href\":\"#{api_salmon_url(alice.id)}\"},{\"rel\":\"magic-public-key\",\"href\":\"data:application/magic-public-key,RSA.x4D6DyZa3zGa1XLhd_VG1bLGvK-Dmyz93WJfWNezIKeSuJkmA0f2NmoOfLUoumq9szN2Xt0GLDX06tDajdYPPXgLtDG0o1qqTrIJ7UTyYhbo94Wotl9iJvEwa5IjP1Mn00YJ_KvFrzKCm15PC7up6r-NtHsqoYS8X1KAqcbnptU=.AQAB\"},{\"rel\":\"http://ostatus.org/schema/1.0/subscribe\",\"template\":\"https://cb6e6126.ngrok.io/authorize_follow?acct={uri}\"}]}"
+      expect(json[:subject]).to eq 'acct:alice@cb6e6126.ngrok.io'
+      expect(json[:aliases]).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice')
     end
 
     it 'returns JSON when account can be found' do
       get :show, params: { resource: alice.to_webfinger_s }, format: :xml
 
+      xml = Nokogiri::XML(response.body)
+
       expect(response).to have_http_status(:success)
       expect(response.content_type).to eq 'application/xrd+xml'
-      expect(response.body).to eq <<"XML"
-<?xml version="1.0"?>
-<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
-  <Subject>acct:alice@cb6e6126.ngrok.io</Subject>
-  <Alias>https://cb6e6126.ngrok.io/@alice</Alias>
-  <Alias>https://cb6e6126.ngrok.io/users/alice</Alias>
-  <Link rel="http://webfinger.net/rel/profile-page" type="text/html" href="https://cb6e6126.ngrok.io/@alice"/>
-  <Link rel="http://schemas.google.com/g/2010#updates-from" type="application/atom+xml" href="https://cb6e6126.ngrok.io/users/alice.atom"/>
-  <Link rel="salmon" href="#{api_salmon_url(alice.id)}"/>
-  <Link rel="magic-public-key" href="data:application/magic-public-key,RSA.x4D6DyZa3zGa1XLhd_VG1bLGvK-Dmyz93WJfWNezIKeSuJkmA0f2NmoOfLUoumq9szN2Xt0GLDX06tDajdYPPXgLtDG0o1qqTrIJ7UTyYhbo94Wotl9iJvEwa5IjP1Mn00YJ_KvFrzKCm15PC7up6r-NtHsqoYS8X1KAqcbnptU=.AQAB"/>
-  <Link rel="http://ostatus.org/schema/1.0/subscribe" template="https://cb6e6126.ngrok.io/authorize_follow?acct={uri}"/>
-</XRD>
-XML
+      expect(xml.at_xpath('//xmlns:Subject').content).to eq 'acct:alice@cb6e6126.ngrok.io'
+      expect(xml.xpath('//xmlns:Alias').map(&:content)).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice')
     end
 
     it 'returns http not found when account cannot be found' do
@@ -80,19 +74,22 @@ XML
     end
 
     it 'returns JSON when account can be found with alternate domains' do
-      Rails.configuration.x.alternate_domains = ["foo.org"]
-      username, domain = alice.to_webfinger_s.split("@")
+      Rails.configuration.x.alternate_domains = ['foo.org']
+      username, = alice.to_webfinger_s.split('@')
 
       get :show, params: { resource: "#{username}@foo.org" }, format: :json
 
+      json = body_as_json
+
       expect(response).to have_http_status(:success)
       expect(response.content_type).to eq 'application/jrd+json'
-      expect(response.body).to eq "{\"subject\":\"acct:alice@cb6e6126.ngrok.io\",\"aliases\":[\"https://cb6e6126.ngrok.io/@alice\",\"https://cb6e6126.ngrok.io/users/alice\"],\"links\":[{\"rel\":\"http://webfinger.net/rel/profile-page\",\"type\":\"text/html\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"http://schemas.google.com/g/2010#updates-from\",\"type\":\"application/atom+xml\",\"href\":\"https://cb6e6126.ngrok.io/users/alice.atom\"},{\"rel\":\"self\",\"type\":\"application/activity+json\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"salmon\",\"href\":\"#{api_salmon_url(alice.id)}\"},{\"rel\":\"magic-public-key\",\"href\":\"data:application/magic-public-key,RSA.x4D6DyZa3zGa1XLhd_VG1bLGvK-Dmyz93WJfWNezIKeSuJkmA0f2NmoOfLUoumq9szN2Xt0GLDX06tDajdYPPXgLtDG0o1qqTrIJ7UTyYhbo94Wotl9iJvEwa5IjP1Mn00YJ_KvFrzKCm15PC7up6r-NtHsqoYS8X1KAqcbnptU=.AQAB\"},{\"rel\":\"http://ostatus.org/schema/1.0/subscribe\",\"template\":\"https://cb6e6126.ngrok.io/authorize_follow?acct={uri}\"}]}"
+      expect(json[:subject]).to eq 'acct:alice@cb6e6126.ngrok.io'
+      expect(json[:aliases]).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice')
     end
 
     it 'returns http not found when account can not be found with alternate domains' do
-      Rails.configuration.x.alternate_domains = ["foo.org"]
-      username, domain = alice.to_webfinger_s.split("@")
+      Rails.configuration.x.alternate_domains = ['foo.org']
+      username, = alice.to_webfinger_s.split('@')
 
       get :show, params: { resource: "#{username}@bar.org" }, format: :json
 
diff --git a/spec/fabricators/web_push_subscription_fabricator.rb b/spec/fabricators/web_push_subscription_fabricator.rb
new file mode 100644
index 000000000..72d11b77c
--- /dev/null
+++ b/spec/fabricators/web_push_subscription_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:web_push_subscription) do
+  endpoint   Faker::Internet.url
+  key_p256dh Faker::Internet.password
+  key_auth   Faker::Internet.password
+end
diff --git a/spec/helpers/activitystreams2_builder_helper_spec.rb b/spec/helpers/activitystreams2_builder_helper_spec.rb
deleted file mode 100644
index 612ce6ad2..000000000
--- a/spec/helpers/activitystreams2_builder_helper_spec.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Activitystreams2BuilderHelper, type: :helper do
-  it 'returns display name if present' do
-    account = Fabricate(:account, display_name: 'display name', username: 'username')
-    expect(account_name(account)).to eq 'display name'
-  end
-
-  it 'returns username if display name is not present' do
-    account = Fabricate(:account, display_name: '', username: 'username')
-    expect(account_name(account)).to eq 'username'
-  end
-end
diff --git a/spec/helpers/emoji_helper_spec.rb b/spec/helpers/emoji_helper_spec.rb
new file mode 100644
index 000000000..1eedfb719
--- /dev/null
+++ b/spec/helpers/emoji_helper_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+RSpec.describe EmojiHelper, type: :helper do
+  describe '#emojify' do
+    it 'converts shortcodes to unicode' do
+      text = ':book: Book'
+      expect(emojify(text)).to eq '📖 Book'
+    end
+
+    it 'does not convert shortcodes that are part of a string into unicode' do
+      text = ':see_no_evil::hear_no_evil::speak_no_evil:'
+      expect(emojify(text)).to eq text
+    end
+  end
+end
diff --git a/spec/helpers/http_helper_spec.rb b/spec/helpers/http_helper_spec.rb
deleted file mode 100644
index b8e31b8e6..000000000
--- a/spec/helpers/http_helper_spec.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe HttpHelper do
-  describe 'http_client' do
-    it 'returns HTTP::Client with default options' do
-      options = helper.http_client.default_options
-      expect(options.headers['User-Agent']).to match /.+ \(Mastodon\/.+;\ \+http:\/\/cb6e6126\.ngrok\.io\/\)/
-      expect(options.timeout_options).to eq read_timeout: 10, write_timeout: 10, connect_timeout: 10
-    end
-  end
-end
diff --git a/spec/helpers/routing_helper.rb b/spec/helpers/routing_helper.rb
deleted file mode 100644
index 3cd397397..000000000
--- a/spec/helpers/routing_helper.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe RoutingHelper, type: :helper do
-
-end
diff --git a/spec/javascript/components/emojify.test.js b/spec/javascript/components/emojify.test.js
index 3e8b25af9..2874bb56d 100644
--- a/spec/javascript/components/emojify.test.js
+++ b/spec/javascript/components/emojify.test.js
@@ -2,32 +2,6 @@ import { expect } from 'chai';
 import emojify from '../../../app/javascript/mastodon/emoji';
 
 describe('emojify', () => {
-  it('does a basic emojify', () => {
-    expect(emojify(':smile:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" />');
-  });
-
-  it('does a double emojify', () => {
-    expect(emojify(':smile: and :wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /> and <img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-  });
-
-  it('works with random colons', () => {
-    expect(emojify(':smile: : :wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /> : <img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-    expect(emojify(':smile::::wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" />::<img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-    expect(emojify(':smile:::::wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" />:::<img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-  });
-
-  it('works with tags', () => {
-    expect(emojify('<p>:smile:</p>')).to.equal(
-      '<p><img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /></p>');
-    expect(emojify('<p>:smile:</p> and <p>:wink:</p>')).to.equal(
-      '<p><img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /></p> and <p><img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" /></p>');
-  });
-
   it('ignores unknown shortcodes', () => {
     expect(emojify(':foobarbazfake:')).to.equal(':foobarbazfake:');
   });
@@ -46,38 +20,28 @@ describe('emojify', () => {
     expect(emojify(':smile')).to.equal(':smile');
   });
 
-  it('does two emoji next to each other', () => {
-    expect(emojify(':smile::wink:')).to.equal(
-      '<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /><img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" />');
-  });
-
   it('does unicode', () => {
     expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).to.equal(
-      '<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":family_wwbb:" src="/emoji/1f469-1f469-1f466-1f466.svg" />');
+      '<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" src="/emoji/1f469-1f469-1f466-1f466.svg" />');
     expect(emojify('\uD83D\uDC68\uD83D\uDC69\uD83D\uDC67\uD83D\uDC67')).to.equal(
-      '<img draggable="false" class="emojione" alt="👨👩👧👧" title=":family_mwgg:" src="/emoji/1f468-1f469-1f467-1f467.svg" />');
-    expect(emojify('\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66')).to.equal('<img draggable="false" class="emojione" alt="👩👩👦" title=":family_wwb:" src="/emoji/1f469-1f469-1f466.svg" />');
+      '<img draggable="false" class="emojione" alt="👨👩👧👧" src="/emoji/1f468-1f469-1f467-1f467.svg" />');
+    expect(emojify('\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66')).to.equal('<img draggable="false" class="emojione" alt="👩👩👦" src="/emoji/1f469-1f469-1f466.svg" />');
     expect(emojify('\u2757')).to.equal(
-      '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
+      '<img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" />');
   });
 
   it('does multiple unicode', () => {
     expect(emojify('\u2757 #\uFE0F\u20E3')).to.equal(
-      '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" />');
+      '<img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" src="/emoji/0023-20e3.svg" />');
     expect(emojify('\u2757#\uFE0F\u20E3')).to.equal(
-      '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" />');
+      '<img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#️⃣" src="/emoji/0023-20e3.svg" />');
     expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).to.equal(
-      '<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" /> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
+      '<img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" src="/emoji/0023-20e3.svg" /> <img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" />');
     expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).to.equal(
-      'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" /> bar');
-  });
-
-  it('does mixed unicode and shortnames', () => {
-    expect(emojify(':smile:#\uFE0F\u20E3:wink:\u2757')).to.equal('<img draggable="false" class="emojione" alt="😄" title=":smile:" src="/emoji/1f604.svg" /><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/0023-20e3.svg" /><img draggable="false" class="emojione" alt="😉" title=":wink:" src="/emoji/1f609.svg" /><img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
+      'foo <img draggable="false" class="emojione" alt="❗" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" src="/emoji/0023-20e3.svg" /> bar');
   });
 
   it('ignores unicode inside of tags', () => {
     expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).to.equal('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
   });
-
 });
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 4bdc96866..22439cf35 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -81,6 +81,13 @@ RSpec.describe FeedManager do
         expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be true
       end
 
+      it 'returns true for the second reply by followee to a non-federated status' do
+        reply        = Fabricate(:status, text: 'Reply 1', reply: true, account: alice)
+        second_reply = Fabricate(:status, text: 'Reply 2', thread: reply, account: alice)
+        bob.follow!(alice)
+        expect(FeedManager.instance.filter?(:home, second_reply, bob.id)).to be true
+      end
+
       it 'returns false for status by followee mentioning another account' do
         bob.follow!(alice)
         status = PostStatusService.new.call(alice, 'Hey @jeff')
diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb
new file mode 100644
index 000000000..782f14b18
--- /dev/null
+++ b/spec/lib/request_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Request do
+  subject { Request.new(:get, 'http://example.com') }
+
+  describe '#headers' do
+    it 'returns user agent' do
+      expect(subject.headers['User-Agent']).to be_present
+    end
+
+    it 'returns the date header' do
+      expect(subject.headers['Date']).to be_present
+    end
+
+    it 'returns the host header' do
+      expect(subject.headers['Host']).to be_present
+    end
+
+    it 'does not return virtual request-target header' do
+      expect(subject.headers['(request-target)']).to be_nil
+    end
+  end
+
+  describe '#on_behalf_of' do
+    it 'when used, adds signature header' do
+      subject.on_behalf_of(Fabricate(:account))
+      expect(subject.headers['Signature']).to be_present
+    end
+  end
+
+  describe '#add_headers' do
+    it 'adds headers to the request' do
+      subject.add_headers('Test' => 'Foo')
+      expect(subject.headers['Test']).to eq 'Foo'
+    end
+  end
+
+  describe '#perform' do
+    before do
+      stub_request(:get, 'http://example.com')
+      subject.perform
+    end
+
+    it 'executes a HTTP request' do
+      expect(a_request(:get, 'http://example.com')).to have_been_made.once
+    end
+
+    it 'sets headers' do
+      expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made
+    end
+  end
+end
diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb
index 7c574eabe..f727fa1dd 100644
--- a/spec/models/tag_spec.rb
+++ b/spec/models/tag_spec.rb
@@ -1,6 +1,24 @@
 require 'rails_helper'
 
 RSpec.describe Tag, type: :model do
+  describe 'validations' do
+    it 'invalid with #' do
+      expect(Tag.new(name: '#hello_world')).to_not be_valid
+    end
+
+    it 'invalid with .' do
+      expect(Tag.new(name: '.abcdef123')).to_not be_valid
+    end
+
+    it 'invalid with spaces' do
+      expect(Tag.new(name: 'hello world')).to_not be_valid
+    end
+
+    it 'valid with aesthetic' do
+      expect(Tag.new(name: 'aesthetic')).to be_valid
+    end
+  end
+
   describe 'HASHTAG_RE' do
     subject { Tag::HASHTAG_RE }
 
@@ -27,6 +45,15 @@ RSpec.describe Tag, type: :model do
       expect(results).to eq [tag]
     end
 
+    it 'finds tag records in case insensitive' do
+      tag = Fabricate(:tag, name: "MATCH")
+      _miss_tag = Fabricate(:tag, name: "miss")
+
+      results = Tag.search_for("match")
+
+      expect(results).to eq [tag]
+    end
+
     it 'finds the exact matching tag as the first item' do
       similar_tag = Fabricate(:tag, name: "matchlater")
       tag = Fabricate(:tag, name: "match")
diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb
new file mode 100644
index 000000000..574da55ac
--- /dev/null
+++ b/spec/models/web/push_subscription_spec.rb
@@ -0,0 +1,28 @@
+require 'rails_helper'
+
+RSpec.describe Web::PushSubscription, type: :model do
+  let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } }
+  let(:payload_no_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd').as_payload }
+  let(:payload_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd', data: { alerts: alerts }).as_payload }
+  let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) }
+
+  describe '#as_payload' do
+    it 'only returns id and endpoint' do
+      expect(payload_no_alerts.keys).to eq [:id, :endpoint]
+    end
+
+    it 'returns alerts if set' do
+      expect(payload_alerts.keys).to eq [:id, :endpoint, :alerts]
+    end
+  end
+
+  describe '#pushable?' do
+    it 'obeys alert settings' do
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Status'))).to eq false
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Follow'))).to eq true
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'FollowRequest'))).to eq false
+      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Favourite'))).to eq true
+    end
+  end
+end
diff --git a/spec/views/stream_entries/show.html.haml_spec.rb b/spec/views/stream_entries/show.html.haml_spec.rb
index cc380e6ea..6cc3b117a 100644
--- a/spec/views/stream_entries/show.html.haml_spec.rb
+++ b/spec/views/stream_entries/show.html.haml_spec.rb
@@ -27,7 +27,7 @@ describe 'stream_entries/show.html.haml', without_verify_partial_doubles: true d
 
     render
 
-    mf2 = Microformats2.parse(rendered)
+    mf2 = Microformats.parse(rendered)
 
     expect(mf2.entry.name.to_s).to eq status.text
     expect(mf2.entry.url.to_s).not_to be_empty
@@ -53,7 +53,7 @@ describe 'stream_entries/show.html.haml', without_verify_partial_doubles: true d
 
     render
 
-    mf2 = Microformats2.parse(rendered)
+    mf2 = Microformats.parse(rendered)
 
     expect(mf2.entry.name.to_s).to eq reply.text
     expect(mf2.entry.url.to_s).not_to be_empty
diff --git a/spec/workers/pubsubhubbub/confirmation_worker_spec.rb b/spec/workers/pubsubhubbub/confirmation_worker_spec.rb
index 1199d5801..8f66b4520 100644
--- a/spec/workers/pubsubhubbub/confirmation_worker_spec.rb
+++ b/spec/workers/pubsubhubbub/confirmation_worker_spec.rb
@@ -83,6 +83,6 @@ describe Pubsubhubbub::ConfirmationWorker do
   end
 
   def http_headers
-    { 'Connection' => 'close', 'Host' => 'example.com', 'User-Agent' => 'Mastodon/PubSubHubbub' }
+    { 'Connection' => 'close', 'Host' => 'example.com', 'User-Agent' => 'http.rb/2.2.2 (Mastodon/1.4.7; +https://cb6e6126.ngrok.io/)' }
   end
 end
diff --git a/spec/workers/pubsubhubbub/delivery_worker_spec.rb b/spec/workers/pubsubhubbub/delivery_worker_spec.rb
index 081dfa41c..a83245786 100644
--- a/spec/workers/pubsubhubbub/delivery_worker_spec.rb
+++ b/spec/workers/pubsubhubbub/delivery_worker_spec.rb
@@ -59,7 +59,7 @@ describe Pubsubhubbub::DeliveryWorker do
         'Content-Type' => 'application/atom+xml',
         'Host' => 'example.com',
         'Link' => "<https://#{Rails.configuration.x.local_domain}/api/push>; rel=\"hub\", <https://#{Rails.configuration.x.local_domain}/users/#{subscription.account.username}.atom>; rel=\"self\"",
-        'User-Agent' => 'Mastodon/PubSubHubbub',
+        'User-Agent' => 'http.rb/2.2.2 (Mastodon/1.4.7; +https://cb6e6126.ngrok.io/)',
       }.tap do |basic|
         known_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), subscription.secret.to_s, payload)
         basic.merge('X-Hub-Signature' => "sha1=#{known_digest}") if subscription.secret?
diff --git a/yarn.lock b/yarn.lock
index fba802e0a..defd8599f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,15 +2,15 @@
 # yarn lockfile v1
 
 
-"@storybook/addon-actions@^3.1.6":
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-3.1.6.tgz#0cbf00ede57ff00d1dfe02e554043d6963940064"
+"@storybook/addon-actions@^3.1.8":
+  version "3.1.8"
+  resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-3.1.8.tgz#2b6d7aa97530b19965c1010b822f40b130ebbc4d"
   dependencies:
     "@storybook/addons" "^3.1.6"
     deep-equal "^1.0.1"
     json-stringify-safe "^5.0.1"
     prop-types "^15.5.8"
-    react-inspector "^2.0.0"
+    react-inspector "^2.1.1"
     uuid "^3.1.0"
 
 "@storybook/addon-links@^3.1.6":
@@ -44,11 +44,11 @@
     fuse.js "^3.0.1"
     prop-types "^15.5.9"
 
-"@storybook/react@^3.1.6":
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/@storybook/react/-/react-3.1.6.tgz#9393bb987ff08ee5f49c4557d12eb84377dee5d2"
+"@storybook/react@^3.1.8":
+  version "3.1.8"
+  resolved "https://registry.yarnpkg.com/@storybook/react/-/react-3.1.8.tgz#4d140c5ae7e9b5eaf627f2071d7324aa38c7af49"
   dependencies:
-    "@storybook/addon-actions" "^3.1.6"
+    "@storybook/addon-actions" "^3.1.8"
     "@storybook/addon-links" "^3.1.6"
     "@storybook/addons" "^3.1.6"
     "@storybook/channel-postmessage" "^3.1.6"
@@ -93,7 +93,7 @@
     url-loader "^0.5.8"
     util-deprecate "^1.0.2"
     uuid "^3.0.1"
-    webpack "^2.5.1"
+    webpack "^2.5.1 || ^3.0.0"
     webpack-dev-middleware "^1.10.2"
     webpack-hot-middleware "^2.18.0"
 
@@ -122,8 +122,8 @@
     redux "^3.6.0"
 
 "@types/node@^6.0.46":
-  version "6.0.78"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.78.tgz#5d4a3f579c1524e01ee21bf474e6fba09198f470"
+  version "6.0.80"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.80.tgz#914a75799605b4609bd9a2918c865ba3c4141367"
 
 abab@^1.0.3:
   version "1.0.3"
@@ -167,8 +167,8 @@ acorn@^4.0.3, acorn@^4.0.4:
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787"
 
 acorn@^5.0.0, acorn@^5.0.1, acorn@^5.0.3:
-  version "5.0.3"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.0.3.tgz#c460df08491463f028ccb82eab3730bf01087b3d"
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.1.tgz#53fe161111f912ab999ee887a90a0bc52822fd75"
 
 adjust-sourcemap-loader@^1.1.0:
   version "1.1.0"
@@ -195,7 +195,7 @@ airbnb-js-shims@^1.1.1:
     string.prototype.padend "^3.0.0"
     string.prototype.padstart "^3.0.0"
 
-ajv-keywords@^1.0.0, ajv-keywords@^1.1.1:
+ajv-keywords@^1.0.0:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c"
 
@@ -211,11 +211,11 @@ ajv@^4.7.0, ajv@^4.9.1:
     json-stable-stringify "^1.0.1"
 
 ajv@^5.0.0, ajv@^5.1.5:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.0.tgz#c1735024c5da2ef75cc190713073d44f098bf486"
+  version "5.2.2"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39"
   dependencies:
     co "^4.6.0"
-    fast-deep-equal "^0.1.0"
+    fast-deep-equal "^1.0.0"
     json-schema-traverse "^0.3.0"
     json-stable-stringify "^1.0.1"
 
@@ -255,6 +255,12 @@ ansi-styles@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
 
+ansi-styles@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.1.0.tgz#09c202d5c917ec23188caa5c9cb9179cd9547750"
+  dependencies:
+    color-convert "^1.0.0"
+
 any-promise@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-0.1.0.tgz#830b680aa7e56f33451d4b049f3bd8044498ee27"
@@ -300,8 +306,8 @@ arr-diff@^2.0.0:
     arr-flatten "^1.0.1"
 
 arr-flatten@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.0.3.tgz#a274ed85ac08849b6bd7847c4580745dc51adfb1"
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
 
 array-equal@^1.0.0:
   version "1.0.0"
@@ -352,8 +358,8 @@ arrify@^1.0.0:
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
 
 asap@~2.0.3:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f"
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
 
 asn1.js@^4.0.0:
   version "4.9.1"
@@ -409,7 +415,7 @@ async@^1.5.2:
   version "1.5.2"
   resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
 
-async@^2.1.2, async@^2.1.4, async@^2.1.5:
+async@^2.1.2, async@^2.1.4, async@^2.1.5, async@^2.4.1:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d"
   dependencies:
@@ -434,15 +440,15 @@ autoprefixer@^6.3.1:
     postcss "^5.2.16"
     postcss-value-parser "^3.2.3"
 
-autoprefixer@^7.1.0, autoprefixer@^7.1.1:
-  version "7.1.1"
-  resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-7.1.1.tgz#97bc854c7d0b979f8d6489de547a0d17fb307f6d"
+autoprefixer@^7.1.1, autoprefixer@^7.1.2:
+  version "7.1.2"
+  resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-7.1.2.tgz#fbeaf07d48fd878e0682bf7cbeeade728adb2b18"
   dependencies:
-    browserslist "^2.1.3"
-    caniuse-lite "^1.0.30000670"
+    browserslist "^2.1.5"
+    caniuse-lite "^1.0.30000697"
     normalize-range "^0.1.2"
     num2fraction "^1.2.2"
-    postcss "^6.0.1"
+    postcss "^6.0.6"
     postcss-value-parser "^3.2.3"
 
 aws-sign2@~0.6.0:
@@ -640,9 +646,9 @@ babel-helpers@^6.24.1:
     babel-runtime "^6.22.0"
     babel-template "^6.24.1"
 
-babel-loader@^7.0.0, babel-loader@^7.1.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.1.0.tgz#3fbf2581f085774bd9642dca9990e6d6c1491144"
+babel-loader@^7.0.0, babel-loader@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.1.1.tgz#b87134c8b12e3e4c2a94e0546085bc680a2b8488"
   dependencies:
     find-cache-dir "^1.0.0"
     loader-utils "^1.0.2"
@@ -675,6 +681,14 @@ babel-plugin-lodash@^3.2.11:
     glob "^7.1.1"
     lodash "^4.17.2"
 
+babel-plugin-preval@^1.3.2:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/babel-plugin-preval/-/babel-plugin-preval-1.3.2.tgz#44192e6e97b58661bf2c5bcae90bba2a366e0134"
+  dependencies:
+    babel-core "^6.25.0"
+    babylon "^6.17.4"
+    require-from-string "^1.2.1"
+
 babel-plugin-react-docgen@^1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-react-docgen/-/babel-plugin-react-docgen-1.5.0.tgz#0339717ad51f4a5ce4349330b8266ea5a56f53b4"
@@ -1080,9 +1094,9 @@ babel-plugin-transform-strict-mode@^6.24.1:
     babel-runtime "^6.22.0"
     babel-types "^6.24.1"
 
-babel-preset-env@1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.4.0.tgz#c8e02a3bcc7792f23cded68e0355b9d4c28f0f7a"
+babel-preset-env@1.5.2:
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.5.2.tgz#cd4ae90a6e94b709f97374b33e5f8b983556adef"
   dependencies:
     babel-plugin-check-es2015-constants "^6.22.0"
     babel-plugin-syntax-trailing-function-commas "^6.22.0"
@@ -1111,12 +1125,13 @@ babel-preset-env@1.4.0:
     babel-plugin-transform-es2015-unicode-regex "^6.22.0"
     babel-plugin-transform-exponentiation-operator "^6.22.0"
     babel-plugin-transform-regenerator "^6.22.0"
-    browserslist "^1.4.0"
+    browserslist "^2.1.2"
     invariant "^2.2.2"
+    semver "^5.3.0"
 
-babel-preset-env@^1.5.2:
-  version "1.5.2"
-  resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.5.2.tgz#cd4ae90a6e94b709f97374b33e5f8b983556adef"
+babel-preset-env@^1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.6.0.tgz#2de1c782a780a0a5d605d199c957596da43c44e4"
   dependencies:
     babel-plugin-check-es2015-constants "^6.22.0"
     babel-plugin-syntax-trailing-function-commas "^6.22.0"
@@ -1191,8 +1206,8 @@ babel-preset-flow@^6.23.0:
     babel-plugin-transform-flow-strip-types "^6.22.0"
 
 babel-preset-react-app@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/babel-preset-react-app/-/babel-preset-react-app-3.0.0.tgz#f4505092f8bba0f0147c764dc72055fe46ac1416"
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/babel-preset-react-app/-/babel-preset-react-app-3.0.1.tgz#8b744cbe47fd57c868e6f913552ceae26ae31860"
   dependencies:
     babel-plugin-dynamic-import-node "1.0.2"
     babel-plugin-syntax-dynamic-import "6.18.0"
@@ -1204,7 +1219,7 @@ babel-preset-react-app@^3.0.0:
     babel-plugin-transform-react-jsx-source "6.22.0"
     babel-plugin-transform-regenerator "6.24.1"
     babel-plugin-transform-runtime "6.23.0"
-    babel-preset-env "1.4.0"
+    babel-preset-env "1.5.2"
     babel-preset-react "6.24.1"
 
 babel-preset-react@6.24.1, babel-preset-react@^6.24.1:
@@ -1305,7 +1320,7 @@ babel-types@^6.19.0, babel-types@^6.23.0, babel-types@^6.24.1, babel-types@^6.25
     lodash "^4.2.0"
     to-fast-properties "^1.0.1"
 
-babylon@^6.17.0, babylon@^6.17.2:
+babylon@^6.17.0, babylon@^6.17.2, babylon@^6.17.4:
   version "6.17.4"
   resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.17.4.tgz#3e8b7402b88d22c3423e137a1577883b15ff869a"
 
@@ -1404,8 +1419,8 @@ braces@^1.8.2:
     repeat-element "^1.1.2"
 
 brcast@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/brcast/-/brcast-2.0.0.tgz#9e627ab82209895664c1d6c1f45cd8c43422e3f6"
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/brcast/-/brcast-2.0.1.tgz#4311508f0634a6f5a2465b6cf2db27f06902aaca"
 
 brorand@^1.0.1:
   version "1.1.0"
@@ -1466,14 +1481,14 @@ browserify-zlib@^0.1.4:
   dependencies:
     pako "~0.2.0"
 
-browserslist@^1.3.6, browserslist@^1.4.0, browserslist@^1.5.2, browserslist@^1.7.6:
+browserslist@^1.3.6, browserslist@^1.5.2, browserslist@^1.7.6:
   version "1.7.7"
   resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.7.7.tgz#0bd76704258be829b2398bb50e4b62d1a166b0b9"
   dependencies:
     caniuse-db "^1.0.30000639"
     electron-to-chromium "^1.2.7"
 
-browserslist@^2.1.2, browserslist@^2.1.3:
+browserslist@^2.1.2, browserslist@^2.1.5:
   version "2.1.5"
   resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-2.1.5.tgz#e882550df3d1cd6d481c1a3e0038f2baf13a4711"
   dependencies:
@@ -1508,9 +1523,9 @@ builtin-status-codes@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
 
-bytes@2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.3.0.tgz#d5b680a165b6201739acb611542aabc2d8ceb070"
+bytes@2.5.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.5.0.tgz#4c9423ea2d252c270c41b2bdefeff9bb6b62c06a"
 
 caller-path@^0.1.0:
   version "0.1.0"
@@ -1559,12 +1574,12 @@ caniuse-api@^1.5.2:
     lodash.uniq "^4.5.0"
 
 caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639:
-  version "1.0.30000696"
-  resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000696.tgz#e71f5c61e1f96c7a3af4e791ac5db55e11737604"
+  version "1.0.30000700"
+  resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000700.tgz#97cfc483865eea8577dc7a3674929b9abf553095"
 
-caniuse-lite@^1.0.30000670, caniuse-lite@^1.0.30000684:
-  version "1.0.30000696"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000696.tgz#30f2695d2a01a0dfd779a26ab83f4d134b3da5cc"
+caniuse-lite@^1.0.30000684, caniuse-lite@^1.0.30000697:
+  version "1.0.30000700"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000700.tgz#6084871ec75c6fa62327de97622514f95d9db26a"
 
 case-sensitive-paths-webpack-plugin@^2.0.0:
   version "2.1.1"
@@ -1588,9 +1603,9 @@ chai-enzyme@^0.8.0:
     html "^1.0.0"
     react-element-to-jsx-string "^5.0.0"
 
-chai@^4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/chai/-/chai-4.0.2.tgz#2f7327c4de6f385dd7787999e2ab02697a32b83b"
+chai@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.0.tgz#331a0391b55c3af8740ae9c3b7458bc1c3805e6d"
   dependencies:
     assertion-error "^1.0.1"
     check-error "^1.0.1"
@@ -1609,6 +1624,14 @@ chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
+chalk@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.0.1.tgz#dbec49436d2ae15f536114e76d14656cdbc0f44d"
+  dependencies:
+    ansi-styles "^3.1.0"
+    escape-string-regexp "^1.0.5"
+    supports-color "^4.0.0"
+
 check-error@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
@@ -1650,10 +1673,11 @@ chokidar@^1.4.3, chokidar@^1.6.0:
     fsevents "^1.0.0"
 
 cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.3.tgz#eeabf194419ce900da3018c207d212f2a6df0a07"
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de"
   dependencies:
     inherits "^2.0.1"
+    safe-buffer "^5.0.1"
 
 circular-json@^0.3.1:
   version "0.3.1"
@@ -1713,8 +1737,8 @@ co@^4.6.0:
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
 
 coa@~1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.3.tgz#1b54a5e1dcf77c990455d4deea98c564416dc893"
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.4.tgz#a9ef153660d6a86a8bdec0289a5c684d217432fd"
   dependencies:
     q "^1.1.2"
 
@@ -1726,7 +1750,7 @@ collapse-white-space@^1.0.0:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.3.tgz#4b906f670e5a963a87b76b0e1689643341b6023c"
 
-color-convert@^1.3.0:
+color-convert@^1.0.0, color-convert@^1.3.0:
   version "1.9.0"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a"
   dependencies:
@@ -1775,10 +1799,8 @@ commander@2.9.0:
     graceful-readlink ">= 1.0.0"
 
 commander@^2.8.1, commander@^2.9.0:
-  version "2.10.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.10.0.tgz#e1f5d3245de246d1a5ca04702fa1ad1bd7e405fe"
-  dependencies:
-    graceful-readlink ">= 1.0.0"
+  version "2.11.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563"
 
 common-tags@^1.4.0:
   version "1.4.0"
@@ -1790,11 +1812,11 @@ commondir@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
 
-complex.js@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/complex.js/-/complex.js-2.0.1.tgz#ea90c7a05aeceaf3a376d2c0f6a78421727d6879"
+complex.js@2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/complex.js/-/complex.js-2.0.4.tgz#d8e7cfb9652d1e853e723386421c1a0ca7a48373"
 
-compressible@~2.0.8:
+compressible@~2.0.10:
   version "2.0.10"
   resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.10.tgz#feda1c7f7617912732b29bf8cf26252a20b9eecd"
   dependencies:
@@ -1810,15 +1832,16 @@ compression-webpack-plugin@^0.4.0:
     node-zopfli "^2.0.0"
 
 compression@^1.5.2:
-  version "1.6.2"
-  resolved "https://registry.yarnpkg.com/compression/-/compression-1.6.2.tgz#cceb121ecc9d09c52d7ad0c3350ea93ddd402bc3"
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.0.tgz#030c9f198f1643a057d776a738e922da4373012d"
   dependencies:
     accepts "~1.3.3"
-    bytes "2.3.0"
-    compressible "~2.0.8"
-    debug "~2.2.0"
+    bytes "2.5.0"
+    compressible "~2.0.10"
+    debug "2.6.8"
     on-headers "~1.0.1"
-    vary "~1.1.0"
+    safe-buffer "5.1.1"
+    vary "~1.1.1"
 
 concat-map@0.0.1:
   version "0.0.1"
@@ -1984,8 +2007,8 @@ cryptiles@2.x.x:
     boom "2.x.x"
 
 crypto-browserify@^3.11.0:
-  version "3.11.0"
-  resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.0.tgz#3652a0906ab9b2a7e0c3ce66a408e957a2485522"
+  version "3.11.1"
+  resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.1.tgz#948945efc6757a400d6e5e5af47194d10064279f"
   dependencies:
     browserify-cipher "^1.0.0"
     browserify-sign "^4.0.0"
@@ -2015,12 +2038,38 @@ css-color-names@0.0.4:
   version "0.0.4"
   resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
 
+css-font-size-keywords@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz#854875ace9aca6a8d2ee0d345a44aae9bb6db6cb"
+
+css-font-stretch-keywords@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz#50cee9b9ba031fb5c952d4723139f1e107b54b10"
+
+css-font-style-keywords@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz#5c3532813f63b4a1de954d13cea86ab4333409e4"
+
+css-font-weight-keywords@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz#9bc04671ac85bc724b574ef5d3ac96b0d604fd97"
+
+css-global-keywords@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/css-global-keywords/-/css-global-keywords-1.0.1.tgz#72a9aea72796d019b1d2a3252de4e5aaa37e4a69"
+
 css-in-js-utils@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/css-in-js-utils/-/css-in-js-utils-1.0.3.tgz#9ac7e02f763cf85d94017666565ed68a5b5f3215"
   dependencies:
     hyphenate-style-name "^1.0.2"
 
+css-list-helpers@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/css-list-helpers/-/css-list-helpers-1.0.1.tgz#fff57192202db83240c41686f919e449a7024f7d"
+  dependencies:
+    tcomb "^2.5.0"
+
 css-loader@^0.28.1, css-loader@^0.28.4:
   version "0.28.4"
   resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.4.tgz#6cf3579192ce355e8b38d5f42dd7a1f2ec898d0f"
@@ -2057,6 +2106,10 @@ css-selector-tokenizer@^0.7.0:
     fastparse "^1.1.1"
     regexpu-core "^1.0.0"
 
+css-system-font-keywords@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz#85c6f086aba4eb32c571a3086affc434b84823ed"
+
 css-what@2.1:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.0.tgz#9467d032c38cfaefb9f2d79501253062f87fa1bd"
@@ -2176,19 +2229,13 @@ debug@~0.7.4:
   version "0.7.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39"
 
-debug@~2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da"
-  dependencies:
-    ms "0.7.1"
-
 decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
 
-decimal.js@7.1.1:
-  version "7.1.1"
-  resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-7.1.1.tgz#1adcad7d70d7a91c426d756f1eb6566c3be6cbcf"
+decimal.js@7.2.3:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-7.2.3.tgz#6434c3b8a8c375780062fc633d0d2bbdb264cc78"
 
 deep-eql@^2.0.1:
   version "2.0.2"
@@ -2200,7 +2247,7 @@ deep-equal@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
 
-deep-extend@~0.4.0:
+deep-extend@^0.4.0, deep-extend@~0.4.0:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
 
@@ -2281,10 +2328,14 @@ detect-node@^2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.3.tgz#a2033c09cc8e158d37748fbde7507832bd6ce127"
 
-diff@3.2.0, diff@^3.1.0:
+diff@3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.2.0.tgz#c9ce393a4b7cbd0b058a725c93df299027868ff9"
 
+diff@^3.1.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-3.3.0.tgz#056695150d7aa93237ca7e378ac3b1682b7963b9"
+
 diffie-hellman@^5.0.0:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
@@ -2357,13 +2408,20 @@ domhandler@^2.3.0:
   dependencies:
     domelementtype "1"
 
-domutils@1.5.1, domutils@^1.5.1:
+domutils@1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
   dependencies:
     dom-serializer "0"
     domelementtype "1"
 
+domutils@^1.5.1:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.6.2.tgz#1958cc0b4c9426e9ed367fb1c8e854891b0fa3ff"
+  dependencies:
+    dom-serializer "0"
+    domelementtype "1"
+
 dot-prop@^4.1.0:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.1.1.tgz#a8493f0b7b5eeec82525b5c7587fa7de7ca859c1"
@@ -2396,13 +2454,13 @@ ee-first@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
 
-ejs@^2.5.6:
+ejs@^2.3.4, ejs@^2.5.6:
   version "2.5.6"
   resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88"
 
 electron-to-chromium@^1.2.7, electron-to-chromium@^1.3.14:
-  version "1.3.14"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.14.tgz#64af0f9efd3c3c6acd57d71f83b49ca7ee9c4b43"
+  version "1.3.15"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.15.tgz#08397934891cbcfaebbd18b82a95b5a481138369"
 
 element-class@^0.2.0:
   version "0.2.2"
@@ -2421,8 +2479,8 @@ elliptic@^6.0.0:
     minimalistic-crypto-utils "^1.0.0"
 
 emoji-regex@^6.1.0:
-  version "6.4.2"
-  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.4.2.tgz#a30b6fee353d406d96cfb9fa765bdc82897eff6e"
+  version "6.4.3"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.4.3.tgz#6ac2ac58d4b78def5e39b33fcbf395688af3076c"
 
 emojione-picker@^2.2.1:
   version "2.2.1"
@@ -2454,9 +2512,9 @@ encoding@^0.1.11:
   dependencies:
     iconv-lite "~0.4.13"
 
-enhanced-resolve@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.1.0.tgz#9f4b626f577245edcf4b2ad83d86e17f4f421dec"
+enhanced-resolve@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.3.0.tgz#950964ecc7f0332a42321b673b38dc8ff15535b3"
   dependencies:
     graceful-fs "^4.1.2"
     memory-fs "^0.4.0"
@@ -2671,7 +2729,11 @@ esprima@^2.6.0, esprima@^2.7.1:
   version "2.7.3"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581"
 
-esprima@^3.1.1, esprima@~3.1.0:
+esprima@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.0.tgz#4499eddcd1110e0b218bacf2fa7f7f59f55ca804"
+
+esprima@~3.1.0:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
 
@@ -2806,12 +2868,12 @@ extglob@^0.3.1:
   dependencies:
     is-extglob "^1.0.0"
 
-extract-text-webpack-plugin@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-2.1.2.tgz#756ef4efa8155c3681833fbc34da53b941746d6c"
+extract-text-webpack-plugin@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.0.tgz#90caa7907bc449f335005e3ac7532b41b00de612"
   dependencies:
-    async "^2.1.2"
-    loader-utils "^1.0.2"
+    async "^2.4.1"
+    loader-utils "^1.1.0"
     schema-utils "^0.3.0"
     webpack-sources "^1.0.1"
 
@@ -2819,9 +2881,9 @@ extsprintf@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.0.2.tgz#e1080e0658e300b06294990cc70e1502235fd550"
 
-fast-deep-equal@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-0.1.0.tgz#5c6f4599aba6b333ee3342e2ed978672f1001f8d"
+fast-deep-equal@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff"
 
 fast-levenshtein@~2.0.4:
   version "2.0.6"
@@ -2999,9 +3061,9 @@ forwarded@~0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363"
 
-fraction.js@4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.0.0.tgz#73974e2f8b51ef709536d624cc90782e2bb61274"
+fraction.js@4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.0.2.tgz#0eae896626f334b1bde763371347a83b5575d7f0"
 
 fresh@0.5.0:
   version "0.5.0"
@@ -3050,12 +3112,12 @@ function-bind@^1.0.2, function-bind@^1.1.0:
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.0.tgz#16176714c801798e4e8f2cf7f7529467bb4a5771"
 
 function.prototype.name@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.0.0.tgz#5f523ca64e491a5f95aba80cc1e391080a14482e"
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.0.1.tgz#39aeab26bbf8ab669b7142965d50ea0965d93d7b"
   dependencies:
     define-properties "^1.1.2"
     function-bind "^1.1.0"
-    is-callable "^1.1.2"
+    is-callable "^1.1.3"
 
 fuse.js@^3.0.1:
   version "3.0.5"
@@ -3133,8 +3195,8 @@ glamor@^2.20.25:
     prop-types "^15.5.8"
 
 glamorous@^3.22.1:
-  version "3.23.4"
-  resolved "https://registry.yarnpkg.com/glamorous/-/glamorous-3.23.4.tgz#a1e5f8045c332850105777dea4d3b21c5bdc4796"
+  version "3.23.5"
+  resolved "https://registry.yarnpkg.com/glamorous/-/glamorous-3.23.5.tgz#49f613a29f64cdee80948679c66dbcd4084e5fd5"
   dependencies:
     brcast "^2.0.0"
     fast-memoize "^2.2.7"
@@ -3287,8 +3349,8 @@ hash-base@^2.0.0:
     inherits "^2.0.1"
 
 hash.js@^1.0.0, hash.js@^1.0.3:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.2.tgz#bf5c887825cfe40b9efde7bf11bd2db26e6bf01b"
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.3.tgz#340dedbe6290187151c1ea1d777a3448935df846"
   dependencies:
     inherits "^2.0.3"
     minimalistic-assert "^1.0.0"
@@ -3436,10 +3498,14 @@ hyphenate-style-name@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.2.tgz#31160a36930adaf1fc04c6074f7eb41465d4ec4b"
 
-iconv-lite@0.4.13, iconv-lite@~0.4.13:
+iconv-lite@0.4.13:
   version "0.4.13"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2"
 
+iconv-lite@~0.4.13:
+  version "0.4.18"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2"
+
 icss-replace-symbols@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
@@ -3503,7 +3569,7 @@ ini@~1.3.0:
   version "1.3.4"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e"
 
-inline-style-prefixer@^3.0.2:
+inline-style-prefixer@^3.0.6:
   version "3.0.6"
   resolved "https://registry.yarnpkg.com/inline-style-prefixer/-/inline-style-prefixer-3.0.6.tgz#b27fe309b4168a31eaf38c8e8c60ab9e7c11731f"
   dependencies:
@@ -3560,12 +3626,24 @@ intl-messageformat@1.3.0, intl-messageformat@^1.3.0:
   dependencies:
     intl-messageformat-parser "1.2.0"
 
+intl-messageformat@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-2.0.0.tgz#3d56982583425aee23b76c8b985fb9b0aae5be3c"
+  dependencies:
+    intl-messageformat-parser "1.2.0"
+
 intl-relativeformat@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/intl-relativeformat/-/intl-relativeformat-1.3.0.tgz#893dc7076fccd380cf091a2300c380fa57ace45b"
   dependencies:
     intl-messageformat "1.3.0"
 
+intl-relativeformat@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/intl-relativeformat/-/intl-relativeformat-2.0.0.tgz#d6ba9dc6c625819bc0abdb1d4e238138b7488f26"
+  dependencies:
+    intl-messageformat "^2.0.0"
+
 intl@^1.2.5:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde"
@@ -3612,7 +3690,7 @@ is-builtin-module@^1.0.0:
   dependencies:
     builtin-modules "^1.0.0"
 
-is-callable@^1.1.1, is-callable@^1.1.2, is-callable@^1.1.3:
+is-callable@^1.1.1, is-callable@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
 
@@ -3730,10 +3808,10 @@ is-plain-obj@^1.0.0:
   resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
 
 is-plain-object@^2.0.1:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.3.tgz#c15bf3e4b66b62d72efaf2925848663ecbc619b6"
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
   dependencies:
-    isobject "^3.0.0"
+    isobject "^3.0.1"
 
 is-posix-bracket@^0.1.0:
   version "0.1.1"
@@ -3811,9 +3889,9 @@ isobject@^2.0.0:
   dependencies:
     isarray "1.0.0"
 
-isobject@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.0.tgz#39565217f3661789e8a0a0c080d5f7e6bc46e1a0"
+isobject@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
 
 isomorphic-fetch@^2.1.1:
   version "2.2.1"
@@ -3826,20 +3904,24 @@ isstream@~0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
 
+javascript-natural-sort@0.7.1:
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59"
+
 js-base64@^2.1.8, js-base64@^2.1.9:
   version "2.1.9"
   resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce"
 
 js-tokens@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.1.tgz#08e9f132484a2c45a30907e9dc4d5567b7f114d7"
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
 
-js-yaml@^3.4.3, js-yaml@^3.5.1, js-yaml@^3.8.4:
-  version "3.8.4"
-  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.8.4.tgz#520b4564f86573ba96662af85a8cafa7b4b5a6f6"
+js-yaml@^3.4.3, js-yaml@^3.5.1, js-yaml@^3.9.0:
+  version "3.9.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.9.0.tgz#4ffbbf25c2ac963b8299dc74da7e3740de1c18ce"
   dependencies:
     argparse "^1.0.7"
-    esprima "^3.1.1"
+    esprima "^4.0.0"
 
 js-yaml@~3.7.0:
   version "3.7.0"
@@ -3852,9 +3934,9 @@ jsbn@~0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
 
-jsdom@^11.0.0:
-  version "11.0.0"
-  resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.0.0.tgz#1ee507cb2c0b16c875002476b1a8557d951353e5"
+jsdom@^11.1.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.1.0.tgz#6c48d7a48ffc5c300283c312904d15da8360509b"
   dependencies:
     abab "^1.0.3"
     acorn "^4.0.4"
@@ -3865,7 +3947,7 @@ jsdom@^11.0.0:
     cssstyle ">= 0.2.37 < 0.3.0"
     escodegen "^1.6.1"
     html-encoding-sniffer "^1.0.1"
-    nwmatcher ">= 1.3.9 < 2.0.0"
+    nwmatcher "^1.4.1"
     parse5 "^3.0.2"
     pn "^1.0.0"
     request "^2.79.0"
@@ -3875,7 +3957,7 @@ jsdom@^11.0.0:
     tough-cookie "^2.3.2"
     webidl-conversions "^4.0.0"
     whatwg-encoding "^1.0.1"
-    whatwg-url "^4.3.0"
+    whatwg-url "^6.1.0"
     xml-name-validator "^2.0.1"
 
 jsesc@^1.3.0:
@@ -4015,7 +4097,7 @@ loader-runner@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2"
 
-loader-utils@^0.2.16:
+loader-utils@0.2.x:
   version "0.2.17"
   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.17.tgz#f86e6374d43205a6e6c60e9196f17c0299bfb348"
   dependencies:
@@ -4268,14 +4350,15 @@ math-expression-evaluator@^1.2.14:
   resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac"
 
 mathjs@^3.11.5:
-  version "3.13.3"
-  resolved "https://registry.yarnpkg.com/mathjs/-/mathjs-3.13.3.tgz#39135ea761f57c083da43638248e3f640727e290"
+  version "3.14.2"
+  resolved "https://registry.yarnpkg.com/mathjs/-/mathjs-3.14.2.tgz#bb79b7dc878b7f586ce408ab067a9a42db2e7a2d"
   dependencies:
-    complex.js "2.0.1"
-    decimal.js "7.1.1"
-    fraction.js "4.0.0"
+    complex.js "2.0.4"
+    decimal.js "7.2.3"
+    fraction.js "4.0.2"
+    javascript-natural-sort "0.7.1"
     seed-random "2.2.0"
-    tiny-emitter "1.0.2"
+    tiny-emitter "2.0.0"
     typed-function "0.10.5"
 
 media-typer@0.3.0:
@@ -4343,7 +4426,11 @@ miller-rabin@^4.0.0:
     bn.js "^4.0.0"
     brorand "^1.0.1"
 
-"mime-db@>= 1.27.0 < 2", mime-db@~1.27.0:
+"mime-db@>= 1.27.0 < 2":
+  version "1.29.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.29.0.tgz#48d26d235589651704ac5916ca06001914266878"
+
+mime-db@~1.27.0:
   version "1.27.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1"
 
@@ -4353,10 +4440,14 @@ mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.7:
   dependencies:
     mime-db "~1.27.0"
 
-mime@1.3.4, mime@1.3.x, mime@^1.3.4:
+mime@1.3.4:
   version "1.3.4"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53"
 
+mime@1.3.x, mime@^1.3.4:
+  version "1.3.6"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.6.tgz#591d84d3653a6b0b4a3b9df8de5aa8108e72e5e0"
+
 mimic-fn@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18"
@@ -4375,7 +4466,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
 
-minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
+minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   dependencies:
@@ -4426,10 +4517,6 @@ mocha@^3.4.1:
     mkdirp "0.5.1"
     supports-color "3.1.2"
 
-ms@0.7.1:
-  version "0.7.1"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098"
-
 ms@0.7.2:
   version "0.7.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765"
@@ -4648,7 +4735,7 @@ number-is-nan@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
 
-"nwmatcher@>= 1.3.9 < 2.0.0":
+nwmatcher@^1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.1.tgz#7ae9b07b0ea804db7e25f05cb5fe4097d4e4949f"
 
@@ -4664,6 +4751,10 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
 
+object-fit-images@^3.2.3:
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/object-fit-images/-/object-fit-images-3.2.3.tgz#4089f6d0070a3b5563d3c1ab6f1b28d61331f0ac"
+
 object-is@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.1.tgz#0aa60ec9989a0b3ed795cf4d06f62cf1ad6539b6"
@@ -4720,6 +4811,16 @@ obuf@^1.0.0, obuf@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.1.tgz#104124b6c602c6796881a042541d36db43a5264e"
 
+offline-plugin@^4.8.3:
+  version "4.8.3"
+  resolved "https://registry.yarnpkg.com/offline-plugin/-/offline-plugin-4.8.3.tgz#9e95bd342ea2ac836b001b81f204c40638694d6c"
+  dependencies:
+    deep-extend "^0.4.0"
+    ejs "^2.3.4"
+    loader-utils "0.2.x"
+    minimatch "^3.0.3"
+    slash "^1.0.0"
+
 on-finished@~2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -4837,6 +4938,20 @@ parse-asn1@^5.0.0:
     evp_bytestokey "^1.0.0"
     pbkdf2 "^3.0.3"
 
+parse-css-font@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/parse-css-font/-/parse-css-font-2.0.2.tgz#7b60b060705a25a9b90b7f0ed493e5823248a652"
+  dependencies:
+    css-font-size-keywords "^1.0.0"
+    css-font-stretch-keywords "^1.0.1"
+    css-font-style-keywords "^1.0.1"
+    css-font-weight-keywords "^1.0.0"
+    css-global-keywords "^1.0.1"
+    css-list-helpers "^1.0.1"
+    css-system-font-keywords "^1.0.0"
+    tcomb "^2.5.0"
+    unquote "^1.1.0"
+
 parse-glob@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
@@ -5322,6 +5437,14 @@ postcss-normalize-url@^3.0.7:
     postcss "^5.0.14"
     postcss-value-parser "^3.2.3"
 
+postcss-object-fit-images@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/postcss-object-fit-images/-/postcss-object-fit-images-1.1.2.tgz#8b773043db14672ef6cd6f2cb1f0d8b26a9f573b"
+  dependencies:
+    parse-css-font "^2.0.2"
+    postcss "^5.0.16"
+    quote "^0.4.0"
+
 postcss-ordered-values@^2.1.0:
   version "2.2.3"
   resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-2.2.3.tgz#eec6c2a67b6c412a8db2042e77fe8da43f95c11d"
@@ -5373,7 +5496,7 @@ postcss-sass@^0.1.0:
     mathjs "^3.11.5"
     postcss "^5.2.6"
 
-postcss-scss@^1.0.0:
+postcss-scss@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-1.0.2.tgz#ff45cf3354b879ee89a4eb68680f46ac9bb14f94"
   dependencies:
@@ -5407,16 +5530,16 @@ postcss-simple-vars@^4.0.0:
   dependencies:
     postcss "^6.0.1"
 
-postcss-smart-import@^0.7.4:
-  version "0.7.4"
-  resolved "https://registry.yarnpkg.com/postcss-smart-import/-/postcss-smart-import-0.7.4.tgz#50cfb3d9a49b70a61f911451bc24d841f8dbf200"
+postcss-smart-import@^0.7.5:
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/postcss-smart-import/-/postcss-smart-import-0.7.5.tgz#df9a9c6dd60d916e5e0670d1c57d03af5d3dcc31"
   dependencies:
     babel-runtime "^6.23.0"
     lodash "^4.17.4"
     object-assign "^4.1.1"
-    postcss "^6.0.1"
+    postcss "^6.0.6"
     postcss-sass "^0.1.0"
-    postcss-scss "^1.0.0"
+    postcss-scss "^1.0.2"
     postcss-value-parser "^3.3.0"
     promise-each "^2.2.0"
     read-cache "^1.0.0"
@@ -5461,13 +5584,13 @@ postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0
     source-map "^0.5.6"
     supports-color "^3.2.3"
 
-postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.2, postcss@^6.0.3:
-  version "6.0.3"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.3.tgz#b7f565b3d956fbb8565ca7c1e239d0506e427d8b"
+postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.2, postcss@^6.0.3, postcss@^6.0.6:
+  version "6.0.6"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.6.tgz#bba4d58e884fc78c840d1539e10eddaabb8f73bd"
   dependencies:
-    chalk "^1.1.3"
+    chalk "^2.0.1"
     source-map "^0.5.6"
-    supports-color "^4.0.0"
+    supports-color "^4.1.0"
 
 postgres-array@~1.0.0:
   version "1.0.2"
@@ -5604,10 +5727,14 @@ q@^1.1.2:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1"
 
-qs@6.4.0, qs@^6.4.0, qs@~6.4.0:
+qs@6.4.0, qs@~6.4.0:
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
 
+qs@^6.4.0:
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.0.tgz#8d04954d364def3efc55b5a0793e1e2c8b1e6e49"
+
 query-string@^4.1.0:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"
@@ -5631,6 +5758,10 @@ querystringify@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-1.0.0.tgz#6286242112c5b712fa654e526652bf6a13ff05cb"
 
+quote@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/quote/-/quote-0.4.0.tgz#10839217f6c1362b89194044d29b233fd7f32f01"
+
 raf@^3.1.0:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/raf/-/raf-3.3.2.tgz#0c13be0b5b49b46f76d6669248d527cf2b02fe27"
@@ -5740,9 +5871,9 @@ react-immutable-pure-component@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/react-immutable-pure-component/-/react-immutable-pure-component-1.0.0.tgz#761d27b1497c5af64d2d2454e17b26ce7c9cda88"
 
-react-inspector@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-2.0.0.tgz#c945932f2c2bf2fab7873c6e07d83881404b9313"
+react-inspector@^2.0.0, react-inspector@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-2.1.1.tgz#2e68030d7ef0811a012f167258dd84232fd5ead1"
   dependencies:
     babel-runtime "^6.23.0"
     is-dom "^1.0.9"
@@ -5871,11 +6002,11 @@ react-simple-dropdown@^3.0.0:
     prop-types "^15.5.8"
 
 react-split-pane@^0.1.63:
-  version "0.1.63"
-  resolved "https://registry.yarnpkg.com/react-split-pane/-/react-split-pane-0.1.63.tgz#fadb3960cc659911dd05ffbc88acee4be9f53583"
+  version "0.1.64"
+  resolved "https://registry.yarnpkg.com/react-split-pane/-/react-split-pane-0.1.64.tgz#725029b9fcb51059aa82f2b8622832473849f559"
   dependencies:
-    inline-style-prefixer "^3.0.2"
-    prop-types "^15.5.8"
+    inline-style-prefixer "^3.0.6"
+    prop-types "^15.5.10"
     react-style-proptype "^3.0.0"
 
 react-stubber@^1.0.0:
@@ -5939,8 +6070,8 @@ react-toggle@^4.0.1:
     classnames "^2.2.5"
 
 react-virtualized@^9.7.4:
-  version "9.8.0"
-  resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.8.0.tgz#7c1fe9b723ce39a1c4916cabe1c4f1bda5dbc04b"
+  version "9.9.0"
+  resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.9.0.tgz#799a6f23819eeb82860d59b82fad33d1d420325e"
   dependencies:
     babel-runtime "^6.11.6"
     classnames "^2.2.3"
@@ -5995,15 +6126,15 @@ read-pkg@^2.0.0:
     path-type "^2.0.0"
 
 readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.2.2, readable-stream@^2.2.6, readable-stream@^2.2.9:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.2.tgz#5a04df05e4f57fe3f0dc68fdd11dc5c97c7e6f4d"
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c"
   dependencies:
     core-util-is "~1.0.0"
     inherits "~2.0.3"
     isarray "~1.0.0"
     process-nextick-args "~1.0.6"
-    safe-buffer "~5.1.0"
-    string_decoder "~1.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.0.3"
     util-deprecate "~1.0.1"
 
 readdirp@^2.0.0:
@@ -6212,7 +6343,7 @@ require-directory@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
 
-require-from-string@^1.1.0:
+require-from-string@^1.1.0, require-from-string@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-1.2.1.tgz#529c9ccef27380adfec9a2f965b649bbee636418"
 
@@ -6322,7 +6453,7 @@ safe-buffer@5.0.1, safe-buffer@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
 
-safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.1.0:
+safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
 
@@ -6503,9 +6634,9 @@ signal-exit@^3.0.0:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
 
-sinon@^2.3.5:
-  version "2.3.5"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.3.5.tgz#9a2fc0ff8d526da716f30953aa2c65d518917f6c"
+sinon@^2.3.7:
+  version "2.3.7"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.3.7.tgz#1451614a2eaab05bb4d876c1335cd40132ec5127"
   dependencies:
     diff "^3.1.0"
     formatio "1.2.0"
@@ -6568,10 +6699,6 @@ source-list-map@^0.1.7, source-list-map@~0.1.7:
   version "0.1.8"
   resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.8.tgz#c550b2ab5427f6b3f21f5afead88c4f5587b2106"
 
-source-list-map@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-1.1.2.tgz#9889019d1024cce55cdc069498337ef6186a11a1"
-
 source-list-map@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085"
@@ -6752,7 +6879,7 @@ string_decoder@^0.10.25:
   version "0.10.31"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
 
-string_decoder@~1.0.0:
+string_decoder@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab"
   dependencies:
@@ -6822,9 +6949,9 @@ style-loader@^0.18.2:
     loader-utils "^1.0.2"
     schema-utils "^0.3.0"
 
-substring-trie@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/substring-trie/-/substring-trie-1.0.0.tgz#5a7ecb83aefcca7b3720f7897cf69e97023be143"
+substring-trie@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/substring-trie/-/substring-trie-1.0.1.tgz#1a5f07f774a91524eb067cb318dd4f3a3037bee0"
 
 sugarss@^1.0.0:
   version "1.0.0"
@@ -6848,9 +6975,9 @@ supports-color@^3.1.0, supports-color@^3.1.1, supports-color@^3.2.3:
   dependencies:
     has-flag "^1.0.0"
 
-supports-color@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.0.0.tgz#33a7c680aa512c9d03ef929cacbb974d203d2790"
+supports-color@^4.0.0, supports-color@^4.1.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.2.0.tgz#ad986dc7eb2315d009b4d77c8169c2231a684037"
   dependencies:
     has-flag "^2.0.0"
 
@@ -6914,7 +7041,7 @@ tar@^2.0.0, tar@^2.2.1:
     fstream "^1.0.2"
     inherits "2"
 
-tcomb@^2.5.1:
+tcomb@^2.5.0, tcomb@^2.5.1:
   version "2.7.0"
   resolved "https://registry.yarnpkg.com/tcomb/-/tcomb-2.7.0.tgz#10d62958041669a5d53567b9a4ee8cde22b1c2b0"
 
@@ -6946,9 +7073,9 @@ timers-browserify@^2.0.2:
   dependencies:
     setimmediate "^1.0.4"
 
-tiny-emitter@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-1.0.2.tgz#8e49470d3f55f89e247210368a6bb9fb51aa1601"
+tiny-emitter@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.0.tgz#bad327adb1804b42a231afa741532bd884cd09ad"
 
 tiny-queue@^0.2.1:
   version "0.2.1"
@@ -7035,7 +7162,7 @@ ua-parser-js@^0.7.9:
   version "0.7.13"
   resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.13.tgz#cd9dd2f86493b3f44dbeeef3780fda74c5ee14be"
 
-uglify-js@^2.8.27, uglify-js@^2.8.29:
+uglify-js@^2.8.29:
   version "2.8.29"
   resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd"
   dependencies:
@@ -7048,7 +7175,7 @@ uglify-to-browserify@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7"
 
-uglifyjs-webpack-plugin@^0.4.4:
+uglifyjs-webpack-plugin@^0.4.6:
   version "0.4.6"
   resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-0.4.6.tgz#b951f4abb6bd617e66f63eb891498e391763e309"
   dependencies:
@@ -7088,6 +7215,10 @@ unpipe@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
 
+unquote@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.0.tgz#98e1fc608b6b854c75afb1b95afc099ba69d942f"
+
 urix@^0.1.0, urix@~0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
@@ -7163,7 +7294,7 @@ value-equal@^0.2.0:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.2.1.tgz#c220a304361fce6994dbbedaa3c7e1a1b895871d"
 
-vary@~1.1.0, vary@~1.1.1:
+vary@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37"
 
@@ -7203,11 +7334,7 @@ wbuf@^1.1.0, wbuf@^1.7.2:
   dependencies:
     minimalistic-assert "^1.0.0"
 
-webidl-conversions@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
-
-webidl-conversions@^4.0.0:
+webidl-conversions@^4.0.0, webidl-conversions@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.1.tgz#8015a17ab83e7e1b311638486ace81da6ce206a0"
 
@@ -7263,17 +7390,17 @@ webpack-dev-server@^2.5.1:
     yargs "^6.0.0"
 
 webpack-hot-middleware@^2.18.0:
-  version "2.18.0"
-  resolved "https://registry.yarnpkg.com/webpack-hot-middleware/-/webpack-hot-middleware-2.18.0.tgz#a16bb535b83a6ac94a78ac5ebce4f3059e8274d3"
+  version "2.18.2"
+  resolved "https://registry.yarnpkg.com/webpack-hot-middleware/-/webpack-hot-middleware-2.18.2.tgz#84dee643f037c3d59c9de142548430371aa8d3b2"
   dependencies:
     ansi-html "0.0.7"
     html-entities "^1.2.0"
     querystring "^0.2.0"
     strip-ansi "^3.0.0"
 
-webpack-manifest-plugin@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/webpack-manifest-plugin/-/webpack-manifest-plugin-1.1.0.tgz#6b6c718aade8a2537995784b46bd2e9836057caa"
+webpack-manifest-plugin@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/webpack-manifest-plugin/-/webpack-manifest-plugin-1.1.2.tgz#e9d9967f4f739ee25380ca57de7f9417c5bea029"
   dependencies:
     fs-extra "^0.30.0"
     lodash ">=3.5 <5"
@@ -7291,13 +7418,6 @@ webpack-sources@^0.1.0:
     source-list-map "~0.1.7"
     source-map "~0.5.3"
 
-webpack-sources@^0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-0.2.3.tgz#17c62bfaf13c707f9d02c479e0dcdde8380697fb"
-  dependencies:
-    source-list-map "^1.1.1"
-    source-map "~0.5.3"
-
 webpack-sources@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.0.1.tgz#c7356436a4d13123be2e2426a05d1dad9cbe65cf"
@@ -7305,42 +7425,16 @@ webpack-sources@^1.0.1:
     source-list-map "^2.0.0"
     source-map "~0.5.3"
 
-webpack@^2.5.1:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.6.1.tgz#2e0457f0abb1ac5df3ab106c69c672f236785f07"
-  dependencies:
-    acorn "^5.0.0"
-    acorn-dynamic-import "^2.0.0"
-    ajv "^4.7.0"
-    ajv-keywords "^1.1.1"
-    async "^2.1.2"
-    enhanced-resolve "^3.0.0"
-    interpret "^1.0.0"
-    json-loader "^0.5.4"
-    json5 "^0.5.1"
-    loader-runner "^2.3.0"
-    loader-utils "^0.2.16"
-    memory-fs "~0.4.1"
-    mkdirp "~0.5.0"
-    node-libs-browser "^2.0.0"
-    source-map "^0.5.3"
-    supports-color "^3.1.0"
-    tapable "~0.2.5"
-    uglify-js "^2.8.27"
-    watchpack "^1.3.1"
-    webpack-sources "^0.2.3"
-    yargs "^6.0.0"
-
-webpack@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.0.0.tgz#ee9bcebf21247f7153cb410168cab45e3a59d4d7"
+"webpack@^2.5.1 || ^3.0.0", webpack@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.2.0.tgz#8b0cae0e1a9fd76bfbf0eab61a8c2ada848c312f"
   dependencies:
     acorn "^5.0.0"
     acorn-dynamic-import "^2.0.0"
     ajv "^5.1.5"
     ajv-keywords "^2.0.0"
     async "^2.1.2"
-    enhanced-resolve "^3.0.0"
+    enhanced-resolve "^3.3.0"
     escope "^3.6.0"
     interpret "^1.0.0"
     json-loader "^0.5.4"
@@ -7353,7 +7447,7 @@ webpack@^3.0.0:
     source-map "^0.5.3"
     supports-color "^3.1.0"
     tapable "~0.2.5"
-    uglifyjs-webpack-plugin "^0.4.4"
+    uglifyjs-webpack-plugin "^0.4.6"
     watchpack "^1.3.1"
     webpack-sources "^1.0.1"
     yargs "^6.0.0"
@@ -7384,12 +7478,13 @@ whatwg-fetch@>=0.10.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84"
 
-whatwg-url@^4.3.0:
-  version "4.8.0"
-  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-4.8.0.tgz#d2981aa9148c1e00a41c5a6131166ab4683bbcc0"
+whatwg-url@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.1.0.tgz#5fc8279b93d75483b9ced8b26239854847a18578"
   dependencies:
+    lodash.sortby "^4.7.0"
     tr46 "~0.0.3"
-    webidl-conversions "^3.0.0"
+    webidl-conversions "^4.0.1"
 
 whet.extend@~0.9.9:
   version "0.9.9"