about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorDavid Yip <yipdw@member.fsf.org>2017-09-09 14:27:47 -0500
committerDavid Yip <yipdw@member.fsf.org>2017-09-09 14:27:47 -0500
commitb9f7bc149b2a6abfbdaee83e6992b617b8bdb18e (patch)
tree355225f4424a6ea1b40c66c5540ccab42096e3bf /app
parente18ed4bbc7ab4e258d05a3e2a5db0790f67a8f37 (diff)
parent5d170587e3b6c1a3b3ebe0910b62a4c526e2900d (diff)
Merge branch 'origin/master' into sync/upstream
 Conflicts:
	app/javascript/mastodon/components/status_list.js
	app/javascript/mastodon/features/notifications/index.js
	app/javascript/mastodon/features/ui/components/modal_root.js
	app/javascript/mastodon/features/ui/components/onboarding_modal.js
	app/javascript/mastodon/features/ui/index.js
	app/javascript/styles/about.scss
	app/javascript/styles/accounts.scss
	app/javascript/styles/components.scss
	app/presenters/instance_presenter.rb
	app/services/post_status_service.rb
	app/services/reblog_service.rb
	app/views/about/more.html.haml
	app/views/about/show.html.haml
	app/views/accounts/_header.html.haml
	config/webpack/loaders/babel.js
	spec/controllers/api/v1/accounts/credentials_controller_spec.rb
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb56
-rw-r--r--app/controllers/activitypub/inboxes_controller.rb36
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb2
-rw-r--r--app/controllers/admin/accounts_controller.rb2
-rw-r--r--app/controllers/admin/statuses_controller.rb4
-rw-r--r--app/controllers/api/base_controller.rb11
-rw-r--r--app/controllers/api/oembed_controller.rb8
-rw-r--r--app/controllers/api/v1/accounts/credentials_controller.rb4
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb5
-rw-r--r--app/controllers/api/v1/statuses/pins_controller.rb28
-rw-r--r--app/controllers/api/v1/statuses_controller.rb2
-rw-r--r--app/controllers/api/web/embeds_controller.rb17
-rw-r--r--app/controllers/concerns/account_controller_concern.rb8
-rw-r--r--app/controllers/concerns/signature_verification.rb25
-rw-r--r--app/controllers/follower_accounts_controller.rb2
-rw-r--r--app/controllers/following_accounts_controller.rb2
-rw-r--r--app/controllers/intents_controller.rb18
-rw-r--r--app/controllers/settings/applications_controller.rb72
-rw-r--r--app/controllers/settings/profiles_controller.rb3
-rw-r--r--app/controllers/shares_controller.rb30
-rw-r--r--app/controllers/statuses_controller.rb21
-rw-r--r--app/controllers/stream_entries_controller.rb12
-rw-r--r--app/controllers/tags_controller.rb2
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/helpers/jsonld_helper.rb52
-rw-r--r--app/helpers/routing_helper.rb4
-rw-r--r--app/helpers/stream_entries_helper.rb2
-rw-r--r--app/javascript/mastodon/actions/interactions.js78
-rw-r--r--app/javascript/mastodon/actions/streaming.js94
-rw-r--r--app/javascript/mastodon/components/account.js12
-rw-r--r--app/javascript/mastodon/components/column.js2
-rw-r--r--app/javascript/mastodon/components/intersection_observer_article.js122
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js179
-rw-r--r--app/javascript/mastodon/components/status.js114
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js29
-rw-r--r--app/javascript/mastodon/components/status_list.js156
-rw-r--r--app/javascript/mastodon/containers/account_container.js2
-rw-r--r--app/javascript/mastodon/containers/compose_container.js39
-rw-r--r--app/javascript/mastodon/containers/mastodon.js82
-rw-r--r--app/javascript/mastodon/containers/status_container.js14
-rw-r--r--app/javascript/mastodon/features/account/components/header.js4
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js2
-rw-r--r--app/javascript/mastodon/features/community_timeline/index.js50
-rw-r--r--app/javascript/mastodon/features/favourited_statuses/index.js5
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js35
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js10
-rw-r--r--app/javascript/mastodon/features/notifications/index.js104
-rw-r--r--app/javascript/mastodon/features/public_timeline/index.js50
-rw-r--r--app/javascript/mastodon/features/standalone/compose/index.js18
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js23
-rw-r--r--app/javascript/mastodon/features/status/components/card.js9
-rw-r--r--app/javascript/mastodon/features/status/index.js16
-rw-r--r--app/javascript/mastodon/features/ui/components/column.js11
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js35
-rw-r--r--app/javascript/mastodon/features/ui/components/embed_modal.js84
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/onboarding_modal.js2
-rw-r--r--app/javascript/mastodon/features/ui/containers/columns_area_container.js2
-rw-r--r--app/javascript/mastodon/features/ui/index.js19
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js4
-rw-r--r--app/javascript/mastodon/locales/ar.json7
-rw-r--r--app/javascript/mastodon/locales/bg.json5
-rw-r--r--app/javascript/mastodon/locales/ca.json5
-rw-r--r--app/javascript/mastodon/locales/de.json157
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json43
-rw-r--r--app/javascript/mastodon/locales/en.json7
-rw-r--r--app/javascript/mastodon/locales/eo.json5
-rw-r--r--app/javascript/mastodon/locales/es.json5
-rw-r--r--app/javascript/mastodon/locales/fa.json5
-rw-r--r--app/javascript/mastodon/locales/fi.json5
-rw-r--r--app/javascript/mastodon/locales/fr.json21
-rw-r--r--app/javascript/mastodon/locales/he.json5
-rw-r--r--app/javascript/mastodon/locales/hr.json5
-rw-r--r--app/javascript/mastodon/locales/hu.json5
-rw-r--r--app/javascript/mastodon/locales/id.json5
-rw-r--r--app/javascript/mastodon/locales/io.json5
-rw-r--r--app/javascript/mastodon/locales/it.json5
-rw-r--r--app/javascript/mastodon/locales/ja.json7
-rw-r--r--app/javascript/mastodon/locales/ko.json5
-rw-r--r--app/javascript/mastodon/locales/nl.json5
-rw-r--r--app/javascript/mastodon/locales/no.json5
-rw-r--r--app/javascript/mastodon/locales/oc.json47
-rw-r--r--app/javascript/mastodon/locales/pl.json41
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json81
-rw-r--r--app/javascript/mastodon/locales/pt.json5
-rw-r--r--app/javascript/mastodon/locales/ru.json43
-rw-r--r--app/javascript/mastodon/locales/th.json5
-rw-r--r--app/javascript/mastodon/locales/tr.json5
-rw-r--r--app/javascript/mastodon/locales/uk.json5
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json33
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json5
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json5
-rw-r--r--app/javascript/mastodon/reducers/compose.js12
-rw-r--r--app/javascript/mastodon/reducers/status_lists.js20
-rw-r--r--app/javascript/mastodon/reducers/statuses.js4
-rw-r--r--app/javascript/mastodon/scroll.js2
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js26
-rw-r--r--app/javascript/mastodon/web_push_subscription.js4
-rw-r--r--app/javascript/packs/public.js12
-rw-r--r--app/javascript/packs/share.js24
-rw-r--r--app/javascript/styles/about.scss867
-rw-r--r--app/javascript/styles/accounts.scss343
-rw-r--r--app/javascript/styles/admin.scss59
-rw-r--r--app/javascript/styles/basics.scss36
-rw-r--r--app/javascript/styles/components.scss97
-rw-r--r--app/javascript/styles/containers.scss22
-rw-r--r--app/javascript/styles/forms.scss47
-rw-r--r--app/javascript/styles/landing_strip.scss13
-rw-r--r--app/javascript/styles/rtl.scss129
-rw-r--r--app/javascript/styles/stream_entries.scss47
-rw-r--r--app/javascript/styles/tables.scss2
-rw-r--r--app/lib/activitypub/activity.rb106
-rw-r--r--app/lib/activitypub/activity/accept.rb25
-rw-r--r--app/lib/activitypub/activity/announce.rb28
-rw-r--r--app/lib/activitypub/activity/block.rb12
-rw-r--r--app/lib/activitypub/activity/create.rb175
-rw-r--r--app/lib/activitypub/activity/delete.rb45
-rw-r--r--app/lib/activitypub/activity/follow.rb24
-rw-r--r--app/lib/activitypub/activity/like.rb12
-rw-r--r--app/lib/activitypub/activity/reject.rb25
-rw-r--r--app/lib/activitypub/activity/undo.rb71
-rw-r--r--app/lib/activitypub/activity/update.rb17
-rw-r--r--app/lib/activitypub/adapter.rb23
-rw-r--r--app/lib/activitypub/case_transform.rb24
-rw-r--r--app/lib/activitypub/linked_data_signature.rb56
-rw-r--r--app/lib/activitypub/tag_manager.rb38
-rw-r--r--app/lib/ostatus/activity/base.rb24
-rw-r--r--app/lib/ostatus/activity/creation.rb49
-rw-r--r--app/lib/ostatus/activity/deletion.rb4
-rw-r--r--app/lib/ostatus/activity/remote.rb6
-rw-r--r--app/lib/ostatus/atom_serializer.rb5
-rw-r--r--app/lib/request.rb24
-rw-r--r--app/lib/status_finder.rb (renamed from app/lib/stream_entry_finder.rb)10
-rw-r--r--app/models/account.rb17
-rw-r--r--app/models/concerns/account_avatar.rb2
-rw-r--r--app/models/concerns/account_header.rb2
-rw-r--r--app/models/concerns/account_interactions.rb4
-rw-r--r--app/models/concerns/remotable.rb2
-rw-r--r--app/models/import.rb1
-rw-r--r--app/models/media_attachment.rb3
-rw-r--r--app/models/preview_card.rb31
-rw-r--r--app/models/remote_follow.rb2
-rw-r--r--app/models/session_activation.rb1
-rw-r--r--app/models/status.rb20
-rw-r--r--app/models/status_pin.rb18
-rw-r--r--app/models/subscription.rb1
-rw-r--r--app/models/user.rb15
-rw-r--r--app/models/web/push_subscription.rb160
-rw-r--r--app/presenters/initial_state_presenter.rb3
-rw-r--r--app/presenters/instance_presenter.rb4
-rw-r--r--app/presenters/status_relationships_presenter.rb19
-rw-r--r--app/serializers/activitypub/accept_follow_serializer.rb6
-rw-r--r--app/serializers/activitypub/activity_serializer.rb8
-rw-r--r--app/serializers/activitypub/actor_serializer.rb48
-rw-r--r--app/serializers/activitypub/block_serializer.rb6
-rw-r--r--app/serializers/activitypub/delete_serializer.rb29
-rw-r--r--app/serializers/activitypub/follow_serializer.rb6
-rw-r--r--app/serializers/activitypub/like_serializer.rb6
-rw-r--r--app/serializers/activitypub/note_serializer.rb36
-rw-r--r--app/serializers/activitypub/reject_follow_serializer.rb6
-rw-r--r--app/serializers/activitypub/undo_announce_serializer.rb19
-rw-r--r--app/serializers/activitypub/undo_block_serializer.rb6
-rw-r--r--app/serializers/activitypub/undo_follow_serializer.rb6
-rw-r--r--app/serializers/activitypub/undo_like_serializer.rb6
-rw-r--r--app/serializers/activitypub/update_serializer.rb6
-rw-r--r--app/serializers/initial_state_serializer.rb3
-rw-r--r--app/serializers/oembed_serializer.rb19
-rw-r--r--app/serializers/rest/instance_serializer.rb16
-rw-r--r--app/serializers/rest/status_serializer.rb16
-rw-r--r--app/serializers/web/notification_serializer.rb169
-rw-r--r--app/services/activitypub/fetch_remote_account_service.rb57
-rw-r--r--app/services/activitypub/fetch_remote_key_service.rb47
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb48
-rw-r--r--app/services/activitypub/process_account_service.rb93
-rw-r--r--app/services/activitypub/process_collection_service.rb48
-rw-r--r--app/services/authorize_follow_service.rb30
-rw-r--r--app/services/batched_remove_status_service.rb67
-rw-r--r--app/services/block_domain_service.rb2
-rw-r--r--app/services/block_service.rb19
-rw-r--r--app/services/favourite_service.rb28
-rw-r--r--app/services/fetch_atom_service.rb84
-rw-r--r--app/services/fetch_link_card_service.rb100
-rw-r--r--app/services/fetch_remote_account_service.rb16
-rw-r--r--app/services/fetch_remote_resource_service.rb33
-rw-r--r--app/services/fetch_remote_status_service.rb16
-rw-r--r--app/services/follow_service.rb14
-rw-r--r--app/services/post_status_service.rb2
-rw-r--r--app/services/process_interaction_service.rb4
-rw-r--r--app/services/process_mentions_service.rb28
-rw-r--r--app/services/reblog_service.rb28
-rw-r--r--app/services/reject_follow_service.rb19
-rw-r--r--app/services/remove_status_service.rb51
-rw-r--r--app/services/resolve_remote_account_service.rb55
-rw-r--r--app/services/subscribe_service.rb4
-rw-r--r--app/services/unblock_service.rb19
-rw-r--r--app/services/unfavourite_service.rb22
-rw-r--r--app/services/unfollow_service.rb45
-rw-r--r--app/services/unsubscribe_service.rb2
-rw-r--r--app/services/update_account_service.rb21
-rw-r--r--app/validators/status_pin_validator.rb9
-rw-r--r--app/views/about/more.html.haml4
-rw-r--r--app/views/about/show.html.haml4
-rw-r--r--app/views/accounts/_grid_card.html.haml11
-rw-r--r--app/views/accounts/_header.html.haml44
-rw-r--r--app/views/accounts/show.html.haml11
-rw-r--r--app/views/admin/accounts/_account.html.haml3
-rw-r--r--app/views/admin/accounts/index.html.haml1
-rw-r--r--app/views/admin/accounts/show.html.haml47
-rw-r--r--app/views/auth/registrations/edit.html.haml2
-rw-r--r--app/views/home/index.html.haml2
-rwxr-xr-xapp/views/layouts/application.html.haml3
-rw-r--r--app/views/settings/applications/_fields.html.haml21
-rw-r--r--app/views/settings/applications/index.html.haml19
-rw-r--r--app/views/settings/applications/new.html.haml8
-rw-r--r--app/views/settings/applications/show.html.haml30
-rw-r--r--app/views/shared/_landing_strip.html.haml9
-rw-r--r--app/views/shares/show.html.haml5
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml5
-rw-r--r--app/views/stream_entries/_status.html.haml7
-rw-r--r--app/views/stream_entries/embed.html.haml5
-rw-r--r--app/views/stream_entries/show.html.haml1
-rw-r--r--app/workers/activitypub/delivery_worker.rb37
-rw-r--r--app/workers/activitypub/distribution_worker.rb42
-rw-r--r--app/workers/activitypub/post_upgrade_worker.rb15
-rw-r--r--app/workers/activitypub/processing_worker.rb11
-rw-r--r--app/workers/activitypub/raw_distribution_worker.rb23
-rw-r--r--app/workers/activitypub/reply_distribution_worker.rb42
-rw-r--r--app/workers/activitypub/update_distribution_worker.rb31
-rw-r--r--app/workers/authorize_follow_worker.rb14
-rw-r--r--app/workers/pubsubhubbub/distribution_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/raw_distribution_worker.rb22
-rw-r--r--app/workers/pubsubhubbub/unsubscribe_worker.rb15
-rw-r--r--app/workers/scheduler/feed_cleanup_scheduler.rb2
-rw-r--r--app/workers/scheduler/media_cleanup_scheduler.rb1
-rw-r--r--app/workers/scheduler/subscriptions_cleanup_scheduler.rb11
-rw-r--r--app/workers/scheduler/subscriptions_scheduler.rb2
236 files changed, 5377 insertions, 1869 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index c270eb000..8dad12f11 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -7,8 +7,17 @@ class AccountsController < ApplicationController
   def show
     respond_to do |format|
       format.html do
-        @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
-        @statuses = cache_collection(@statuses, Status)
+        @pinned_statuses = []
+
+        if current_account && @account.blocking?(current_account)
+          @statuses = []
+          return
+        end
+
+        @pinned_statuses = cache_collection(@account.pinned_statuses, Status) unless media_requested?
+        @statuses        = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id])
+        @statuses        = cache_collection(@statuses, Status)
+        @next_url        = next_url unless @statuses.empty?
       end
 
       format.atom do
@@ -17,14 +26,55 @@ class AccountsController < ApplicationController
       end
 
       format.json do
-        render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
+        render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
 
   private
 
+  def filtered_statuses
+    default_statuses.tap do |statuses|
+      statuses.merge!(only_media_scope) if media_requested?
+      statuses.merge!(no_replies_scope) unless replies_requested?
+    end
+  end
+
+  def default_statuses
+    @account.statuses.where(visibility: [:public, :unlisted])
+  end
+
+  def only_media_scope
+    Status.where(id: account_media_status_ids)
+  end
+
+  def account_media_status_ids
+    @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
+  end
+
+  def no_replies_scope
+    Status.without_replies
+  end
+
   def set_account
     @account = Account.find_local!(params[:username])
   end
+
+  def next_url
+    if media_requested?
+      short_account_media_url(@account, max_id: @statuses.last.id)
+    elsif replies_requested?
+      short_account_with_replies_url(@account, max_id: @statuses.last.id)
+    else
+      short_account_url(@account, max_id: @statuses.last.id)
+    end
+  end
+
+  def media_requested?
+    request.path.ends_with?('/media')
+  end
+
+  def replies_requested?
+    request.path.ends_with?('/with_replies')
+  end
 end
diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb
new file mode 100644
index 000000000..5fce505fd
--- /dev/null
+++ b/app/controllers/activitypub/inboxes_controller.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class ActivityPub::InboxesController < Api::BaseController
+  include SignatureVerification
+
+  before_action :set_account
+
+  def create
+    if signed_request_account
+      upgrade_account
+      process_payload
+      head 201
+    else
+      head 202
+    end
+  end
+
+  private
+
+  def set_account
+    @account = Account.find_local!(params[:account_username]) if params[:account_username]
+  end
+
+  def body
+    @body ||= request.body.read
+  end
+
+  def upgrade_account
+    return unless signed_request_account.subscribed?
+    Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id)
+  end
+
+  def process_payload
+    ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'))
+  end
+end
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index 30b91f370..9f97ff622 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -7,7 +7,7 @@ class ActivityPub::OutboxesController < Api::BaseController
     @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
+    render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
   end
 
   private
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 7bceee2cd..54c659e1b 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -17,7 +17,7 @@ module Admin
     end
 
     def unsubscribe
-      UnsubscribeService.new.call(@account)
+      Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id)
       redirect_to admin_account_path(@account.id)
     end
 
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index 50712f0dd..b05000b16 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -9,7 +9,7 @@ module Admin
     before_action :set_account
     before_action :set_status, only: [:update, :destroy]
 
-    PAR_PAGE = 20
+    PER_PAGE = 20
 
     def index
       @statuses = @account.statuses
@@ -17,7 +17,7 @@ module Admin
         account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
         @statuses.merge!(Status.where(id: account_media_status_ids))
       end
-      @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE)
+      @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
 
       @form = Form::StatusBatch.new
     end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 105a2859d..7cfe8fe71 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -43,7 +43,7 @@ class Api::BaseController < ApplicationController
     links = []
     links << [next_path, [%w(rel next)]] if next_path
     links << [prev_path, [%w(rel prev)]] if prev_path
-    response.headers['Link'] = LinkHeader.new(links)
+    response.headers['Link'] = LinkHeader.new(links) unless links.empty?
   end
 
   def limit_param(default_limit)
@@ -62,10 +62,11 @@ class Api::BaseController < ApplicationController
   end
 
   def require_user!
-    current_resource_owner
-    set_user_activity
-  rescue ActiveRecord::RecordNotFound
-    render json: { error: 'This method requires an authenticated user' }, status: 422
+    if current_user
+      set_user_activity
+    else
+      render json: { error: 'This method requires an authenticated user' }, status: 422
+    end
   end
 
   def render_empty
diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb
index f8c87dd16..37a163cd3 100644
--- a/app/controllers/api/oembed_controller.rb
+++ b/app/controllers/api/oembed_controller.rb
@@ -4,14 +4,14 @@ class Api::OEmbedController < Api::BaseController
   respond_to :json
 
   def show
-    @stream_entry = find_stream_entry.stream_entry
-    render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
+    @status = status_finder.status
+    render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
   end
 
   private
 
-  def find_stream_entry
-    StreamEntryFinder.new(params[:url])
+  def status_finder
+    StatusFinder.new(params[:url])
   end
 
   def maxwidth_or_default
diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb
index 073808532..da534d960 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Api::V1::Accounts::CredentialsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read }, except: [:update]
   before_action -> { doorkeeper_authorize! :write }, only: [:update]
   before_action :require_user!
 
@@ -10,8 +11,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
   end
 
   def update
-    current_account.update!(account_params)
     @account = current_account
+    UpdateAccountService.new.call(@account, account_params, raise_error: true)
+    ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
     render json: @account, serializer: REST::CredentialAccountSerializer
   end
 
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index d9ae5c089..095f6937b 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -29,6 +29,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   def account_statuses
     default_statuses.tap do |statuses|
       statuses.merge!(only_media_scope) if params[:only_media]
+      statuses.merge!(pinned_scope) if params[:pinned]
       statuses.merge!(no_replies_scope) if params[:exclude_replies]
     end
   end
@@ -53,6 +54,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
     @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
   end
 
+  def pinned_scope
+    @account.pinned_statuses
+  end
+
   def no_replies_scope
     Status.without_replies
   end
diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb
new file mode 100644
index 000000000..3de1009b8
--- /dev/null
+++ b/app/controllers/api/v1/statuses/pins_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::PinsController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :write }
+  before_action :require_user!
+  before_action :set_status
+
+  respond_to :json
+
+  def create
+    StatusPin.create!(account: current_account, status: @status)
+    render json: @status, serializer: REST::StatusSerializer
+  end
+
+  def destroy
+    pin = StatusPin.find_by(account: current_account, status: @status)
+    pin&.destroy!
+    render json: @status, serializer: REST::StatusSerializer
+  end
+
+  private
+
+  def set_status
+    @status = Status.find(params[:status_id])
+  end
+end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 9c7124d0f..544a4ce21 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -29,7 +29,7 @@ class Api::V1::StatusesController < Api::BaseController
   end
 
   def card
-    @card = PreviewCard.find_by(status: @status)
+    @card = @status.preview_cards.first
 
     if @card.nil?
       render_empty
diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb
new file mode 100644
index 000000000..2ed516161
--- /dev/null
+++ b/app/controllers/api/web/embeds_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Api::Web::EmbedsController < Api::BaseController
+  respond_to :json
+
+  before_action :require_user!
+
+  def create
+    status = StatusFinder.new(params[:url]).status
+    render json: status, serializer: OEmbedSerializer, width: 400
+  rescue ActiveRecord::RecordNotFound
+    oembed = OEmbed::Providers.get(params[:url])
+    render json: Oj.dump(oembed.fields)
+  rescue OEmbed::NotFound
+    render json: {}, status: :not_found
+  end
+end
diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb
index d36fc8c93..5b9981aa2 100644
--- a/app/controllers/concerns/account_controller_concern.rb
+++ b/app/controllers/concerns/account_controller_concern.rb
@@ -23,6 +23,7 @@ module AccountControllerConcern
       [
         webfinger_account_link,
         atom_account_url_link,
+        actor_url_link,
       ]
     )
   end
@@ -41,6 +42,13 @@ module AccountControllerConcern
     ]
   end
 
+  def actor_url_link
+    [
+      ActivityPub::TagManager.instance.uri_for(@account),
+      [%w(rel alternate), %w(type application/activity+json)],
+    ]
+  end
+
   def webfinger_account_url
     webfinger_url(resource: @account.to_webfinger_s)
   end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index abe845d93..4211283ed 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -31,7 +31,7 @@ module SignatureVerification
       return
     end
 
-    account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, ''))
+    account = account_from_key_id(signature_params['keyId'])
 
     if account.nil?
       @signed_request_account = nil
@@ -49,6 +49,10 @@ module SignatureVerification
     end
   end
 
+  def request_body
+    @request_body ||= request.raw_post
+  end
+
   private
 
   def build_signed_string(signed_headers)
@@ -57,6 +61,8 @@ module SignatureVerification
     signed_headers.split(' ').map do |signed_header|
       if signed_header == Request::REQUEST_TARGET
         "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
+      elsif signed_header == 'digest'
+        "digest: #{body_digest}"
       else
         "#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
       end
@@ -73,6 +79,10 @@ module SignatureVerification
     (Time.now.utc - time_sent).abs <= 30
   end
 
+  def body_digest
+    "SHA-256=#{Digest::SHA256.base64digest(request_body)}"
+  end
+
   def to_header_name(name)
     name.split(/-/).map(&:capitalize).join('-')
   end
@@ -81,7 +91,16 @@ module SignatureVerification
     signature_params['keyId'].blank? ||
       signature_params['signature'].blank? ||
       signature_params['algorithm'].blank? ||
-      signature_params['algorithm'] != 'rsa-sha256' ||
-      !signature_params['keyId'].start_with?('acct:')
+      signature_params['algorithm'] != 'rsa-sha256'
+  end
+
+  def account_from_key_id(key_id)
+    if key_id.start_with?('acct:')
+      ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, ''))
+    elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
+      account   = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
+      account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id)
+      account
+    end
   end
 end
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 5edb4d67c..0e1949897 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -10,7 +10,7 @@ class FollowerAccountsController < ApplicationController
       format.html
 
       format.json do
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 7cafe5fda..d4593093f 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -10,7 +10,7 @@ class FollowingAccountsController < ApplicationController
       format.html
 
       format.json do
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb
new file mode 100644
index 000000000..504befd1f
--- /dev/null
+++ b/app/controllers/intents_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class IntentsController < ApplicationController
+  def show
+    uri = Addressable::URI.parse(params[:uri])
+
+    if uri.scheme == 'web+mastodon'
+      case uri.host
+      when 'follow'
+        return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, ''))
+      when 'share'
+        return redirect_to share_path(text: uri.query_values['text'])
+      end
+    end
+
+    not_found
+  end
+end
diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb
new file mode 100644
index 000000000..8fc9a0fa9
--- /dev/null
+++ b/app/controllers/settings/applications_controller.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+class Settings::ApplicationsController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_application, only: [:show, :update, :destroy, :regenerate]
+  before_action :prepare_scopes, only: [:create, :update]
+
+  def index
+    @applications = current_user.applications.page(params[:page])
+  end
+
+  def new
+    @application = Doorkeeper::Application.new(
+      redirect_uri: Doorkeeper.configuration.native_redirect_uri,
+      scopes: 'read write follow'
+    )
+  end
+
+  def show; end
+
+  def create
+    @application = current_user.applications.build(application_params)
+
+    if @application.save
+      redirect_to settings_applications_path, notice: I18n.t('applications.created')
+    else
+      render :new
+    end
+  end
+
+  def update
+    if @application.update(application_params)
+      redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg')
+    else
+      render :show
+    end
+  end
+
+  def destroy
+    @application.destroy
+    redirect_to settings_applications_path, notice: I18n.t('applications.destroyed')
+  end
+
+  def regenerate
+    @access_token = current_user.token_for_app(@application)
+    @access_token.destroy
+
+    redirect_to settings_application_path(@application), notice: I18n.t('applications.token_regenerated')
+  end
+
+  private
+
+  def set_application
+    @application = current_user.applications.find(params[:id])
+  end
+
+  def application_params
+    params.require(:doorkeeper_application).permit(
+      :name,
+      :redirect_uri,
+      :scopes,
+      :website
+    )
+  end
+
+  def prepare_scopes
+    scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil)
+    params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array
+  end
+end
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 0367e3593..28f78a4fb 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -14,7 +14,8 @@ class Settings::ProfilesController < ApplicationController
   def show; end
 
   def update
-    if @account.update(account_params)
+    if UpdateAccountService.new.call(@account, account_params)
+      ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
       redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg')
     else
       render :show
diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb
new file mode 100644
index 000000000..994742c3d
--- /dev/null
+++ b/app/controllers/shares_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class SharesController < ApplicationController
+  layout 'modal'
+
+  before_action :authenticate_user!
+  before_action :set_body_classes
+
+  def show
+    serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
+    @initial_state_json   = serializable_resource.to_json
+  end
+
+  private
+
+  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),
+      text: params[:text],
+    }
+  end
+
+  def set_body_classes
+    @body_classes = 'compose-standalone'
+  end
+end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 8e0ce0ec3..65206ea96 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -9,6 +9,7 @@ class StatusesController < ApplicationController
   before_action :set_status
   before_action :set_link_headers
   before_action :check_account_suspension
+  before_action :redirect_to_original, only: [:show]
 
   def show
     respond_to do |format|
@@ -20,13 +21,18 @@ class StatusesController < ApplicationController
       end
 
       format.json do
-        render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
+        render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
 
   def activity
-    render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
+    render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+  end
+
+  def embed
+    response.headers['X-Frame-Options'] = 'ALLOWALL'
+    render 'stream_entries/embed', layout: 'embedded'
   end
 
   private
@@ -36,7 +42,12 @@ class StatusesController < ApplicationController
   end
 
   def set_link_headers
-    response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]])
+    response.headers['Link'] = LinkHeader.new(
+      [
+        [account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
+        [ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]],
+      ]
+    )
   end
 
   def set_status
@@ -53,4 +64,8 @@ class StatusesController < ApplicationController
   def check_account_suspension
     gone if @account.suspended?
   end
+
+  def redirect_to_original
+    redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog?
+  end
 end
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index 3eb91d830..cc579dbc8 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -25,10 +25,7 @@ class StreamEntriesController < ApplicationController
   end
 
   def embed
-    response.headers['X-Frame-Options'] = 'ALLOWALL'
-    return gone if @stream_entry.activity.nil?
-
-    render layout: 'embedded'
+    redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301
   end
 
   private
@@ -38,7 +35,12 @@ class StreamEntriesController < ApplicationController
   end
 
   def set_link_headers
-    response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]])
+    response.headers['Link'] = LinkHeader.new(
+      [
+        [account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
+        [ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]],
+      ]
+    )
   end
 
   def set_stream_entry
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 2cd85e185..3001b2ee3 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -12,7 +12,7 @@ class TagsController < ApplicationController
       format.html
 
       format.json do
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
+        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
   end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 9f50d8bdb..61d4442c1 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -5,6 +5,10 @@ module ApplicationHelper
     current_page?(path) ? 'active' : ''
   end
 
+  def active_link_to(label, path, options = {})
+    link_to label, path, options.merge(class: active_nav_class(path))
+  end
+
   def show_landing_strip?
     !user_signed_in? && !single_user_mode?
   end
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
new file mode 100644
index 000000000..d82a07332
--- /dev/null
+++ b/app/helpers/jsonld_helper.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module JsonLdHelper
+  def equals_or_includes?(haystack, needle)
+    haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
+  end
+
+  def first_of_value(value)
+    value.is_a?(Array) ? value.first : value
+  end
+
+  def value_or_id(value)
+    value.is_a?(String) || value.nil? ? value : value['id']
+  end
+
+  def supported_context?(json)
+    !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
+  end
+
+  def canonicalize(json)
+    graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
+    graph.dump(:normalize)
+  end
+
+  def fetch_resource(uri)
+    response = build_request(uri).perform
+    return if response.code != 200
+    body_to_json(response.to_s)
+  end
+
+  def body_to_json(body)
+    body.is_a?(String) ? Oj.load(body, mode: :strict) : body
+  rescue Oj::ParseError
+    nil
+  end
+
+  def merge_context(context, new_context)
+    if context.is_a?(Array)
+      context << new_context
+    else
+      [context, new_context]
+    end
+  end
+
+  private
+
+  def build_request(uri)
+    request = Request.new(:get, uri)
+    request.add_headers('Accept' => 'application/activity+json, application/ld+json')
+    request
+  end
+end
diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb
index 8126176ba..1fbf77ec3 100644
--- a/app/helpers/routing_helper.rb
+++ b/app/helpers/routing_helper.rb
@@ -12,6 +12,8 @@ module RoutingHelper
   end
 
   def full_asset_url(source, options = {})
-    Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s
+    source = ActionController::Base.helpers.asset_url(source, options) unless Rails.configuration.x.use_s3
+
+    URI.join(root_url, source).to_s
   end
 end
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index 4ef7cffb0..445114985 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 module StreamEntriesHelper
-  EMBEDDED_CONTROLLER = 'stream_entries'
+  EMBEDDED_CONTROLLER = 'statuses'
   EMBEDDED_ACTION = 'embed'
 
   def display_name(account)
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
index 36eec4934..7b5f4bd9c 100644
--- a/app/javascript/mastodon/actions/interactions.js
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -24,6 +24,14 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
 export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
 export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL';
 
+export const PIN_REQUEST = 'PIN_REQUEST';
+export const PIN_SUCCESS = 'PIN_SUCCESS';
+export const PIN_FAIL    = 'PIN_FAIL';
+
+export const UNPIN_REQUEST = 'UNPIN_REQUEST';
+export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
+export const UNPIN_FAIL    = 'UNPIN_FAIL';
+
 export function reblog(status) {
   return function (dispatch, getState) {
     dispatch(reblogRequest(status));
@@ -233,3 +241,73 @@ export function fetchFavouritesFail(id, error) {
     error,
   };
 };
+
+export function pin(status) {
+  return (dispatch, getState) => {
+    dispatch(pinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
+      dispatch(pinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(pinFail(status, error));
+    });
+  };
+};
+
+export function pinRequest(status) {
+  return {
+    type: PIN_REQUEST,
+    status,
+  };
+};
+
+export function pinSuccess(status, response) {
+  return {
+    type: PIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function pinFail(status, error) {
+  return {
+    type: PIN_FAIL,
+    status,
+    error,
+  };
+};
+
+export function unpin (status) {
+  return (dispatch, getState) => {
+    dispatch(unpinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
+      dispatch(unpinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unpinFail(status, error));
+    });
+  };
+};
+
+export function unpinRequest(status) {
+  return {
+    type: UNPIN_REQUEST,
+    status,
+  };
+};
+
+export function unpinSuccess(status, response) {
+  return {
+    type: UNPIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function unpinFail(status, error) {
+  return {
+    type: UNPIN_FAIL,
+    status,
+    error,
+  };
+};
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
new file mode 100644
index 000000000..7802694a3
--- /dev/null
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -0,0 +1,94 @@
+import createStream from '../stream';
+import {
+  updateTimeline,
+  deleteFromTimelines,
+  refreshHomeTimeline,
+  connectTimeline,
+  disconnectTimeline,
+} from './timelines';
+import { updateNotifications, refreshNotifications } from './notifications';
+import { getLocale } from '../locales';
+
+const { messages } = getLocale();
+
+export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
+  return (dispatch, getState) => {
+    const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
+    const accessToken = getState().getIn(['meta', 'access_token']);
+    const locale = getState().getIn(['meta', 'locale']);
+    let polling = null;
+
+    const setupPolling = () => {
+      polling = setInterval(() => {
+        pollingRefresh(dispatch);
+      }, 20000);
+    };
+
+    const clearPolling = () => {
+      if (polling) {
+        clearInterval(polling);
+        polling = null;
+      }
+    };
+
+    const subscription = createStream(streamingAPIBaseURL, accessToken, path, {
+
+      connected () {
+        if (pollingRefresh) {
+          clearPolling();
+        }
+        dispatch(connectTimeline(timelineId));
+      },
+
+      disconnected () {
+        if (pollingRefresh) {
+          setupPolling();
+        }
+        dispatch(disconnectTimeline(timelineId));
+      },
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        case 'notification':
+          dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
+          break;
+        }
+      },
+
+      reconnected () {
+        if (pollingRefresh) {
+          clearPolling();
+          pollingRefresh(dispatch);
+        }
+        dispatch(connectTimeline(timelineId));
+      },
+
+    });
+
+    const disconnect = () => {
+      if (subscription) {
+        subscription.close();
+      }
+      clearPolling();
+    };
+
+    return disconnect;
+  };
+}
+
+function refreshHomeTimelineAndNotification (dispatch) {
+  dispatch(refreshHomeTimeline());
+  dispatch(refreshNotifications());
+}
+
+export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
+export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
+export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
+export const connectPublicStream = () => connectTimelineStream('public', 'public');
+export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index 69cc63d10..6456c12ba 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -26,6 +26,7 @@ export default class Account extends ImmutablePureComponent {
     onBlock: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
+    hidden: PropTypes.bool,
   };
 
   handleFollow = () => {
@@ -41,12 +42,21 @@ export default class Account extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, me, intl } = this.props;
+    const { account, me, intl, hidden } = this.props;
 
     if (!account) {
       return <div />;
     }
 
+    if (hidden) {
+      return (
+        <div>
+          {account.get('display_name')}
+          {account.get('username')}
+        </div>
+      );
+    }
+
     let buttons;
 
     if (account.get('id') !== me && account.get('relationship', null) !== null) {
diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js
index 103fcd495..168f2da9c 100644
--- a/app/javascript/mastodon/components/column.js
+++ b/app/javascript/mastodon/components/column.js
@@ -33,7 +33,7 @@ export default class Column extends React.PureComponent {
   }
 
   componentDidMount () {
-    this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents ? { passive: true } : false);
+    this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
   }
 
   componentWillUnmount () {
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
new file mode 100644
index 000000000..347767818
--- /dev/null
+++ b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -0,0 +1,122 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
+import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
+
+export default class IntersectionObserverArticle extends ImmutablePureComponent {
+
+  static propTypes = {
+    intersectionObserverWrapper: PropTypes.object,
+    id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    children: PropTypes.node,
+  };
+
+  state = {
+    isHidden: false, // set to true in requestIdleCallback to trigger un-render
+  }
+
+  shouldComponentUpdate (nextProps, nextState) {
+    if (!nextState.isIntersecting && nextState.isHidden) {
+      // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
+      // that either "isIntersecting" or "isHidden" matter, and then they're
+      // the only things that matter (and updated ARIA attributes).
+      return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
+    } else if (nextState.isIntersecting && !this.state.isIntersecting) {
+      // If we're going from a non-intersecting state to an intersecting state,
+      // (i.e. offscreen to onscreen), then we definitely need to re-render
+      return true;
+    }
+    // Otherwise, diff based on "updateOnProps" and "updateOnStates"
+    return super.shouldComponentUpdate(nextProps, nextState);
+  }
+
+  componentDidMount () {
+    if (!this.props.intersectionObserverWrapper) {
+      // TODO: enable IntersectionObserver optimization for notification statuses.
+      // These are managed in notifications/index.js rather than status_list.js
+      return;
+    }
+    this.props.intersectionObserverWrapper.observe(
+      this.props.id,
+      this.node,
+      this.handleIntersection
+    );
+
+    this.componentMounted = true;
+  }
+
+  componentWillUnmount () {
+    if (this.props.intersectionObserverWrapper) {
+      this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node);
+    }
+
+    this.componentMounted = false;
+  }
+
+  handleIntersection = (entry) => {
+    if (this.node && this.node.children.length !== 0) {
+      // save the height of the fully-rendered element
+      this.height = getRectFromEntry(entry).height;
+
+      if (this.props.onHeightChange) {
+        this.props.onHeightChange(this.props.status, this.height);
+      }
+    }
+
+    this.setState((prevState) => {
+      if (prevState.isIntersecting && !entry.isIntersecting) {
+        scheduleIdleTask(this.hideIfNotIntersecting);
+      }
+      return {
+        isIntersecting: entry.isIntersecting,
+        isHidden: false,
+      };
+    });
+  }
+
+  hideIfNotIntersecting = () => {
+    if (!this.componentMounted) {
+      return;
+    }
+
+    // When the browser gets a chance, test if we're still not intersecting,
+    // and if so, set our isHidden to true to trigger an unrender. The point of
+    // this is to save DOM nodes and avoid using up too much memory.
+    // See: https://github.com/tootsuite/mastodon/issues/2900
+    this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+  }
+
+  handleRef = (node) => {
+    this.node = node;
+  }
+
+  render () {
+    const { children, id, index, listLength } = this.props;
+    const { isIntersecting, isHidden } = this.state;
+
+    if (!isIntersecting && isHidden) {
+      return (
+        <article
+          ref={this.handleRef}
+          aria-posinset={index}
+          aria-setsize={listLength}
+          style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}
+          data-id={id}
+          tabIndex='0'
+        >
+          {children && React.cloneElement(children, { hidden: true })}
+        </article>
+      );
+    }
+
+    return (
+      <article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'>
+        {children && React.cloneElement(children, { hidden: false })}
+      </article>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
new file mode 100644
index 000000000..1a122dbe5
--- /dev/null
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -0,0 +1,179 @@
+import React, { PureComponent } from 'react';
+import { ScrollContainer } from 'react-router-scroll';
+import PropTypes from 'prop-types';
+import IntersectionObserverArticle from './intersection_observer_article';
+import LoadMore from './load_more';
+import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
+import { throttle } from 'lodash';
+
+export default class ScrollableList extends PureComponent {
+
+  static propTypes = {
+    scrollKey: PropTypes.string.isRequired,
+    onScrollToBottom: PropTypes.func,
+    onScrollToTop: PropTypes.func,
+    onScroll: PropTypes.func,
+    trackScroll: PropTypes.bool,
+    shouldUpdateScroll: PropTypes.func,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    prepend: PropTypes.node,
+    emptyMessage: PropTypes.node,
+    children: PropTypes.node,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  intersectionObserverWrapper = new IntersectionObserverWrapper();
+
+  handleScroll = throttle(() => {
+    if (this.node) {
+      const { scrollTop, scrollHeight, clientHeight } = this.node;
+      const offset = scrollHeight - scrollTop - clientHeight;
+      this._oldScrollPosition = scrollHeight - scrollTop;
+
+      if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+        this.props.onScrollToBottom();
+      } else if (scrollTop < 100 && this.props.onScrollToTop) {
+        this.props.onScrollToTop();
+      } else if (this.props.onScroll) {
+        this.props.onScroll();
+      }
+    }
+  }, 150, {
+    trailing: true,
+  });
+
+  componentDidMount () {
+    this.attachScrollListener();
+    this.attachIntersectionObserver();
+
+    // Handle initial scroll posiiton
+    this.handleScroll();
+  }
+
+  componentDidUpdate (prevProps) {
+    // Reset the scroll position when a new child comes in in order not to
+    // jerk the scrollbar around if you're already scrolled down the page.
+    if (React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this._oldScrollPosition && this.node.scrollTop > 0) {
+      if (this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props)) {
+        const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
+        if (this.node.scrollTop !== newScrollTop) {
+          this.node.scrollTop = newScrollTop;
+        }
+      } else {
+        this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
+      }
+    }
+  }
+
+  componentWillUnmount () {
+    this.detachScrollListener();
+    this.detachIntersectionObserver();
+  }
+
+  attachIntersectionObserver () {
+    this.intersectionObserverWrapper.connect({
+      root: this.node,
+      rootMargin: '300% 0px',
+    });
+  }
+
+  detachIntersectionObserver () {
+    this.intersectionObserverWrapper.disconnect();
+  }
+
+  attachScrollListener () {
+    this.node.addEventListener('scroll', this.handleScroll);
+  }
+
+  detachScrollListener () {
+    this.node.removeEventListener('scroll', this.handleScroll);
+  }
+
+  getFirstChildKey (props) {
+    const { children } = props;
+    const firstChild = Array.isArray(children) ? children[0] : children;
+    return firstChild && firstChild.key;
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  handleLoadMore = (e) => {
+    e.preventDefault();
+    this.props.onScrollToBottom();
+  }
+
+  handleKeyDown = (e) => {
+    if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
+      const article = (() => {
+        switch (e.key) {
+        case 'PageDown':
+          return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
+        case 'PageUp':
+          return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
+        case 'End':
+          return this.node.querySelector('[role="feed"] > article:last-of-type');
+        case 'Home':
+          return this.node.querySelector('[role="feed"] > article:first-of-type');
+        default:
+          return null;
+        }
+      })();
+
+
+      if (article) {
+        e.preventDefault();
+        article.focus();
+        article.scrollIntoView();
+      }
+    }
+  }
+
+  render () {
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+    const childrenCount = React.Children.count(children);
+
+    const loadMore     = <LoadMore visible={!isLoading && childrenCount > 0 && hasMore} onClick={this.handleLoadMore} />;
+    let scrollableArea = null;
+
+    if (isLoading || childrenCount > 0 || !emptyMessage) {
+      scrollableArea = (
+        <div className='scrollable' ref={this.setRef}>
+          <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
+            {prepend}
+
+            {React.Children.map(this.props.children, (child, index) => (
+              <IntersectionObserverArticle key={child.key} id={child.key} index={index} listLength={childrenCount} intersectionObserverWrapper={this.intersectionObserverWrapper}>
+                {child}
+              </IntersectionObserverArticle>
+            ))}
+
+            {loadMore}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          {emptyMessage}
+        </div>
+      );
+    }
+
+    if (trackScroll) {
+      return (
+        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
+          {scrollableArea}
+        </ScrollContainer>
+      );
+    } else {
+      return scrollableArea;
+    }
+  }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 7468957d3..b8617018d 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -12,13 +12,11 @@ import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import { FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
 import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components';
 
 // We use the component (and not the container) since we do not want
 // to use the progress bar to show download progress
 import Bundle from '../features/ui/components/bundle';
-import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
 
 export default class Status extends ImmutablePureComponent {
 
@@ -29,27 +27,25 @@ export default class Status extends ImmutablePureComponent {
   static propTypes = {
     status: ImmutablePropTypes.map,
     account: ImmutablePropTypes.map,
-    wrapped: PropTypes.bool,
     onReply: PropTypes.func,
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onPin: PropTypes.func,
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
     onBlock: PropTypes.func,
+    onEmbed: PropTypes.func,
     onHeightChange: PropTypes.func,
     me: PropTypes.number,
     boostModal: PropTypes.bool,
     autoPlayGif: PropTypes.bool,
     muted: PropTypes.bool,
-    intersectionObserverWrapper: PropTypes.object,
-    index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
-    listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    hidden: PropTypes.bool,
   };
 
   state = {
     isExpanded: false,
-    isHidden: false, // set to true in requestIdleCallback to trigger un-render
   }
 
   // Avoid checking props that are functions (and whose equality will always
@@ -57,91 +53,15 @@ export default class Status extends ImmutablePureComponent {
   updateOnProps = [
     'status',
     'account',
-    'wrapped',
     'me',
     'boostModal',
     'autoPlayGif',
     'muted',
-    'listLength',
+    'hidden',
   ]
 
   updateOnStates = ['isExpanded']
 
-  shouldComponentUpdate (nextProps, nextState) {
-    if (!nextState.isIntersecting && nextState.isHidden) {
-      // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
-      // that either "isIntersecting" or "isHidden" matter, and then they're
-      // the only things that matter (and updated ARIA attributes).
-      return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
-    } else if (nextState.isIntersecting && !this.state.isIntersecting) {
-      // If we're going from a non-intersecting state to an intersecting state,
-      // (i.e. offscreen to onscreen), then we definitely need to re-render
-      return true;
-    }
-    // Otherwise, diff based on "updateOnProps" and "updateOnStates"
-    return super.shouldComponentUpdate(nextProps, nextState);
-  }
-
-  componentDidMount () {
-    if (!this.props.intersectionObserverWrapper) {
-      // TODO: enable IntersectionObserver optimization for notification statuses.
-      // These are managed in notifications/index.js rather than status_list.js
-      return;
-    }
-    this.props.intersectionObserverWrapper.observe(
-      this.props.id,
-      this.node,
-      this.handleIntersection
-    );
-
-    this.componentMounted = true;
-  }
-
-  componentWillUnmount () {
-    if (this.props.intersectionObserverWrapper) {
-      this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node);
-    }
-
-    this.componentMounted = false;
-  }
-
-  handleIntersection = (entry) => {
-    if (this.node && this.node.children.length !== 0) {
-      // save the height of the fully-rendered element
-      this.height = getRectFromEntry(entry).height;
-
-      if (this.props.onHeightChange) {
-        this.props.onHeightChange(this.props.status, this.height);
-      }
-    }
-
-    this.setState((prevState) => {
-      if (prevState.isIntersecting && !entry.isIntersecting) {
-        scheduleIdleTask(this.hideIfNotIntersecting);
-      }
-      return {
-        isIntersecting: entry.isIntersecting,
-        isHidden: false,
-      };
-    });
-  }
-
-  hideIfNotIntersecting = () => {
-    if (!this.componentMounted) {
-      return;
-    }
-
-    // When the browser gets a chance, test if we're still not intersecting,
-    // and if so, set our isHidden to true to trigger an unrender. The point of
-    // this is to save DOM nodes and avoid using up too much memory.
-    // See: https://github.com/tootsuite/mastodon/issues/2900
-    this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
-  }
-
-  handleRef = (node) => {
-    this.node = node;
-  }
-
   handleClick = () => {
     if (!this.context.router) {
       return;
@@ -175,25 +95,19 @@ export default class Status extends ImmutablePureComponent {
     let media = null;
     let statusAvatar;
 
-    // Exclude intersectionObserverWrapper from `other` variable
-    // because intersection is managed in here.
-    const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props;
-    const { isExpanded, isIntersecting, isHidden } = this.state;
+    const { status, account, hidden, ...other } = this.props;
+    const { isExpanded } = this.state;
 
     if (status === null) {
       return null;
     }
 
-    const hasIntersectionObserverWrapper = !!this.props.intersectionObserverWrapper;
-    const isHiddenForSure = isIntersecting === false && isHidden;
-    const visibilityUnknownButHeightIsCached = isIntersecting === undefined && status.has('height');
-
-    if (hasIntersectionObserverWrapper && (isHiddenForSure || visibilityUnknownButHeightIsCached)) {
+    if (hidden) {
       return (
-        <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height || status.get('height')}px`, opacity: 0, overflow: 'hidden' }}>
+        <div>
           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
           {status.get('content')}
-        </article>
+        </div>
       );
     }
 
@@ -201,14 +115,14 @@ export default class Status extends ImmutablePureComponent {
       const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
 
       return (
-        <article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0'>
+        <div className='status__wrapper' data-id={status.get('id')} >
           <div className='status__prepend'>
             <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
             <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
           </div>
 
-          <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} />
-        </article>
+          <Status {...other} status={status.get('reblog')} account={status.get('account')} />
+        </div>
       );
     }
 
@@ -237,7 +151,7 @@ export default class Status extends ImmutablePureComponent {
     }
 
     return (
-      <article aria-posinset={index} aria-setsize={listLength} className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} tabIndex={wrapped ? null : '0'}  ref={this.handleRef}>
+      <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}>
         <div className='status__info'>
           <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
 
@@ -255,7 +169,7 @@ export default class Status extends ImmutablePureComponent {
         {media}
 
         <StatusActionBar {...this.props} />
-      </article>
+      </div>
     );
   }
 
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 81c2a4e23..de99f8850 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -24,6 +24,9 @@ const messages = defineMessages({
   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
   muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
   unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+  embed: { id: 'status.embed', defaultMessage: 'Embed' },
 });
 
 @injectIntl
@@ -43,7 +46,9 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onMute: PropTypes.func,
     onBlock: PropTypes.func,
     onReport: PropTypes.func,
+    onEmbed: PropTypes.func,
     onMuteConversation: PropTypes.func,
+    onPin: PropTypes.func,
     me: PropTypes.number,
     withDismiss: PropTypes.bool,
     intl: PropTypes.object.isRequired,
@@ -80,6 +85,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.props.onDelete(this.props.status);
   }
 
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
   handleMentionClick = () => {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
@@ -96,6 +105,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
   }
 
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  }
+
   handleReport = () => {
     this.props.onReport(this.props.status);
   }
@@ -106,9 +119,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
 
   render () {
     const { status, me, intl, withDismiss } = this.props;
-    const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
+
     const mutingConversation = status.get('muted');
-    const anonymousAccess = !me;
+    const anonymousAccess    = !me;
+    const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
 
     let menu = [];
     let reblogIcon = 'retweet';
@@ -116,6 +130,11 @@ export default class StatusActionBar extends ImmutablePureComponent {
     let replyTitle;
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+    }
+
     menu.push(null);
 
     if (withDismiss) {
@@ -124,6 +143,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     }
 
     if (status.getIn(['account', 'id']) === me) {
+      if (publicStatus) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
@@ -154,7 +177,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
     return (
       <div className='status__action-bar'>
         <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
-        <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
+        <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
         <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
         {shareButton}
 
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 271cf33b7..6bd357754 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -1,12 +1,10 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { ScrollContainer } from 'react-router-scroll';
 import PropTypes from 'prop-types';
 import StatusContainer from '../../glitch/components/status/container';
 import LoadMore from './load_more';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
-import { throttle } from 'lodash';
+import ScrollableList from './scrollable_list';
 
 export default class StatusList extends ImmutablePureComponent {
 
@@ -28,145 +26,21 @@ export default class StatusList extends ImmutablePureComponent {
     trackScroll: true,
   };
 
-  intersectionObserverWrapper = new IntersectionObserverWrapper();
-
-  handleScroll = throttle(() => {
-    if (this.node) {
-      const { scrollTop, scrollHeight, clientHeight } = this.node;
-      const offset = scrollHeight - scrollTop - clientHeight;
-      this._oldScrollPosition = scrollHeight - scrollTop;
-
-      if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
-        this.props.onScrollToBottom();
-      } else if (scrollTop < 100 && this.props.onScrollToTop) {
-        this.props.onScrollToTop();
-      } else if (this.props.onScroll) {
-        this.props.onScroll();
-      }
-    }
-  }, 150, {
-    trailing: true,
-  });
-
-  componentDidMount () {
-    this.attachScrollListener();
-    this.attachIntersectionObserver();
-
-    // Handle initial scroll posiiton
-    this.handleScroll();
-  }
-
-  componentDidUpdate (prevProps) {
-    // Reset the scroll position when a new toot comes in in order not to
-    // jerk the scrollbar around if you're already scrolled down the page.
-    if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) {
-      if (prevProps.statusIds.first() !== this.props.statusIds.first()) {
-        let newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
-        if (this.node.scrollTop !== newScrollTop) {
-          this.node.scrollTop = newScrollTop;
-        }
-      } else {
-        this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
-      }
-    }
-  }
-
-  componentWillUnmount () {
-    this.detachScrollListener();
-    this.detachIntersectionObserver();
-  }
-
-  attachIntersectionObserver () {
-    this.intersectionObserverWrapper.connect({
-      root: this.node,
-      rootMargin: '300% 0px',
-    });
-  }
-
-  detachIntersectionObserver () {
-    this.intersectionObserverWrapper.disconnect();
-  }
-
-  attachScrollListener () {
-    this.node.addEventListener('scroll', this.handleScroll);
-  }
-
-  detachScrollListener () {
-    this.node.removeEventListener('scroll', this.handleScroll);
-  }
-
-  setRef = (c) => {
-    this.node = c;
-  }
-
-  handleLoadMore = (e) => {
-    e.preventDefault();
-    this.props.onScrollToBottom();
-  }
-
-  handleKeyDown = (e) => {
-    if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
-      const article = (() => {
-        switch (e.key) {
-        case 'PageDown':
-          return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
-        case 'PageUp':
-          return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
-        case 'End':
-          return this.node.querySelector('[role="feed"] > article:last-of-type');
-        case 'Home':
-          return this.node.querySelector('[role="feed"] > article:first-of-type');
-        default:
-          return null;
-        }
-      })();
-
-
-      if (article) {
-        e.preventDefault();
-        article.focus();
-        article.scrollIntoView();
-      }
-    }
-  }
-
   render () {
-    const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
-
-    const loadMore     = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />;
-    let scrollableArea = null;
-
-    if (isLoading || statusIds.size > 0 || !emptyMessage) {
-      scrollableArea = (
-        <div className='scrollable' ref={this.setRef}>
-          <div role='feed' className='status-list' onKeyDown={this.handleKeyDown}>
-            {prepend}
-
-            {statusIds.map((statusId, index) => {
-              return <StatusContainer key={statusId} id={statusId} index={index} listLength={statusIds.size} intersectionObserverWrapper={this.intersectionObserverWrapper} />;
-            })}
-
-            {loadMore}
-          </div>
-        </div>
-      );
-    } else {
-      scrollableArea = (
-        <div className='empty-column-indicator' ref={this.setRef}>
-          {emptyMessage}
-        </div>
-      );
-    }
-
-    if (trackScroll) {
-      return (
-        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
-          {scrollableArea}
-        </ScrollContainer>
-      );
-    } else {
-      return scrollableArea;
-    }
+    const { statusIds, ...other } = this.props;
+    const { isLoading } = other;
+
+    const scrollableContent = (isLoading || statusIds.size > 0) ? (
+      statusIds.map((statusId) => (
+        <StatusContainer key={statusId} id={statusId} />
+      ))
+    ) : null;
+
+    return (
+      <ScrollableList {...other}>
+        {scrollableContent}
+      </ScrollableList>
+    );
   }
 
 }
diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js
index ca1efd0e5..7c77cb764 100644
--- a/app/javascript/mastodon/containers/account_container.js
+++ b/app/javascript/mastodon/containers/account_container.js
@@ -32,7 +32,7 @@ const makeMapStateToProps = () => {
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onFollow (account) {
-    if (account.getIn(['relationship', 'following'])) {
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
       if (this.unfollowModal) {
         dispatch(openModal('CONFIRM', {
           message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
diff --git a/app/javascript/mastodon/containers/compose_container.js b/app/javascript/mastodon/containers/compose_container.js
new file mode 100644
index 000000000..db452d03a
--- /dev/null
+++ b/app/javascript/mastodon/containers/compose_container.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from '../store/configureStore';
+import { hydrateStore } from '../actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import Compose from '../features/standalone/compose';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+const initialStateContainer = document.getElementById('initial-state');
+
+if (initialStateContainer !== null) {
+  const initialState = JSON.parse(initialStateContainer.textContent);
+  store.dispatch(hydrateStore(initialState));
+}
+
+export default class TimelineContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+  };
+
+  render () {
+    const { locale } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Provider store={store}>
+          <Compose />
+        </Provider>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index 8287375c4..db2a5f269 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -2,21 +2,13 @@ import React from 'react';
 import { Provider } from 'react-redux';
 import PropTypes from 'prop-types';
 import configureStore from '../store/configureStore';
-import {
-  updateTimeline,
-  deleteFromTimelines,
-  refreshHomeTimeline,
-  connectTimeline,
-  disconnectTimeline,
-} from '../actions/timelines';
 import { showOnboardingOnce } from '../actions/onboarding';
-import { updateNotifications, refreshNotifications } from '../actions/notifications';
 import BrowserRouter from 'react-router-dom/BrowserRouter';
 import Route from 'react-router-dom/Route';
 import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext';
 import UI from '../features/ui';
 import { hydrateStore } from '../actions/store';
-import createStream from '../stream';
+import { connectUserStream } from '../actions/streaming';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from '../locales';
 const { localeData, messages } = getLocale();
@@ -39,74 +31,28 @@ export default class Mastodon extends React.PureComponent {
   };
 
   componentDidMount() {
-    const { locale }  = this.props;
-    const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']);
-    const accessToken = store.getState().getIn(['meta', 'access_token']);
-
-    const setupPolling = () => {
-      this.polling = setInterval(() => {
-        store.dispatch(refreshHomeTimeline());
-        store.dispatch(refreshNotifications());
-      }, 20000);
-    };
-
-    const clearPolling = () => {
-      clearInterval(this.polling);
-      this.polling = undefined;
-    };
-
-    this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', {
-
-      connected () {
-        clearPolling();
-        store.dispatch(connectTimeline('home'));
-      },
-
-      disconnected () {
-        setupPolling();
-        store.dispatch(disconnectTimeline('home'));
-      },
-
-      received (data) {
-        switch(data.event) {
-        case 'update':
-          store.dispatch(updateTimeline('home', JSON.parse(data.payload)));
-          break;
-        case 'delete':
-          store.dispatch(deleteFromTimelines(data.payload));
-          break;
-        case 'notification':
-          store.dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
-          break;
-        }
-      },
-
-      reconnected () {
-        clearPolling();
-        store.dispatch(connectTimeline('home'));
-        store.dispatch(refreshHomeTimeline());
-        store.dispatch(refreshNotifications());
-      },
-
-    });
+    this.disconnect = store.dispatch(connectUserStream());
 
     // Desktop notifications
+    // Ask after 1 minute
     if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
-      Notification.requestPermission();
+      window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
+    }
+
+    // Protocol handler
+    // Ask after 5 minutes
+    if (typeof navigator.registerProtocolHandler !== 'undefined') {
+      const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
+      window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
     }
 
     store.dispatch(showOnboardingOnce());
   }
 
   componentWillUnmount () {
-    if (typeof this.subscription !== 'undefined') {
-      this.subscription.close();
-      this.subscription = null;
-    }
-
-    if (typeof this.polling !== 'undefined') {
-      clearInterval(this.polling);
-      this.polling = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index d71584267..9dff79b72 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -14,6 +14,8 @@ import {
   favourite,
   unreblog,
   unfavourite,
+  pin,
+  unpin,
 } from '../actions/interactions';
 import {
   blockAccount,
@@ -75,6 +77,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', { url: status.get('url') }));
+  },
+
   onDelete (status) {
     if (!this.deleteModal) {
       dispatch(deleteStatus(status.get('id')));
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 320e669a2..7ab492225 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -14,7 +14,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
 });
 
 const makeMapStateToProps = () => {
@@ -105,7 +105,7 @@ export default class Header extends ImmutablePureComponent {
       if (account.getIn(['relationship', 'requested'])) {
         actionBtn = (
           <div className='account--action-button'>
-            <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
+            <IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />
           </div>
         );
       } else if (!account.getIn(['relationship', 'blocking'])) {
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index baa81bbc2..dcee78b3e 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -38,7 +38,7 @@ const makeMapStateToProps = () => {
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onFollow (account) {
-    if (account.getIn(['relationship', 'following'])) {
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
       if (this.unfollowModal) {
         dispatch(openModal('CONFIRM', {
           message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
index 0e2300f8c..596a89412 100644
--- a/app/javascript/mastodon/features/community_timeline/index.js
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header';
 import {
   refreshCommunityTimeline,
   expandCommunityTimeline,
-  updateTimeline,
-  deleteFromTimelines,
-  connectTimeline,
-  disconnectTimeline,
 } from '../../actions/timelines';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
-import createStream from '../../stream';
+import { connectCommunityStream } from '../../actions/streaming';
 
 const messages = defineMessages({
   title: { id: 'column.community', defaultMessage: 'Local timeline' },
@@ -23,8 +19,6 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
-  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
-  accessToken: state.getIn(['meta', 'access_token']),
 });
 
 @connect(mapStateToProps)
@@ -35,8 +29,6 @@ export default class CommunityTimeline extends React.PureComponent {
     dispatch: PropTypes.func.isRequired,
     columnId: PropTypes.string,
     intl: PropTypes.object.isRequired,
-    streamingAPIBaseURL: PropTypes.string.isRequired,
-    accessToken: PropTypes.string.isRequired,
     hasUnread: PropTypes.bool,
     multiColumn: PropTypes.bool,
   };
@@ -61,46 +53,16 @@ export default class CommunityTimeline extends React.PureComponent {
   }
 
   componentDidMount () {
-    const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
+    const { dispatch } = this.props;
 
     dispatch(refreshCommunityTimeline());
-
-    if (typeof this._subscription !== 'undefined') {
-      return;
-    }
-
-    this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', {
-
-      connected () {
-        dispatch(connectTimeline('community'));
-      },
-
-      reconnected () {
-        dispatch(connectTimeline('community'));
-      },
-
-      disconnected () {
-        dispatch(disconnectTimeline('community'));
-      },
-
-      received (data) {
-        switch(data.event) {
-        case 'update':
-          dispatch(updateTimeline('community', JSON.parse(data.payload)));
-          break;
-        case 'delete':
-          dispatch(deleteFromTimelines(data.payload));
-          break;
-        }
-      },
-
-    });
+    this.disconnect = dispatch(connectCommunityStream());
   }
 
   componentWillUnmount () {
-    if (typeof this._subscription !== 'undefined') {
-      this._subscription.close();
-      this._subscription = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
index d9ad9bc1f..82b16b369 100644
--- a/app/javascript/mastodon/features/favourited_statuses/index.js
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -16,6 +16,7 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+  hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
 });
 
 @connect(mapStateToProps)
@@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent {
     intl: PropTypes.object.isRequired,
     columnId: PropTypes.string,
     multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
   };
 
   componentWillMount () {
@@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, statusIds, columnId, multiColumn } = this.props;
+    const { intl, statusIds, columnId, multiColumn, hasMore } = this.props;
     const pinned = !!columnId;
 
     return (
@@ -81,6 +83,7 @@ export default class Favourites extends ImmutablePureComponent {
           trackScroll={!pinned}
           statusIds={statusIds}
           scrollKey={`favourited_statuses-${columnId}`}
+          hasMore={hasMore}
           onScrollToBottom={this.handleScrollToBottom}
         />
       </Column>
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
index b17e8e1a5..5fe21ce90 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -7,17 +7,13 @@ import ColumnHeader from '../../components/column_header';
 import {
   refreshHashtagTimeline,
   expandHashtagTimeline,
-  updateTimeline,
-  deleteFromTimelines,
 } from '../../actions/timelines';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { FormattedMessage } from 'react-intl';
-import createStream from '../../stream';
+import { connectHashtagStream } from '../../actions/streaming';
 
-const mapStateToProps = state => ({
-  hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
-  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
-  accessToken: state.getIn(['meta', 'access_token']),
+const mapStateToProps = (state, props) => ({
+  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
 });
 
 @connect(mapStateToProps)
@@ -27,8 +23,6 @@ export default class HashtagTimeline extends React.PureComponent {
     params: PropTypes.object.isRequired,
     columnId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
-    streamingAPIBaseURL: PropTypes.string.isRequired,
-    accessToken: PropTypes.string.isRequired,
     hasUnread: PropTypes.bool,
     multiColumn: PropTypes.bool,
   };
@@ -53,28 +47,13 @@ export default class HashtagTimeline extends React.PureComponent {
   }
 
   _subscribe (dispatch, id) {
-    const { streamingAPIBaseURL, accessToken } = this.props;
-
-    this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, {
-
-      received (data) {
-        switch(data.event) {
-        case 'update':
-          dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload)));
-          break;
-        case 'delete':
-          dispatch(deleteFromTimelines(data.payload));
-          break;
-        }
-      },
-
-    });
+    this.disconnect = dispatch(connectHashtagStream(id));
   }
 
   _unsubscribe () {
-    if (typeof this.subscription !== 'undefined') {
-      this.subscription.close();
-      this.subscription = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 7d521e4b6..b52c3c934 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -2,6 +2,7 @@
 //  SEE INSTEAD : glitch/components/notification
 
 import React from 'react';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import StatusContainer from '../../../containers/status_container';
 import AccountContainer from '../../../containers/account_container';
@@ -13,6 +14,7 @@ export default class Notification extends ImmutablePureComponent {
 
   static propTypes = {
     notification: ImmutablePropTypes.map.isRequired,
+    hidden: PropTypes.bool,
   };
 
   renderFollow (account, link) {
@@ -26,13 +28,13 @@ export default class Notification extends ImmutablePureComponent {
           <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
         </div>
 
-        <AccountContainer id={account.get('id')} withNote={false} />
+        <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
       </div>
     );
   }
 
   renderMention (notification) {
-    return <StatusContainer id={notification.get('status')} withDismiss />;
+    return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
   }
 
   renderFavourite (notification, link) {
@@ -45,7 +47,7 @@ export default class Notification extends ImmutablePureComponent {
           <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
         </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss />
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
       </div>
     );
   }
@@ -60,7 +62,7 @@ export default class Notification extends ImmutablePureComponent {
           <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
         </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss />
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 0d86d41ce..b644718e3 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -16,8 +16,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
 import { createSelector } from 'reselect';
 import { List as ImmutableList } from 'immutable';
-import LoadMore from '../../components/load_more';
 import { debounce } from 'lodash';
+import ScrollableList from '../../components/scrollable_list';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -68,40 +68,18 @@ export default class Notifications extends React.PureComponent {
     trackScroll: true,
   };
 
-  dispatchExpandNotifications = debounce(() => {
+  handleScrollToBottom = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(false));
     this.props.dispatch(expandNotifications());
   }, 300, { leading: true });
 
-  dispatchScrollToTop = debounce((top) => {
-    this.props.dispatch(scrollTopNotifications(top));
+  handleScrollToTop = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(true));
   }, 100);
 
-  handleScroll = (e) => {
-    const { scrollTop, scrollHeight, clientHeight } = e.target;
-    const offset = scrollHeight - scrollTop - clientHeight;
-    this._oldScrollPosition = scrollHeight - scrollTop;
-
-    if (250 > offset && this.props.hasMore && !this.props.isLoading) {
-      this.dispatchExpandNotifications();
-    }
-
-    if (scrollTop < 100) {
-      this.dispatchScrollToTop(true);
-    } else {
-      this.dispatchScrollToTop(false);
-    }
-  }
-
-  componentDidUpdate (prevProps) {
-    if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) {
-      this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
-    }
-  }
-
-  handleLoadMore = (e) => {
-    e.preventDefault();
-    this.dispatchExpandNotifications();
-  }
+  handleScroll = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(false));
+  }, 100);
 
   handlePin = () => {
     const { columnId, dispatch } = this.props;
@@ -122,10 +100,6 @@ export default class Notifications extends React.PureComponent {
     this.column.scrollTop();
   }
 
-  setRef = (c) => {
-    this.node = c;
-  }
-
   setColumnRef = c => {
     this.column = c;
   }
@@ -133,52 +107,34 @@ export default class Notifications extends React.PureComponent {
   render () {
     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
     const pinned = !!columnId;
+    const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
 
-    let loadMore       = '';
-    let scrollableArea = '';
-    let unread         = '';
-    let scrollContainer = '';
-
-    if (!isLoading && hasMore) {
-      loadMore = <LoadMore onClick={this.handleLoadMore} />;
-    }
-
-    if (isUnread) {
-      unread = <div className='notifications__unread-indicator' />;
-    }
+    let scrollableContent = null;
 
-    if (isLoading && this.scrollableArea) {
-      scrollableArea = this.scrollableArea;
+    if (isLoading && this.scrollableContent) {
+      scrollableContent = this.scrollableContent;
     } else if (notifications.size > 0 || hasMore) {
-      scrollableArea = (
-        <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
-          {unread}
-
-          <div>
-            {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
-            {loadMore}
-          </div>
-        </div>
-      );
+      scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
     } else {
-      scrollableArea = (
-        <div className='empty-column-indicator' ref={this.setRef}>
-          <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
-        </div>
-      );
+      scrollableContent = null;
     }
 
-    if (pinned) {
-      scrollContainer = scrollableArea;
-    } else {
-      scrollContainer = (
-        <ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}>
-          {scrollableArea}
-        </ScrollContainer>
-      );
-    }
-
-    this.scrollableArea = scrollableArea;
+    this.scrollableContent = scrollableContent;
+
+    const scrollContainer = (
+      <ScrollableList
+        scrollKey={`notifications-${columnId}`}
+        isLoading={isLoading}
+        hasMore={hasMore}
+        emptyMessage={emptyMessage}
+        onScrollToBottom={this.handleScrollToBottom}
+        onScrollToTop={this.handleScrollToTop}
+        onScroll={this.handleScroll}
+        shouldUpdateScroll={shouldUpdateScroll}
+      >
+        {scrollableContent}
+      </ScrollableList>
+    );
 
     return (
       <Column
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
index c6cad02d6..193489c63 100644
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header';
 import {
   refreshPublicTimeline,
   expandPublicTimeline,
-  updateTimeline,
-  deleteFromTimelines,
-  connectTimeline,
-  disconnectTimeline,
 } from '../../actions/timelines';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
-import createStream from '../../stream';
+import { connectPublicStream } from '../../actions/streaming';
 
 const messages = defineMessages({
   title: { id: 'column.public', defaultMessage: 'Federated timeline' },
@@ -23,8 +19,6 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
-  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
-  accessToken: state.getIn(['meta', 'access_token']),
 });
 
 @connect(mapStateToProps)
@@ -36,8 +30,6 @@ export default class PublicTimeline extends React.PureComponent {
     intl: PropTypes.object.isRequired,
     columnId: PropTypes.string,
     multiColumn: PropTypes.bool,
-    streamingAPIBaseURL: PropTypes.string.isRequired,
-    accessToken: PropTypes.string.isRequired,
     hasUnread: PropTypes.bool,
   };
 
@@ -61,46 +53,16 @@ export default class PublicTimeline extends React.PureComponent {
   }
 
   componentDidMount () {
-    const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
+    const { dispatch } = this.props;
 
     dispatch(refreshPublicTimeline());
-
-    if (typeof this._subscription !== 'undefined') {
-      return;
-    }
-
-    this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', {
-
-      connected () {
-        dispatch(connectTimeline('public'));
-      },
-
-      reconnected () {
-        dispatch(connectTimeline('public'));
-      },
-
-      disconnected () {
-        dispatch(disconnectTimeline('public'));
-      },
-
-      received (data) {
-        switch(data.event) {
-        case 'update':
-          dispatch(updateTimeline('public', JSON.parse(data.payload)));
-          break;
-        case 'delete':
-          dispatch(deleteFromTimelines(data.payload));
-          break;
-        }
-      },
-
-    });
+    this.disconnect = dispatch(connectPublicStream());
   }
 
   componentWillUnmount () {
-    if (typeof this._subscription !== 'undefined') {
-      this._subscription.close();
-      this._subscription = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js
new file mode 100644
index 000000000..96d07fefb
--- /dev/null
+++ b/app/javascript/mastodon/features/standalone/compose/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import ComposeFormContainer from '../../compose/containers/compose_form_container';
+import NotificationsContainer from '../../ui/containers/notifications_container';
+import LoadingBarContainer from '../../ui/containers/loading_bar_container';
+
+export default class Compose extends React.PureComponent {
+
+  render () {
+    return (
+      <div>
+        <ComposeFormContainer />
+        <NotificationsContainer />
+        <LoadingBarContainer className='loading-bar' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index a2885adda..4be013be7 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -14,6 +14,9 @@ const messages = defineMessages({
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
   share: { id: 'status.share', defaultMessage: 'Share' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+  embed: { id: 'status.embed', defaultMessage: 'Embed' },
 });
 
 @injectIntl
@@ -31,6 +34,8 @@ export default class ActionBar extends React.PureComponent {
     onDelete: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onReport: PropTypes.func,
+    onPin: PropTypes.func,
+    onEmbed: PropTypes.func,
     me: PropTypes.number.isRequired,
     intl: PropTypes.object.isRequired,
   };
@@ -59,6 +64,10 @@ export default class ActionBar extends React.PureComponent {
     this.props.onReport(this.props.status);
   }
 
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
   handleShare = () => {
     navigator.share({
       text: this.props.status.get('search_index'),
@@ -66,12 +75,26 @@ export default class ActionBar extends React.PureComponent {
     });
   }
 
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  }
+
   render () {
     const { status, me, intl } = this.props;
 
+    const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+
     let menu = [];
 
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+    }
+
     if (me === status.getIn(['account', 'id'])) {
+      if (publicStatus) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index bfb40468b..6b13e15cc 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import punycode from 'punycode';
+import classnames from 'classnames';
 
 const IDNA_PREFIX = 'xn--';
 
@@ -32,7 +33,7 @@ export default class Card extends React.PureComponent {
     if (card.get('image')) {
       image = (
         <div className='status-card__image'>
-          <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' />
+          <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} />
         </div>
       );
     }
@@ -41,8 +42,12 @@ export default class Card extends React.PureComponent {
       provider = decodeIDNA(getHostname(card.get('url')));
     }
 
+    const className = classnames('status-card', {
+      'horizontal': card.get('width') > card.get('height'),
+    });
+
     return (
-      <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'>
+      <a href={card.get('url')} className={className} target='_blank' rel='noopener'>
         {image}
 
         <div className='status-card__content'>
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index d774dfdfe..03010cf0a 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -12,6 +12,8 @@ import {
   unfavourite,
   reblog,
   unreblog,
+  pin,
+  unpin,
 } from '../../actions/interactions';
 import {
   replyCompose,
@@ -89,6 +91,14 @@ export default class Status extends ImmutablePureComponent {
     }
   }
 
+  handlePin = (status) => {
+    if (status.get('pinned')) {
+      this.props.dispatch(unpin(status));
+    } else {
+      this.props.dispatch(pin(status));
+    }
+  }
+
   handleReplyClick = (status) => {
     this.props.dispatch(replyCompose(status, this.context.router.history));
   }
@@ -139,6 +149,10 @@ export default class Status extends ImmutablePureComponent {
     this.props.dispatch(initReport(status.get('account'), status));
   }
 
+  handleEmbed = (status) => {
+    this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+  }
+
   renderChildren (list) {
     return list.map(id => <StatusContainer key={id} id={id} />);
   }
@@ -190,6 +204,8 @@ export default class Status extends ImmutablePureComponent {
               onDelete={this.handleDeleteClick}
               onMention={this.handleMentionClick}
               onReport={this.handleReport}
+              onPin={this.handlePin}
+              onEmbed={this.handleEmbed}
             />
 
             {descendants}
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
index 9031c16fc..15538ea38 100644
--- a/app/javascript/mastodon/features/ui/components/column.js
+++ b/app/javascript/mastodon/features/ui/components/column.js
@@ -25,6 +25,17 @@ export default class Column extends React.PureComponent {
     this._interruptScrollAnimation = scrollTop(scrollable);
   }
 
+  scrollTop () {
+    const scrollable = this.node.querySelector('.scrollable');
+
+    if (!scrollable) {
+      return;
+    }
+
+    this._interruptScrollAnimation = scrollTop(scrollable);
+  }
+
+
   handleScroll = debounce(() => {
     if (typeof this._interruptScrollAnimation !== 'undefined') {
       this._interruptScrollAnimation();
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 47d5a2e20..7d84bece7 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -12,6 +12,7 @@ import ColumnLoading from './column_loading';
 import BundleColumnError from './bundle_column_error';
 import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
 
+import detectPassiveEvents from 'detect-passive-events';
 import { scrollRight } from '../../../scroll';
 
 const componentMap = {
@@ -24,7 +25,7 @@ const componentMap = {
   'FAVOURITES': FavouritedStatuses,
 };
 
-@injectIntl
+@component => injectIntl(component, { withRef: true })
 export default class ColumnsArea extends ImmutablePureComponent {
 
   static contextTypes = {
@@ -47,16 +48,36 @@ export default class ColumnsArea extends ImmutablePureComponent {
   }
 
   componentDidMount() {
+    if (!this.props.singleColumn) {
+      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+    }
     this.lastIndex = getIndex(this.context.router.history.location.pathname);
     this.setState({ shouldAnimate: true });
   }
 
+  componentWillUpdate(nextProps) {
+    if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
+      this.node.removeEventListener('wheel', this.handleWheel);
+    }
+  }
+
   componentDidUpdate(prevProps) {
+    if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
+      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+    }
     this.lastIndex = getIndex(this.context.router.history.location.pathname);
     this.setState({ shouldAnimate: true });
+  }
 
-    if (this.props.children !== prevProps.children && !this.props.singleColumn) {
-      scrollRight(this.node);
+  componentWillUnmount () {
+    if (!this.props.singleColumn) {
+      this.node.removeEventListener('wheel', this.handleWheel);
+    }
+  }
+
+  handleChildrenContentChange() {
+    if (!this.props.singleColumn) {
+      scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
     }
   }
 
@@ -80,6 +101,14 @@ export default class ColumnsArea extends ImmutablePureComponent {
     }
   }
 
+  handleWheel = () => {
+    if (typeof this._interruptScrollAnimation !== 'function') {
+      return;
+    }
+
+    this._interruptScrollAnimation();
+  }
+
   setRef = (node) => {
     this.node = node;
   }
diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.js b/app/javascript/mastodon/features/ui/components/embed_modal.js
new file mode 100644
index 000000000..992aed8a3
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/embed_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import axios from 'axios';
+
+@injectIntl
+export default class EmbedModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    url: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  }
+
+  state = {
+    loading: false,
+    oembed: null,
+  };
+
+  componentDidMount () {
+    const { url } = this.props;
+
+    this.setState({ loading: true });
+
+    axios.post('/api/web/embed', { url }).then(res => {
+      this.setState({ loading: false, oembed: res.data });
+
+      const iframeDocument = this.iframe.contentWindow.document;
+
+      iframeDocument.open();
+      iframeDocument.write(res.data.html);
+      iframeDocument.close();
+
+      iframeDocument.body.style.margin = 0;
+      this.iframe.height = iframeDocument.body.scrollHeight + 'px';
+    });
+  }
+
+  setIframeRef = c =>  {
+    this.iframe = c;
+  }
+
+  handleTextareaClick = (e) => {
+    e.target.select();
+  }
+
+  render () {
+    const { oembed } = this.state;
+
+    return (
+      <div className='modal-root__modal embed-modal'>
+        <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4>
+
+        <div className='embed-modal__container'>
+          <p className='hint'>
+            <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
+          </p>
+
+          <input
+            type='text'
+            className='embed-modal__html'
+            readOnly
+            value={oembed && oembed.html || ''}
+            onClick={this.handleTextareaClick}
+          />
+
+          <p className='hint'>
+            <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
+          </p>
+
+          <iframe
+            className='embed-modal__iframe'
+            scrolling='no'
+            frameBorder='0'
+            ref={this.setIframeRef}
+            title='preview'
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index d316ff433..cd605d7b2 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -14,6 +14,7 @@ import {
   ConfirmationModal,
   ReportModal,
   SettingsModal,
+  EmbedModal,
 } from '../../../features/ui/util/async-components';
 
 const MODAL_COMPONENTS = {
@@ -25,6 +26,7 @@ const MODAL_COMPONENTS = {
   'REPORT': ReportModal,
   'SETTINGS': SettingsModal,
   'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
+  'EMBED': EmbedModal,
 };
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
index 0c872f40d..2facf9c44 100644
--- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js
+++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
@@ -30,7 +30,7 @@ const PageOne = ({ acct, domain }) => (
     <div>
       <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1>
       <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p>
-      <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p>
+      <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p>
     </div>
   </div>
 );
diff --git a/app/javascript/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
index 6420f0784..95f95618b 100644
--- a/app/javascript/mastodon/features/ui/containers/columns_area_container.js
+++ b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
@@ -5,4 +5,4 @@ const mapStateToProps = state => ({
   columns: state.getIn(['settings', 'columns']),
 });
 
-export default connect(mapStateToProps)(ColumnsArea);
+export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea);
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 6d53f474d..883466602 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -1,12 +1,11 @@
 import React from 'react';
-import classNames from 'classnames';
-import Redirect from 'react-router-dom/Redirect';
 import NotificationsContainer from './containers/notifications_container';
 import PropTypes from 'prop-types';
 import LoadingBarContainer from './containers/loading_bar_container';
 import TabsBar from './components/tabs_bar';
 import ModalContainer from './containers/modal_container';
 import { connect } from 'react-redux';
+import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from '../../is_mobile';
 import { debounce } from 'lodash';
 import { uploadCompose } from '../../actions/compose';
@@ -51,6 +50,7 @@ const mapStateToProps = state => ({
 });
 
 @connect(mapStateToProps)
+@withRouter
 export default class UI extends React.PureComponent {
 
   static contextTypes = {
@@ -65,6 +65,7 @@ export default class UI extends React.PureComponent {
     systemFontUi: PropTypes.bool,
     navbarUnder: PropTypes.bool,
     isComposing: PropTypes.bool,
+    location: PropTypes.object,
   };
 
   state = {
@@ -141,7 +142,7 @@ export default class UI extends React.PureComponent {
     if (data.type === 'navigate') {
       this.context.router.history.push(data.path);
     } else {
-      console.warn('Unknown message type:', data.type); // eslint-disable-line no-console
+      console.warn('Unknown message type:', data.type);
     }
   }
 
@@ -175,6 +176,12 @@ export default class UI extends React.PureComponent {
     return true;
   }
 
+  componentDidUpdate (prevProps) {
+    if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+      this.columnsAreaNode.handleChildrenContentChange();
+    }
+  }
+
   componentWillUnmount () {
     window.removeEventListener('resize', this.handleResize);
     document.removeEventListener('dragenter', this.handleDragEnter);
@@ -188,6 +195,10 @@ export default class UI extends React.PureComponent {
     this.node = c;
   }
 
+  setColumnsAreaRef = (c) => {
+    this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
+  }
+
   render () {
     const { width, draggingOver } = this.state;
     const { children, layout, isWide, navbarUnder } = this.props;
@@ -212,7 +223,7 @@ export default class UI extends React.PureComponent {
     return (
       <div className={className} ref={this.setRef}>
         {navbarUnder ? null : (<TabsBar />)}
-        <ColumnsAreaContainer singleColumn={isMobile(width, layout)}>
+        <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
           <WrappedSwitch>
             <Redirect from='/' to='/getting-started' exact />
             <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 9267519dd..108ffc142 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -116,3 +116,7 @@ export function MediaGallery () {
 export function VideoPlayer () {
   return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
 }
+
+export function EmbedModal () {
+  return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
+}
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index f5cf77f92..2ceb6eb9a 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -47,7 +47,7 @@
   "compose_form.lock_disclaimer.lock": "مقفل",
   "compose_form.placeholder": "فيمَ تفكّر؟",
   "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
-  "compose_form.publish": "بوّق !",
+  "compose_form.publish": "بوّق",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس",
   "compose_form.spoiler": "أخفِ النص واعرض تحذيرا",
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "الأنشطة",
   "emoji_button.flags": "الأعلام",
   "emoji_button.food": "الطعام والشراب",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
   "status.delete": "إحذف",
+  "status.embed": "Embed",
   "status.favourite": "أضف إلى المفضلة",
   "status.load_more": "حمّل المزيد",
   "status.media_hidden": "الصورة مستترة",
   "status.mention": "أذكُر @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "وسع هذه المشاركة",
+  "status.pin": "Pin on profile",
   "status.reblog": "رَقِّي",
   "status.reblogged_by": "{name} رقى",
   "status.reply": "ردّ",
@@ -179,6 +183,7 @@
   "status.show_less": "إعرض أقلّ",
   "status.show_more": "أظهر المزيد",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "تحرير",
   "tabs_bar.federated_timeline": "الموحَّد",
   "tabs_bar.home": "الرئيسية",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index e6788f9eb..183ba2673 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Изтриване",
+  "status.embed": "Embed",
   "status.favourite": "Предпочитани",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Споменаване",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Споделяне",
   "status.reblogged_by": "{name} сподели",
   "status.reply": "Отговор",
@@ -179,6 +183,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Съставяне",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Начало",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 95b3c60bf..0e3d2bc18 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Estàs segur que vols silenciar {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activitat",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Menjar i Beure",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
   "status.delete": "Esborrar",
+  "status.embed": "Embed",
   "status.favourite": "Favorit",
   "status.load_more": "Carrega més",
   "status.media_hidden": "Multimèdia amagat",
   "status.mention": "Esmentar @{name}",
   "status.mute_conversation": "Silenciar conversació",
   "status.open": "Ampliar aquest estat",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} ha retootejat",
   "status.reply": "Respondre",
@@ -179,6 +183,7 @@
   "status.show_less": "Mostra menys",
   "status.show_more": "Mostra més",
   "status.unmute_conversation": "Activar conversació",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compondre",
   "tabs_bar.federated_timeline": "Federada",
   "tabs_bar.home": "Inici",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 67a99b765..38324e156 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -1,31 +1,31 @@
 {
   "account.block": "@{name} blocken",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "Alles von {domain} verstecken",
+  "account.disclaimer_full": "Hier aufgeführten Informationen können unvollständig sein.",
   "account.edit_profile": "Profil bearbeiten",
   "account.follow": "Folgen",
   "account.followers": "Folgende",
   "account.follows": "Folgt",
   "account.follows_you": "Folgt dir",
-  "account.media": "Media",
+  "account.media": "Medien",
   "account.mention": "@{name} erwähnen",
   "account.mute": "@{name} stummschalten",
   "account.posts": "Beiträge",
   "account.report": "@{name} melden",
-  "account.requested": "Warte auf Erlaubnis",
-  "account.share": "Share @{name}'s profile",
+  "account.requested": "Warte auf Erlaubnis. Klicke zum Abbrechen",
+  "account.share": "Profil von @{name} teilen",
   "account.unblock": "@{name} entblocken",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "{domain} wieder anzeigen",
   "account.unfollow": "Entfolgen",
   "account.unmute": "@{name} nicht mehr stummschalten",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "Komplettes Profil anzeigen",
   "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
+  "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
+  "bundle_column_error.retry": "Erneut versuchen",
+  "bundle_column_error.title": "Netzwerkfehlher",
+  "bundle_modal_error.close": "Schließen",
+  "bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.",
+  "bundle_modal_error.retry": "Erneut versuchen",
   "column.blocks": "Blockierte Benutzer",
   "column.community": "Lokale Zeitleiste",
   "column.favourites": "Favoriten",
@@ -35,16 +35,16 @@
   "column.notifications": "Mitteilungen",
   "column.public": "Gesamtes bekanntes Netz",
   "column_back_button.label": "Zurück",
-  "column_header.hide_settings": "Hide settings",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
-  "column_header.pin": "Pin",
-  "column_header.show_settings": "Show settings",
-  "column_header.unpin": "Unpin",
+  "column_header.hide_settings": "Einstellungen verbergen",
+  "column_header.moveLeft_settings": "Spalte links verschieben",
+  "column_header.moveRight_settings": "Spalte rechts verschieben",
+  "column_header.pin": "Anheften",
+  "column_header.show_settings": "Einstellungen anzeigen",
+  "column_header.unpin": "Lösen",
   "column_subheading.navigation": "Navigation",
-  "column_subheading.settings": "Settings",
-  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
-  "compose_form.lock_disclaimer.lock": "locked",
+  "column_subheading.settings": "Einstellungen",
+  "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.",
+  "compose_form.lock_disclaimer.lock": "gesperrt",
   "compose_form.placeholder": "Worüber möchtest du schreiben?",
   "compose_form.privacy_disclaimer": "Dein privater Status wird an die genannten Benutzer auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.",
   "compose_form.publish": "Tröt",
@@ -52,41 +52,43 @@
   "compose_form.sensitive": "Medien als heikel markieren",
   "compose_form.spoiler": "Text hinter Warnung verbergen",
   "compose_form.spoiler_placeholder": "Inhaltswarnung",
-  "confirmation_modal.cancel": "Cancel",
-  "confirmations.block.confirm": "Block",
-  "confirmations.block.message": "Are you sure you want to block {name}?",
-  "confirmations.delete.confirm": "Delete",
-  "confirmations.delete.message": "Are you sure you want to delete this status?",
-  "confirmations.domain_block.confirm": "Hide entire domain",
-  "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
-  "confirmations.mute.confirm": "Mute",
-  "confirmations.mute.message": "Are you sure you want to mute {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
-  "emoji_button.activity": "Activity",
-  "emoji_button.flags": "Flags",
-  "emoji_button.food": "Food & Drink",
+  "confirmation_modal.cancel": "Abbrechen",
+  "confirmations.block.confirm": "Blockieren",
+  "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
+  "confirmations.delete.confirm": "Löschen",
+  "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchstest?",
+  "confirmations.domain_block.confirm": "Die ganze Domain verbergen",
+  "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen sind ein paar gezielte Blocks genug.",
+  "confirmations.mute.confirm": "Stummschalten",
+  "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchstest?",
+  "confirmations.unfollow.confirm": "Entfolgen",
+  "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchstest?",
+  "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.",
+  "embed.preview": "So wird es aussehen:",
+  "emoji_button.activity": "Aktivitäten",
+  "emoji_button.flags": "Flaggen",
+  "emoji_button.food": "Essen und Trinken",
   "emoji_button.label": "Emoji einfügen",
-  "emoji_button.nature": "Nature",
-  "emoji_button.objects": "Objects",
-  "emoji_button.people": "People",
-  "emoji_button.search": "Search...",
-  "emoji_button.symbols": "Symbols",
-  "emoji_button.travel": "Travel & Places",
+  "emoji_button.nature": "Natur",
+  "emoji_button.objects": "Dinge",
+  "emoji_button.people": "Leute",
+  "emoji_button.search": "Suche…",
+  "emoji_button.symbols": "Symbole",
+  "emoji_button.travel": "Reise und Orte",
   "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!",
   "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.",
   "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Benutzer anzutreffen.",
-  "empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.",
+  "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich wiedererstellt.",
   "empty_column.home.public_timeline": "die öffentliche Zeitleiste",
   "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.",
   "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Benutzern von anderen Instanzen, um es aufzufüllen.",
   "follow_request.authorize": "Erlauben",
   "follow_request.reject": "Ablehnen",
-  "getting_started.appsshort": "Apps",
-  "getting_started.faq": "FAQ",
+  "getting_started.appsshort": "Anwendungen",
+  "getting_started.faq": "Häufig gestellte Fragen",
   "getting_started.heading": "Erste Schritte",
   "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
-  "getting_started.userguide": "User Guide",
+  "getting_started.userguide": "Nutzeranleitung",
   "home.column_settings.advanced": "Fortgeschritten",
   "home.column_settings.basic": "Einfach",
   "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
@@ -94,8 +96,8 @@
   "home.column_settings.show_replies": "Antworten anzeigen",
   "home.settings": "Spalteneinstellungen",
   "lightbox.close": "Schließen",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "Weiter",
+  "lightbox.previous": "Zurück",
   "loading_indicator.label": "Lade…",
   "media_gallery.toggle_visible": "Sichtbarkeit einstellen",
   "missing_indicator.label": "Nicht gefunden",
@@ -113,8 +115,8 @@
   "notification.follow": "{name} folgt dir",
   "notification.mention": "{name} erwähnte dich",
   "notification.reblog": "{name} teilte deinen Status",
-  "notifications.clear": "Mitteilungen beseitigen",
-  "notifications.clear_confirmation": "Bist du sicher, dass du alle Mitteilungen beseitigen willst?",
+  "notifications.clear": "Mitteilungen löschen",
+  "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchstest?",
   "notifications.column_settings.alert": "Desktop-Benachrichtigungen",
   "notifications.column_settings.favourite": "Favorisierungen:",
   "notifications.column_settings.follow": "Neue Folgende:",
@@ -124,26 +126,26 @@
   "notifications.column_settings.reblog": "Geteilte Beiträge:",
   "notifications.column_settings.show": "In der Spalte anzeigen",
   "notifications.column_settings.sound": "Ton abspielen",
-  "onboarding.done": "Done",
-  "onboarding.next": "Next",
-  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
-  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
-  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
-  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
-  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
-  "onboarding.page_one.welcome": "Welcome to Mastodon!",
-  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
-  "onboarding.page_six.almost_done": "Almost done...",
-  "onboarding.page_six.appetoot": "Bon Appetoot!",
-  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
-  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
-  "onboarding.page_six.guidelines": "community guidelines",
-  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
-  "onboarding.page_six.various_app": "mobile apps",
-  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
-  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
-  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
-  "onboarding.skip": "Skip",
+  "onboarding.done": "Fertig",
+  "onboarding.next": "Weiter",
+  "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf deiner Instanz {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, die kollektiv aus deiner Instanz heraus gefolgt werden. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt, durch sie kannst du viel neues entdecken.",
+  "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.",
+  "onboarding.page_four.notifications": "Wenn jemand mir dir interagiert, bekommst du eine Mitteilung.",
+  "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.",
+  "onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Nutzername im Netzwerk {handle}",
+  "onboarding.page_one.welcome": "Willkommen bei Mastodon!",
+  "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.",
+  "onboarding.page_six.almost_done": "Fast fertig…",
+  "onboarding.page_six.appetoot": "Guten Appetröt!",
+  "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.",
+  "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
+  "onboarding.page_six.guidelines": "Richtlinien",
+  "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!",
+  "onboarding.page_six.various_app": "mobile Anwendungen",
+  "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.",
+  "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel, die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Nutzernamen.",
+  "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeitseinstellungen ändern und Inhaltswarnungen hinzufügen.",
+  "onboarding.skip": "Überspringen",
   "privacy.change": "Privatsphäre des Status anpassen",
   "privacy.direct.long": "Beitrag nur an erwähnte Benutzer",
   "privacy.direct.short": "Direkt",
@@ -159,15 +161,17 @@
   "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",
+  "standalone.public_title": "Vorschau…",
+  "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
   "status.delete": "Löschen",
+  "status.embed": "Einbetten",
   "status.favourite": "Favorisieren",
   "status.load_more": "Weitere laden",
   "status.media_hidden": "Medien versteckt",
   "status.mention": "Erwähnen",
-  "status.mute_conversation": "Mute conversation",
+  "status.mute_conversation": "Thread stummschalten",
   "status.open": "Öffnen",
+  "status.pin": "Auf dem Profil anheften",
   "status.reblog": "Teilen",
   "status.reblogged_by": "{name} teilte",
   "status.reply": "Antworten",
@@ -175,13 +179,14 @@
   "status.report": "@{name} melden",
   "status.sensitive_toggle": "Klicke, um sie zu sehen",
   "status.sensitive_warning": "Heikle Inhalte",
-  "status.share": "Share",
+  "status.share": "Teilen",
   "status.show_less": "Weniger anzeigen",
   "status.show_more": "Mehr anzeigen",
-  "status.unmute_conversation": "Unmute conversation",
+  "status.unmute_conversation": "Stummschaltung von Thread aufheben",
+  "status.unpin": "Vom Profil lösen",
   "tabs_bar.compose": "Schreiben",
   "tabs_bar.federated_timeline": "Föderation",
-  "tabs_bar.home": "Home",
+  "tabs_bar.home": "Startseite",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Mitteilungen",
   "upload_area.title": "Hereinziehen zum Hochladen",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index e5d541cd6..89f74a56b 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -189,6 +189,18 @@
       {
         "defaultMessage": "Unmute conversation",
         "id": "status.unmute_conversation"
+      },
+      {
+        "defaultMessage": "Pin on profile",
+        "id": "status.pin"
+      },
+      {
+        "defaultMessage": "Unpin from profile",
+        "id": "status.unpin"
+      },
+      {
+        "defaultMessage": "Embed",
+        "id": "status.embed"
       }
     ],
     "path": "app/javascript/mastodon/components/status_action_bar.json"
@@ -424,7 +436,7 @@
         "id": "account.follow"
       },
       {
-        "defaultMessage": "Awaiting approval",
+        "defaultMessage": "Awaiting approval. Click to cancel follow request",
         "id": "account.requested"
       },
       {
@@ -1035,6 +1047,18 @@
       {
         "defaultMessage": "Share",
         "id": "status.share"
+      },
+      {
+        "defaultMessage": "Pin on profile",
+        "id": "status.pin"
+      },
+      {
+        "defaultMessage": "Unpin from profile",
+        "id": "status.unpin"
+      },
+      {
+        "defaultMessage": "Embed",
+        "id": "status.embed"
       }
     ],
     "path": "app/javascript/mastodon/features/status/components/action_bar.json"
@@ -1111,6 +1135,23 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Embed",
+        "id": "status.embed"
+      },
+      {
+        "defaultMessage": "Embed this status on your website by copying the code below.",
+        "id": "embed.instructions"
+      },
+      {
+        "defaultMessage": "Here is what it will look like:",
+        "id": "embed.preview"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/embed_modal.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Close",
         "id": "lightbox.close"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 2ea2062d3..6d9b9c208 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -12,7 +12,7 @@
   "account.mute": "Mute @{name}",
   "account.posts": "Posts",
   "account.report": "Report @{name}",
-  "account.requested": "Awaiting approval",
+  "account.requested": "Awaiting approval. Click to cancel follow request",
   "account.share": "Share @{name}'s profile",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unhide {domain}",
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Delete",
+  "status.embed": "Embed",
   "status.favourite": "Favourite",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mention @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boosted",
   "status.reply": "Reply",
@@ -179,6 +183,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compose",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 960d747ec..d828d0858 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Forigi",
+  "status.embed": "Embed",
   "status.favourite": "Favori",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mencii @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Diskonigi",
   "status.reblogged_by": "{name} diskonigita",
   "status.reply": "Respondi",
@@ -179,6 +183,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Ekskribi",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Hejmo",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 212d16639..d35eb84e7 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Borrar",
+  "status.embed": "Embed",
   "status.favourite": "Favorito",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mencionar",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir estado",
+  "status.pin": "Pin on profile",
   "status.reblog": "Retoot",
   "status.reblogged_by": "Retooteado por {name}",
   "status.reply": "Responder",
@@ -179,6 +183,7 @@
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar más",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Redactar",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Inicio",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 5ada62f93..b51340fa7 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "آیا واقعاً می‌خواهید {name} را بی‌صدا کنید؟",
   "confirmations.unfollow.confirm": "لغو پیگیری",
   "confirmations.unfollow.message": "آیا واقعاً می‌خواهید به پیگیری از {name} پایان دهید؟",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "فعالیت",
   "emoji_button.flags": "پرچم‌ها",
   "emoji_button.food": "غذا و نوشیدنی",
@@ -162,12 +164,14 @@
   "standalone.public_title": "نگاهی به کاربران این سرور...",
   "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
   "status.delete": "پاک‌کردن",
+  "status.embed": "Embed",
   "status.favourite": "پسندیدن",
   "status.load_more": "بیشتر نشان بده",
   "status.media_hidden": "تصویر پنهان شده",
   "status.mention": "نام‌بردن از @{name}",
   "status.mute_conversation": "بی‌صداکردن گفتگو",
   "status.open": "این نوشته را باز کن",
+  "status.pin": "Pin on profile",
   "status.reblog": "بازبوقیدن",
   "status.reblogged_by": "‫{name}‬ بازبوقید",
   "status.reply": "پاسخ",
@@ -179,6 +183,7 @@
   "status.show_less": "نهفتن",
   "status.show_more": "نمایش",
   "status.unmute_conversation": "باصداکردن گفتگو",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "بنویسید",
   "tabs_bar.federated_timeline": "همگانی",
   "tabs_bar.home": "خانه",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index cb9e9c2a6..926a57ff1 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Poista",
+  "status.embed": "Embed",
   "status.favourite": "Tykkää",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mainitse @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Buustaa",
   "status.reblogged_by": "{name} buustasi",
   "status.reply": "Vastaa",
@@ -179,6 +183,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Luo",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Koti",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index f3f0d0463..fa8ea6c73 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -20,11 +20,11 @@
   "account.unmute": "Ne plus masquer",
   "account.view_full_profile": "Afficher le profil complet",
   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois",
-  "bundle_column_error.body": "Une erreur s'est produite lors du chargement de ce composant.",
+  "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.",
   "bundle_column_error.retry": "Réessayer",
   "bundle_column_error.title": "Erreur réseau",
   "bundle_modal_error.close": "Fermer",
-  "bundle_modal_error.message": "Une erreur s'est produite lors du chargement de ce composant.",
+  "bundle_modal_error.message": "Une erreur s’est produite lors du chargement de ce composant.",
   "bundle_modal_error.retry": "Réessayer",
   "column.blocks": "Comptes bloqués",
   "column.community": "Fil public local",
@@ -43,12 +43,12 @@
   "column_header.unpin": "Retirer",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Paramètres",
-  "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets restreints.",
+  "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets privés.",
   "compose_form.lock_disclaimer.lock": "verrouillé",
   "compose_form.placeholder": "Qu’avez-vous en tête ?",
   "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodon. Si {domains} {domainsCount, plural, one {n’est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n’y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d’une autre manière à d’autres personnes imprévues.",
   "compose_form.publish": "Pouet ",
-  "compose_form.publish_loud": "{publish}!",
+  "compose_form.publish_loud": "{publish} !",
   "compose_form.sensitive": "Marquer le média comme sensible",
   "compose_form.spoiler": "Masquer le texte derrière un avertissement",
   "compose_form.spoiler_placeholder": "Écrivez ici votre avertissement",
@@ -62,7 +62,9 @@
   "confirmations.mute.confirm": "Masquer",
   "confirmations.mute.message": "Confirmez vous le masquage de {name} ?",
   "confirmations.unfollow.confirm": "Ne plus suivre",
-  "confirmations.unfollow.message": "Vous voulez-vous arrêter de suivre {name} ?",
+  "confirmations.unfollow.message": "Vous voulez-vous arrêter de suivre {name} ?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activités",
   "emoji_button.flags": "Drapeaux",
   "emoji_button.food": "Boire et manger",
@@ -134,8 +136,8 @@
   "onboarding.page_one.welcome": "Bienvenue sur Mastodon !",
   "onboarding.page_six.admin": "L’administrateur⋅trice de votre instance est {admin}",
   "onboarding.page_six.almost_done": "Nous y sommes presque…",
-  "onboarding.page_six.appetoot": "Bon Appétoot!",
-  "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon Appétoot!",
+  "onboarding.page_six.appetoot": "Bon appouétit !",
+  "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon appouétit !",
   "onboarding.page_six.github": "Mastodon est un logiciel libre, gratuit et open-source. Vous pouvez rapporter des bogues, suggérer des fonctionnalités, ou contribuer à son développement sur {github}.",
   "onboarding.page_six.guidelines": "règles de la communauté",
   "onboarding.page_six.read_guidelines": "S’il vous plaît, n’oubliez pas de lire les {guidelines} !",
@@ -159,15 +161,17 @@
   "report.target": "Signalement",
   "search.placeholder": "Rechercher",
   "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
-  "standalone.public_title": "Coup d'œil",
+  "standalone.public_title": "Jeter un coup d’œil…",
   "status.cannot_reblog": "Cette publication ne peut être boostée",
   "status.delete": "Effacer",
+  "status.embed": "Embed",
   "status.favourite": "Ajouter aux favoris",
   "status.load_more": "Charger plus",
   "status.media_hidden": "Média caché",
   "status.mention": "Mentionner",
   "status.mute_conversation": "Masquer la conversation",
   "status.open": "Déplier ce statut",
+  "status.pin": "Épingler sur le profil",
   "status.reblog": "Partager",
   "status.reblogged_by": "{name} a partagé :",
   "status.reply": "Répondre",
@@ -179,6 +183,7 @@
   "status.show_less": "Replier",
   "status.show_more": "Déplier",
   "status.unmute_conversation": "Ne plus masquer la conversation",
+  "status.unpin": "Retirer du profil",
   "tabs_bar.compose": "Composer",
   "tabs_bar.federated_timeline": "Fil public global",
   "tabs_bar.home": "Accueil",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 34266d8e1..9ef933108 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "להשתיק את {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "פעילות",
   "emoji_button.flags": "דגלים",
   "emoji_button.food": "אוכל ושתיה",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "לא ניתן להדהד הודעה זו",
   "status.delete": "מחיקה",
+  "status.embed": "Embed",
   "status.favourite": "חיבוב",
   "status.load_more": "עוד",
   "status.media_hidden": "מדיה מוסתרת",
   "status.mention": "פניה אל @{name}",
   "status.mute_conversation": "השתקת שיחה",
   "status.open": "הרחבת הודעה",
+  "status.pin": "Pin on profile",
   "status.reblog": "הדהוד",
   "status.reblogged_by": "הודהד על ידי {name}",
   "status.reply": "תגובה",
@@ -179,6 +183,7 @@
   "status.show_less": "הראה פחות",
   "status.show_more": "הראה יותר",
   "status.unmute_conversation": "הסרת השתקת שיחה",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "חיבור",
   "tabs_bar.federated_timeline": "ציר זמן בין-קהילתי",
   "tabs_bar.home": "בבית",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index f69b096d4..27e943bdd 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Jesi li siguran da želiš utišati {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Aktivnost",
   "emoji_button.flags": "Zastave",
   "emoji_button.food": "Hrana & Piće",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Ovaj post ne može biti podignut",
   "status.delete": "Obriši",
+  "status.embed": "Embed",
   "status.favourite": "Označi omiljenim",
   "status.load_more": "Učitaj više",
   "status.media_hidden": "Sakriven media sadržaj",
   "status.mention": "Spomeni @{name}",
   "status.mute_conversation": "Utišaj razgovor",
   "status.open": "Proširi ovaj status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Podigni",
   "status.reblogged_by": "{name} je podigao",
   "status.reply": "Odgovori",
@@ -179,6 +183,7 @@
   "status.show_less": "Pokaži manje",
   "status.show_more": "Pokaži više",
   "status.unmute_conversation": "Poništi utišavanje razgovora",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Sastavi",
   "tabs_bar.federated_timeline": "Federalni",
   "tabs_bar.home": "Dom",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 4d2a50963..a708ec638 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Törlés",
+  "status.embed": "Embed",
   "status.favourite": "Kedvenc",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Említés",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Reblog",
   "status.reblogged_by": "{name} reblogolta",
   "status.reply": "Válasz",
@@ -179,6 +183,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Összeállítás",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Kezdőlap",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 532739e3c..d71e293e8 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Apa anda yakin ingin membisukan {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Aktivitas",
   "emoji_button.flags": "Bendera",
   "emoji_button.food": "Makanan & Minuman",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Hapus",
+  "status.embed": "Embed",
   "status.favourite": "Difavoritkan",
   "status.load_more": "Tampilkan semua",
   "status.media_hidden": "Media disembunyikan",
   "status.mention": "Balasan @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Tampilkan status ini",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "di-boost {name}",
   "status.reply": "Balas",
@@ -179,6 +183,7 @@
   "status.show_less": "Tampilkan lebih sedikit",
   "status.show_more": "Tampilkan semua",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Tulis",
   "tabs_bar.federated_timeline": "Gabungan",
   "tabs_bar.home": "Beranda",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index a5e363e40..5df5c59a1 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Efacar",
+  "status.embed": "Embed",
   "status.favourite": "Favorizar",
   "status.load_more": "Kargar pluse",
   "status.media_hidden": "Kontenajo celita",
   "status.mention": "Mencionar @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Detaligar ca mesajo",
+  "status.pin": "Pin on profile",
   "status.reblog": "Repetar",
   "status.reblogged_by": "{name} repetita",
   "status.reply": "Respondar",
@@ -179,6 +183,7 @@
   "status.show_less": "Montrar mine",
   "status.show_more": "Montrar plue",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Kompozar",
   "tabs_bar.federated_timeline": "Federata",
   "tabs_bar.home": "Hemo",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 329eb82ca..eec35a70c 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Elimina",
+  "status.embed": "Embed",
   "status.favourite": "Apprezzato",
   "status.load_more": "Mostra di più",
   "status.media_hidden": "Allegato nascosto",
   "status.mention": "Nomina @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Espandi questo post",
+  "status.pin": "Pin on profile",
   "status.reblog": "Condividi",
   "status.reblogged_by": "{name} ha condiviso",
   "status.reply": "Rispondi",
@@ -179,6 +183,7 @@
   "status.show_less": "Mostra meno",
   "status.show_more": "Mostra di più",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Scrivi",
   "tabs_bar.federated_timeline": "Federazione",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 4c98086bb..560d2b668 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "本当に{name}をミュートしますか?",
   "confirmations.unfollow.confirm": "フォロー解除",
   "confirmations.unfollow.message": "本当に{name}をフォロー解除しますか?",
+  "embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。",
+  "embed.preview": "表示例:",
   "emoji_button.activity": "活動",
   "emoji_button.flags": "国旗",
   "emoji_button.food": "食べ物",
@@ -159,15 +161,17 @@
   "report.target": "{target} を通報する",
   "search.placeholder": "検索",
   "search_results.total": "{count, number}件の結果",
-  "standalone.public_title": "連合タイムライン",
+  "standalone.public_title": "今こんな話をしています",
   "status.cannot_reblog": "この投稿はブーストできません",
   "status.delete": "削除",
+  "status.embed": "埋め込み",
   "status.favourite": "お気に入り",
   "status.load_more": "もっと見る",
   "status.media_hidden": "非表示のメディア",
   "status.mention": "返信",
   "status.mute_conversation": "会話をミュート",
   "status.open": "詳細を表示",
+  "status.pin": "プロフィールに固定表示",
   "status.reblog": "ブースト",
   "status.reblogged_by": "{name}さんにブーストされました",
   "status.reply": "返信",
@@ -179,6 +183,7 @@
   "status.show_less": "隠す",
   "status.show_more": "もっと見る",
   "status.unmute_conversation": "会話のミュートを解除",
+  "status.unpin": "プロフィールの固定表示を解除",
   "tabs_bar.compose": "投稿",
   "tabs_bar.federated_timeline": "連合",
   "tabs_bar.home": "ホーム",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 47d0d4087..7d573506c 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "정말로 {name}를 뮤트하시겠습니까?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "활동",
   "emoji_button.flags": "국기",
   "emoji_button.food": "음식",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
   "status.delete": "삭제",
+  "status.embed": "Embed",
   "status.favourite": "즐겨찾기",
   "status.load_more": "더 보기",
   "status.media_hidden": "미디어 숨겨짐",
   "status.mention": "답장",
   "status.mute_conversation": "이 대화를 뮤트",
   "status.open": "상세 정보 표시",
+  "status.pin": "Pin on profile",
   "status.reblog": "부스트",
   "status.reblogged_by": "{name}님이 부스트 했습니다",
   "status.reply": "답장",
@@ -179,6 +183,7 @@
   "status.show_less": "숨기기",
   "status.show_more": "더 보기",
   "status.unmute_conversation": "이 대화의 뮤트 해제하기",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "포스트",
   "tabs_bar.federated_timeline": "연합",
   "tabs_bar.home": "홈",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 4d68c7992..d6775e1e4 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Weet je het zeker dat je {name} wilt negeren?",
   "confirmations.unfollow.confirm": "Ontvolgen",
   "confirmations.unfollow.message": "Weet je het zeker dat je {name} wilt ontvolgen?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activiteiten",
   "emoji_button.flags": "Vlaggen",
   "emoji_button.food": "Eten en drinken",
@@ -162,12 +164,14 @@
   "standalone.public_title": "Een kijkje binnenin...",
   "status.cannot_reblog": "Deze toot kan niet geboost worden",
   "status.delete": "Verwijderen",
+  "status.embed": "Embed",
   "status.favourite": "Favoriet",
   "status.load_more": "Meer laden",
   "status.media_hidden": "Media verborgen",
   "status.mention": "Vermeld @{name}",
   "status.mute_conversation": "Negeer conversatie",
   "status.open": "Toot volledig tonen",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boostte",
   "status.reply": "Reageren",
@@ -179,6 +183,7 @@
   "status.show_less": "Minder tonen",
   "status.show_more": "Meer tonen",
   "status.unmute_conversation": "Conversatie niet meer negeren",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Schrijven",
   "tabs_bar.federated_timeline": "Globaal",
   "tabs_bar.home": "Start",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 9453e65ff..f3c24a807 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Er du sikker på at du vil dempe {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Aktivitet",
   "emoji_button.flags": "Flagg",
   "emoji_button.food": "Mat og drikke",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Denne posten kan ikke fremheves",
   "status.delete": "Slett",
+  "status.embed": "Embed",
   "status.favourite": "Lik",
   "status.load_more": "Last mer",
   "status.media_hidden": "Media skjult",
   "status.mention": "Nevn @{name}",
   "status.mute_conversation": "Demp samtale",
   "status.open": "Utvid denne statusen",
+  "status.pin": "Pin on profile",
   "status.reblog": "Fremhev",
   "status.reblogged_by": "Fremhevd av {name}",
   "status.reply": "Svar",
@@ -179,6 +183,7 @@
   "status.show_less": "Vis mindre",
   "status.show_more": "Vis mer",
   "status.unmute_conversation": "Ikke demp samtale",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Komponer",
   "tabs_bar.federated_timeline": "Felles",
   "tabs_bar.home": "Hjem",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index e2a5d7c59..141bff042 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -45,24 +45,26 @@
   "column_subheading.settings": "Paramètres",
   "compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.",
   "compose_form.lock_disclaimer.lock": "clavat",
-  "compose_form.placeholder": "A de qué pensatz ?",
-  "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists",
+  "compose_form.placeholder": "A de qué pensatz ?",
+  "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists",
   "compose_form.publish": "Tut",
-  "compose_form.publish_loud": "{publish} !",
+  "compose_form.publish_loud": "{publish} !",
   "compose_form.sensitive": "Marcar lo mèdia coma sensible",
   "compose_form.spoiler": "Rescondre lo tèxte darrièr un avertiment",
   "compose_form.spoiler_placeholder": "Escrivètz l’avertiment aquí",
   "confirmation_modal.cancel": "Anullar",
   "confirmations.block.confirm": "Blocar",
-  "confirmations.block.message": "Sètz segur de voler blocar {name} ?",
+  "confirmations.block.message": "Sètz segur de voler blocar {name} ?",
   "confirmations.delete.confirm": "Suprimir",
-  "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?",
+  "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?",
   "confirmations.domain_block.confirm": "Amagar tot lo domeni",
-  "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
+  "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
   "confirmations.mute.confirm": "Metre en silenci",
-  "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?",
+  "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?",
   "confirmations.unfollow.confirm": "Quitar de sègre",
-  "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?",
+  "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activitats",
   "emoji_button.flags": "Drapèus",
   "emoji_button.food": "Beure e manjar",
@@ -73,13 +75,13 @@
   "emoji_button.search": "Cercar…",
   "emoji_button.symbols": "Simbòls",
   "emoji_button.travel": "Viatges & lòcs",
-  "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !",
+  "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !",
   "empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag",
   "empty_column.home": "Pel moment seguètz pas degun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.",
   "empty_column.home.inactivity": "Vòstra pagina d’acuèlh es voida. Se sètz estat inactiu per un moment, serà tornada generar per vos dins una estona.",
   "empty_column.home.public_timeline": "lo flux public",
   "empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.",
-  "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.",
+  "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.",
   "follow_request.authorize": "Autorizar",
   "follow_request.reject": "Regetar",
   "getting_started.appsshort": "Apps",
@@ -109,19 +111,19 @@
   "navigation_bar.mutes": "Personas rescondudas",
   "navigation_bar.preferences": "Preferéncias",
   "navigation_bar.public_timeline": "Flux public global",
-  "notification.favourite": "{name} a ajustat a sos favorits :",
+  "notification.favourite": "{name} a ajustat a sos favorits :",
   "notification.follow": "{name} vos sèc",
-  "notification.mention": "{name} vos a mencionat :",
-  "notification.reblog": "{name} a partejat vòstre estatut :",
+  "notification.mention": "{name} vos a mencionat :",
+  "notification.reblog": "{name} a partejat vòstre estatut :",
   "notifications.clear": "Escafar",
-  "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?",
+  "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?",
   "notifications.column_settings.alert": "Notificacions localas",
-  "notifications.column_settings.favourite": "Favorits :",
-  "notifications.column_settings.follow": "Nòus seguidors :",
-  "notifications.column_settings.mention": "Mencions :",
+  "notifications.column_settings.favourite": "Favorits :",
+  "notifications.column_settings.follow": "Nòus seguidors :",
+  "notifications.column_settings.mention": "Mencions :",
   "notifications.column_settings.push": "Notificacions",
   "notifications.column_settings.push_meta": "Aqueste periferic",
-  "notifications.column_settings.reblog": "Partatges :",
+  "notifications.column_settings.reblog": "Partatges :",
   "notifications.column_settings.show": "Mostrar dins la colomna",
   "notifications.column_settings.sound": "Emetre un son",
   "onboarding.done": "Fach",
@@ -131,14 +133,14 @@
   "onboarding.page_four.notifications": "La colomna de notificacions vos fa veire quand qualqu’un interagís amb vos",
   "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per bastir un malhum ma larg. Òm los apèla instàncias.",
   "onboarding.page_one.handle": "Sètz sus {domain}, doncas vòstre identificant complet es {handle}",
-  "onboarding.page_one.welcome": "Benvengut a Mastodon !",
+  "onboarding.page_one.welcome": "Benvengut a Mastodon !",
   "onboarding.page_six.admin": "Vòstre administrator d’instància es {admin}.",
   "onboarding.page_six.almost_done": "Gaireben acabat…",
   "onboarding.page_six.appetoot": "Bon Appetut!",
   "onboarding.page_six.apps_available": "I a d’aplicacions per mobil per iOS, Android e mai.",
   "onboarding.page_six.github": "Mastodon es un logicial liure e open-source.  Podètz senhalar de bugs, demandar de foncionalitats e contribuir al còdi sus {github}.",
   "onboarding.page_six.guidelines": "guida de la comunitat",
-  "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} a {domain} !",
+  "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} a {domain} !",
   "onboarding.page_six.various_app": "aplicacions per mobil",
   "onboarding.page_three.profile": "Modificatz vòstre perfil per cambiar vòstre avatar, bio e escais-nom. I a enlà totas las preferéncias.",
   "onboarding.page_three.search": "Emplegatz la barra de recèrca per trobar de mond e engachatz las etiquetas coma {illustration} e {introductions}. Per trobar una persona d’una autra instància, picatz son identificant complet.",
@@ -162,14 +164,16 @@
   "standalone.public_title": "Una ulhada dedins…",
   "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
   "status.delete": "Escafar",
+  "status.embed": "Embed",
   "status.favourite": "Apondre als favorits",
   "status.load_more": "Cargar mai",
   "status.media_hidden": "Mèdia rescondut",
   "status.mention": "Mencionar",
   "status.mute_conversation": "Rescondre la conversacion",
   "status.open": "Desplegar aqueste estatut",
+  "status.pin": "Penjar al perfil",
   "status.reblog": "Partejar",
-  "status.reblogged_by": "{name} a partejat :",
+  "status.reblogged_by": "{name} a partejat :",
   "status.reply": "Respondre",
   "status.replyAll": "Respondre a la conversacion",
   "status.report": "Senhalar @{name}",
@@ -179,6 +183,7 @@
   "status.show_less": "Tornar plegar",
   "status.show_more": "Desplegar",
   "status.unmute_conversation": "Conversacions amb silenci levat",
+  "status.unpin": "Despenjar del perfil",
   "tabs_bar.compose": "Compausar",
   "tabs_bar.federated_timeline": "Flux public global",
   "tabs_bar.home": "Acuèlh",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 542230f11..e3e652970 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -10,7 +10,7 @@
   "account.media": "Media",
   "account.mention": "Wspomnij o @{name}",
   "account.mute": "Wycisz @{name}",
-  "account.posts": "Posty",
+  "account.posts": "Wpisy",
   "account.report": "Zgłoś @{name}",
   "account.requested": "Oczekująca prośba",
   "account.share": "Udostępnij profil @{name}",
@@ -43,10 +43,10 @@
   "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ę śledzi, może wyświetlać Twoje posty przeznaczone tylko dla śledzących.",
+  "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje wpisy przeznaczone tylko dla śledzących.",
   "compose_form.lock_disclaimer.lock": "zablokowane",
   "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.privacy_disclaimer": "Twój wpis zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność wpisów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, wpis może być widoczny dla niewłaściwych osób.",
   "compose_form.publish": "Wyślij",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Oznacz treści jako wrażliwe",
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?",
   "confirmations.unfollow.confirm": "Przestań śledzić",
   "confirmations.unfollow.message": "Czy na pewno zamierzasz przestać śledzić {name}?",
+  "embed.instructions": "Osadź ten status na swojej stronie wklejając poniższy kod.",
+  "embed.preview": "Tak będzie to wyglądać:",
   "emoji_button.activity": "Aktywność",
   "emoji_button.flags": "Flagi",
   "emoji_button.food": "Żywność i napoje",
@@ -70,11 +72,11 @@
   "emoji_button.nature": "Natura",
   "emoji_button.objects": "Objekty",
   "emoji_button.people": "Ludzie",
-  "emoji_button.search": "Szukaj...",
+  "emoji_button.search": "Szukaj…",
   "emoji_button.symbols": "Symbole",
   "emoji_button.travel": "Podróże i miejsca",
   "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!",
-  "empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
+  "empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
   "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",
@@ -85,7 +87,7 @@
   "getting_started.appsshort": "Aplikacje",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Naucz się korzystać",
-  "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj {github}.",
+  "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj: {github}.",
   "getting_started.userguide": "Podręcznik użytkownika",
   "home.column_settings.advanced": "Zaawansowane",
   "home.column_settings.basic": "Podstawowe",
@@ -96,7 +98,7 @@
   "lightbox.close": "Zamknij",
   "lightbox.next": "Następne",
   "lightbox.previous": "Poprzednie",
-  "loading_indicator.label": "Ładowanie...",
+  "loading_indicator.label": "Ładowanie…",
   "media_gallery.toggle_visible": "Przełącz widoczność",
   "missing_indicator.label": "Nie znaleziono",
   "navigation_bar.blocks": "Zablokowani użytkownicy",
@@ -116,12 +118,12 @@
   "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.favourite": "Dodanie do ulubionych:",
   "notifications.column_settings.follow": "Nowi śledzący:",
-  "notifications.column_settings.mention": "Wspomniali:",
+  "notifications.column_settings.mention": "Wspomnienia:",
   "notifications.column_settings.push": "Powiadomienia push",
   "notifications.column_settings.push_meta": "To urządzenie",
-  "notifications.column_settings.reblog": "Podbili:",
+  "notifications.column_settings.reblog": "Podbicia:",
   "notifications.column_settings.show": "Pokaż w kolumnie",
   "notifications.column_settings.sound": "Odtwarzaj dźwięk",
   "onboarding.done": "Gotowe",
@@ -142,15 +144,15 @@
   "onboarding.page_six.various_app": "aplikacje mobilne",
   "onboarding.page_three.profile": "Edytuj profil, aby zmienić obraz profilowy, biografię, wyświetlaną nazwę i inne ustawienia.",
   "onboarding.page_three.search": "Użyj paska wyszukiwania aby znaleźć ludzi i hashtagi, takie jak {illustration} i {introductions}. Aby znaleźć osobę spoza tej instancji, musisz użyć pełnego adresu.",
-  "onboarding.page_two.compose": "Napisz posty, aby wypełnić kolumnę. Możesz wysłać zdjęcia, zmienić ustawienia prywatności lub dodać ostrzeżenie o zawartości.",
+  "onboarding.page_two.compose": "Utwórz wpisy, aby wypełnić kolumnę. Możesz wysłać zdjęcia, zmienić ustawienia prywatności lub dodać ostrzeżenie o zawartości.",
   "onboarding.skip": "Pomiń",
-  "privacy.change": "Dostosuj widoczność postów",
-  "privacy.direct.long": "Widoczne tylko dla oznaczonych",
+  "privacy.change": "Dostosuj widoczność wpisów",
+  "privacy.direct.long": "Widoczny tylko dla wspomnianych",
   "privacy.direct.short": "Bezpośrednio",
-  "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.private.long": "Widoczny tylko dla osób, które Cię śledzą",
+  "privacy.private.short": "Tylko dla śledzących",
+  "privacy.public.long": "Widoczny na publicznych osiach czasu",
+  "privacy.public.short": "Publiczny",
   "privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
   "privacy.unlisted.short": "Niewidoczne",
   "reply_indicator.cancel": "Anuluj",
@@ -160,14 +162,16 @@
   "search.placeholder": "Szukaj",
   "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
   "standalone.public_title": "Spojrzenie w głąb…",
-  "status.cannot_reblog": "Ten post nie może zostać podbity",
+  "status.cannot_reblog": "Ten wpis nie może zostać podbity",
   "status.delete": "Usuń",
+  "status.embed": "Osadź",
   "status.favourite": "Ulubione",
   "status.load_more": "Załaduj więcej",
   "status.media_hidden": "Zawartość multimedialna ukryta",
   "status.mention": "Wspomnij o @{name}",
   "status.mute_conversation": "Wycisz konwersację",
   "status.open": "Rozszerz ten status",
+  "status.pin": "Przypnij do profilu",
   "status.reblog": "Podbij",
   "status.reblogged_by": "{name} podbił",
   "status.reply": "Odpowiedz",
@@ -179,6 +183,7 @@
   "status.show_less": "Pokaż mniej",
   "status.show_more": "Pokaż więcej",
   "status.unmute_conversation": "Cofnij wyciszenie konwersacji",
+  "status.unpin": "Odepnij z profilu",
   "tabs_bar.compose": "Napisz",
   "tabs_bar.federated_timeline": "Globalne",
   "tabs_bar.home": "Strona główna",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 55d2f05de..e861bf73f 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -1,68 +1,70 @@
 {
   "account.block": "Bloquear @{name}",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "Esconder tudo de {domain}",
+  "account.disclaimer_full": "As informações abaixo podem refletir o perfil do usuário de maneira incompleta.",
   "account.edit_profile": "Editar perfil",
   "account.follow": "Seguir",
   "account.followers": "Seguidores",
   "account.follows": "Segue",
-  "account.follows_you": "É teu seguidor",
-  "account.media": "Media",
+  "account.follows_you": "É seu seguidor",
+  "account.media": "Mídia",
   "account.mention": "Mencionar @{name}",
   "account.mute": "Silenciar @{name}",
   "account.posts": "Posts",
   "account.report": "Denunciar @{name}",
-  "account.requested": "A aguardar aprovação",
-  "account.share": "Share @{name}'s profile",
+  "account.requested": "Aguardando aprovação",
+  "account.share": "Compartilhar perfil de @{name}",
   "account.unblock": "Não bloquear @{name}",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "Desbloquear {domain}",
   "account.unfollow": "Deixar de seguir",
   "account.unmute": "Não silenciar @{name}",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "Ver perfil completo",
   "boost_modal.combo": "Pode clicar {combo} para não voltar a ver",
   "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
+  "bundle_column_error.retry": "Tente novamente",
   "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
+  "bundle_modal_error.close": "Fechar",
   "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
-  "column.blocks": "Utilizadores Bloqueados",
+  "bundle_modal_error.retry": "Tente novamente",
+  "column.blocks": "Usuários bloqueados",
   "column.community": "Local",
   "column.favourites": "Favoritos",
-  "column.follow_requests": "Seguidores Pendentes",
-  "column.home": "Home",
-  "column.mutes": "Utilizadores silenciados",
+  "column.follow_requests": "Seguidores pendentes",
+  "column.home": "Página inicial",
+  "column.mutes": "Usuários silenciados",
   "column.notifications": "Notificações",
   "column.public": "Global",
   "column_back_button.label": "Voltar",
-  "column_header.hide_settings": "Hide settings",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
-  "column_header.pin": "Pin",
-  "column_header.show_settings": "Show settings",
-  "column_header.unpin": "Unpin",
-  "column_subheading.navigation": "Navigation",
-  "column_subheading.settings": "Settings",
-  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "column_header.hide_settings": "Esconder configurações",
+  "column_header.moveLeft_settings": "Mover coluna para a esquerda",
+  "column_header.moveRight_settings": "Mover coluna para a direita",
+  "column_header.pin": "Fixar",
+  "column_header.show_settings": "Mostrar configurações",
+  "column_header.unpin": "Desafixar",
+  "column_subheading.navigation": "Navegação",
+  "column_subheading.settings": "Configurações",
+  "compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar as suas postagens só para seguidores.",
   "compose_form.lock_disclaimer.lock": "locked",
-  "compose_form.placeholder": "Em que estás a pensar?",
-  "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.",
+  "compose_form.placeholder": "No que você está pensando?",
+  "compose_form.privacy_disclaimer": "O seu conteúdo privado será compartilhado com os usuários do {domains}. Você confia {domainsCount, plural, one {neste servidor} other {nestes servidores}}? As configurações de privacidade só funcionam em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não há como garantir a privacidade de suas postagens, e elas podem ser compartilhadas com outros.",
   "compose_form.publish": "Publicar",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive": "Marcar media como conteúdo sensível",
+  "compose_form.sensitive": "Marcar mídia como conteúdo sensível",
   "compose_form.spoiler": "Esconder texto com aviso",
   "compose_form.spoiler_placeholder": "Aviso de conteúdo",
-  "confirmation_modal.cancel": "Cancel",
-  "confirmations.block.confirm": "Block",
-  "confirmations.block.message": "Are you sure you want to block {name}?",
-  "confirmations.delete.confirm": "Delete",
-  "confirmations.delete.message": "Are you sure you want to delete this status?",
-  "confirmations.domain_block.confirm": "Hide entire domain",
-  "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
-  "confirmations.mute.confirm": "Mute",
-  "confirmations.mute.message": "Are you sure you want to mute {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmation_modal.cancel": "Cancelar",
+  "confirmations.block.confirm": "Bloquear",
+  "confirmations.block.message": "Você tem certeza de que quer bloquear {name}?",
+  "confirmations.delete.confirm": "Excluir",
+  "confirmations.delete.message": "Você tem certeza de que quer excluir este status?",
+  "confirmations.domain_block.confirm": "Esconder o domínio inteiro",
+  "confirmations.domain_block.message": "Você quer mesmo bloquear {domain} inteiro? Na maioria dos casos, silenciar ou bloquear alguns usuários é o suficiente e o recomendado.",
+  "confirmations.mute.confirm": "Silenciar",
+  "confirmations.mute.message": "Você tem certeza de que quer silenciar {name}?",
+  "confirmations.unfollow.confirm": "Deixar de seguir",
+  "confirmations.unfollow.message": "Você tem certeza de que quer deixar de seguir {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Eliminar",
+  "status.embed": "Embed",
   "status.favourite": "Adicionar aos favoritos",
   "status.load_more": "Carregar mais",
   "status.media_hidden": "Media escondida",
   "status.mention": "Mencionar @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir",
+  "status.pin": "Pin on profile",
   "status.reblog": "Partilhar",
   "status.reblogged_by": "{name} partilhou",
   "status.reply": "Responder",
@@ -179,6 +183,7 @@
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Criar",
   "tabs_bar.federated_timeline": "Global",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 55d2f05de..f9e686411 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Eliminar",
+  "status.embed": "Embed",
   "status.favourite": "Adicionar aos favoritos",
   "status.load_more": "Carregar mais",
   "status.media_hidden": "Media escondida",
   "status.mention": "Mencionar @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir",
+  "status.pin": "Pin on profile",
   "status.reblog": "Partilhar",
   "status.reblogged_by": "{name} partilhou",
   "status.reply": "Responder",
@@ -179,6 +183,7 @@
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Criar",
   "tabs_bar.federated_timeline": "Global",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 1abfb4370..0f78f4b17 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -1,7 +1,7 @@
 {
   "account.block": "Блокировать",
   "account.block_domain": "Блокировать все с {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.disclaimer_full": "Нижеуказанная информация может не полностью отражать профиль пользователя.",
   "account.edit_profile": "Изменить профиль",
   "account.follow": "Подписаться",
   "account.followers": "Подписаны",
@@ -13,19 +13,19 @@
   "account.posts": "Посты",
   "account.report": "Пожаловаться",
   "account.requested": "Ожидает подтверждения",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "Поделиться профилем @{name}",
   "account.unblock": "Разблокировать",
   "account.unblock_domain": "Разблокировать {domain}",
   "account.unfollow": "Отписаться",
   "account.unmute": "Снять глушение",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "Показать полный профиль",
   "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
+  "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.",
+  "bundle_column_error.retry": "Попробовать снова",
+  "bundle_column_error.title": "Ошибка сети",
+  "bundle_modal_error.close": "Закрыть",
+  "bundle_modal_error.message": "Что-то пошло не так при загрузке этого компонента.",
+  "bundle_modal_error.retry": "Попробовать снова",
   "column.blocks": "Список блокировки",
   "column.community": "Локальная лента",
   "column.favourites": "Понравившееся",
@@ -35,11 +35,11 @@
   "column.notifications": "Уведомления",
   "column.public": "Глобальная лента",
   "column_back_button.label": "Назад",
-  "column_header.hide_settings": "Hide settings",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
+  "column_header.hide_settings": "Скрыть настройки",
+  "column_header.moveLeft_settings": "Передвинуть колонку влево",
+  "column_header.moveRight_settings": "Передвинуть колонку вправо",
   "column_header.pin": "Закрепить",
-  "column_header.show_settings": "Show settings",
+  "column_header.show_settings": "Показать настройки",
   "column_header.unpin": "Открепить",
   "column_subheading.navigation": "Навигация",
   "column_subheading.settings": "Настройки",
@@ -61,8 +61,10 @@
   "confirmations.domain_block.message": "Вы на самом деле уверены, что хотите блокировать весь {domain}? В большинстве случаев нескольких отдельных блокировок или глушений достаточно.",
   "confirmations.mute.confirm": "Заглушить",
   "confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmations.unfollow.confirm": "Отписаться",
+  "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Занятия",
   "emoji_button.flags": "Флаги",
   "emoji_button.food": "Еда и напитки",
@@ -94,8 +96,8 @@
   "home.column_settings.show_replies": "Показывать ответы",
   "home.settings": "Настройки колонки",
   "lightbox.close": "Закрыть",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "Далее",
+  "lightbox.previous": "Назад",
   "loading_indicator.label": "Загрузка...",
   "media_gallery.toggle_visible": "Показать/скрыть",
   "missing_indicator.label": "Не найдено",
@@ -119,8 +121,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.push": "Push-уведомления",
+  "notifications.column_settings.push_meta": "Это устройство",
   "notifications.column_settings.reblog": "Продвижения:",
   "notifications.column_settings.show": "Показывать в колонке",
   "notifications.column_settings.sound": "Проигрывать звук",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Этот статус не может быть продвинут",
   "status.delete": "Удалить",
+  "status.embed": "Embed",
   "status.favourite": "Нравится",
   "status.load_more": "Показать еще",
   "status.media_hidden": "Медиаконтент скрыт",
   "status.mention": "Упомянуть @{name}",
   "status.mute_conversation": "Заглушить тред",
   "status.open": "Развернуть статус",
+  "status.pin": "Pin on profile",
   "status.reblog": "Продвинуть",
   "status.reblogged_by": "{name} продвинул(а)",
   "status.reply": "Ответить",
@@ -179,6 +183,7 @@
   "status.show_less": "Свернуть",
   "status.show_more": "Развернуть",
   "status.unmute_conversation": "Снять глушение с треда",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Написать",
   "tabs_bar.federated_timeline": "Глобальная",
   "tabs_bar.home": "Главная",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index aa0929f82..069fdf7c3 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
   "emoji_button.flags": "Flags",
   "emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Delete",
+  "status.embed": "Embed",
   "status.favourite": "Favourite",
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mention @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boosted",
   "status.reply": "Reply",
@@ -179,6 +183,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compose",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index 37ce8597e..8a36bd207 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "{name} kullanıcısını sessize almak istiyor musunuz?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Aktivite",
   "emoji_button.flags": "Bayraklar",
   "emoji_button.food": "Yiyecek ve İçecek",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Bu gönderi boost edilemez",
   "status.delete": "Sil",
+  "status.embed": "Embed",
   "status.favourite": "Favorilere ekle",
   "status.load_more": "Daha fazla",
   "status.media_hidden": "Gizli görsel",
   "status.mention": "Bahset @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Bu gönderiyi genişlet",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost'la",
   "status.reblogged_by": "{name} boost etti",
   "status.reply": "Cevapla",
@@ -179,6 +183,7 @@
   "status.show_less": "Daha azı",
   "status.show_more": "Daha fazlası",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Oluştur",
   "tabs_bar.federated_timeline": "Federe",
   "tabs_bar.home": "Ana sayfa",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index fea7bd94e..1d06218e6 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "Ви впевнені, що хочете заглушити {name}?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Заняття",
   "emoji_button.flags": "Прапори",
   "emoji_button.food": "Їжа та напої",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Цей допис не може бути передмухнутий",
   "status.delete": "Видалити",
+  "status.embed": "Embed",
   "status.favourite": "Подобається",
   "status.load_more": "Завантажити більше",
   "status.media_hidden": "Медіаконтент приховано",
   "status.mention": "Згадати",
   "status.mute_conversation": "Заглушити діалог",
   "status.open": "Розгорнути допис",
+  "status.pin": "Pin on profile",
   "status.reblog": "Передмухнути",
   "status.reblogged_by": "{name} передмухнув(-ла)",
   "status.reply": "Відповісти",
@@ -179,6 +183,7 @@
   "status.show_less": "Згорнути",
   "status.show_more": "Розгорнути",
   "status.unmute_conversation": "Зняти глушення з діалогу",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Написати",
   "tabs_bar.federated_timeline": "Глобальна",
   "tabs_bar.home": "Головна",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index d0c4b3d1b..93faf8876 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -5,7 +5,7 @@
   "account.edit_profile": "修改个人资料",
   "account.follow": "关注",
   "account.followers": "关注者",
-  "account.follows": "正关注",
+  "account.follows": "正在关注",
   "account.follows_you": "关注你",
   "account.media": "Media",
   "account.mention": "提及 @{name}",
@@ -13,19 +13,19 @@
   "account.posts": "嘟文",
   "account.report": "举报 @{name}",
   "account.requested": "等待审批",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "分享 @{name}的个人资料",
   "account.unblock": "解除对 @{name} 的屏蔽",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "解除封锁 {domain}",
   "account.unfollow": "取消关注",
   "account.unmute": "取消 @{name} 的静音",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "查看完整资料",
   "boost_modal.combo": "如你想在下次路过时显示,请按{combo},",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
+  "bundle_column_error.body": "载入组件出错。",
+  "bundle_column_error.retry": "再次尝试",
+  "bundle_column_error.title": "网络错误",
+  "bundle_modal_error.close": "关闭",
+  "bundle_modal_error.message": "载入组件出错。",
+  "bundle_modal_error.retry": "再次尝试",
   "column.blocks": "屏蔽用户",
   "column.community": "本站时间轴",
   "column.favourites": "赞过的嘟文",
@@ -34,7 +34,7 @@
   "column.mutes": "被静音的用户",
   "column.notifications": "通知",
   "column.public": "跨站公共时间轴",
-  "column_back_button.label": "Back",
+  "column_back_button.label": "返回",
   "column_header.hide_settings": "Hide settings",
   "column_header.moveLeft_settings": "Move column to the left",
   "column_header.moveRight_settings": "Move column to the right",
@@ -61,8 +61,10 @@
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
   "confirmations.mute.confirm": "静音",
   "confirmations.mute.message": "想好了,真的要静音 {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmations.unfollow.confirm": "取消关注",
+  "confirmations.unfollow.message": "确定要取消关注 {name}吗?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "活动",
   "emoji_button.flags": "旗帜",
   "emoji_button.food": "食物和饮料",
@@ -86,7 +88,7 @@
   "getting_started.faq": "FAQ",
   "getting_started.heading": "开始使用",
   "getting_started.open_source_notice": "Mastodon 是一个开放源码的软件。你可以在官方 GitHub ({github}) 贡献或者回报问题。",
-  "getting_started.userguide": "User Guide",
+  "getting_started.userguide": "用户指南",
   "home.column_settings.advanced": "高端",
   "home.column_settings.basic": "基本",
   "home.column_settings.filter_regex": "使用正则表达式 (regex) 过滤",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "没法转嘟这条嘟文啦……",
   "status.delete": "删除",
+  "status.embed": "Embed",
   "status.favourite": "赞",
   "status.load_more": "加载更多",
   "status.media_hidden": "隐藏媒体内容",
   "status.mention": "提及 @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "展开嘟文",
+  "status.pin": "Pin on profile",
   "status.reblog": "转嘟",
   "status.reblogged_by": "{name} 转嘟",
   "status.reply": "回应",
@@ -179,6 +183,7 @@
   "status.show_less": "减少显示",
   "status.show_more": "显示更多",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "撰写",
   "tabs_bar.federated_timeline": "跨站",
   "tabs_bar.home": "主页",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 7312aae82..d689cd5ae 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "你確定要將{name}靜音嗎?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "活動",
   "emoji_button.flags": "旗幟",
   "emoji_button.food": "飲飲食食",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "這篇文章無法被轉推",
   "status.delete": "刪除",
+  "status.embed": "Embed",
   "status.favourite": "喜歡",
   "status.load_more": "載入更多",
   "status.media_hidden": "隱藏媒體內容",
   "status.mention": "提及 @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "展開文章",
+  "status.pin": "Pin on profile",
   "status.reblog": "轉推",
   "status.reblogged_by": "{name} 轉推",
   "status.reply": "回應",
@@ -179,6 +183,7 @@
   "status.show_less": "減少顯示",
   "status.show_more": "顯示更多",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "撰寫",
   "tabs_bar.federated_timeline": "跨站",
   "tabs_bar.home": "主頁",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 1c2e35272..dcb9d7f3c 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -63,6 +63,8 @@
   "confirmations.mute.message": "你確定要消音 {name} ?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "活動",
   "emoji_button.flags": "旗幟",
   "emoji_button.food": "食物與飲料",
@@ -162,12 +164,14 @@
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "此貼文無法轉推",
   "status.delete": "刪除",
+  "status.embed": "Embed",
   "status.favourite": "喜愛",
   "status.load_more": "載入更多",
   "status.media_hidden": "媒體已隱藏",
   "status.mention": "提到 @{name}",
   "status.mute_conversation": "消音對話",
   "status.open": "展開這個狀態",
+  "status.pin": "Pin on profile",
   "status.reblog": "轉推",
   "status.reblogged_by": "{name} 轉推了",
   "status.reply": "回應",
@@ -179,6 +183,7 @@
   "status.show_less": "看少點",
   "status.show_more": "看更多",
   "status.unmute_conversation": "不消音對話",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "編輯",
   "tabs_bar.federated_timeline": "聯盟",
   "tabs_bar.home": "家",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 07207c93b..e7a3567b4 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -149,10 +149,20 @@ const privacyPreference = (a, b) => {
   }
 };
 
+const hydrate = (state, hydratedState) => {
+  state = clearAll(state.merge(hydratedState));
+
+  if (hydratedState.has('text')) {
+    state = state.set('text', hydratedState.get('text'));
+  }
+
+  return state;
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
-    return clearAll(state.merge(action.state.get('compose')));
+    return hydrate(state, action.state.get('compose'));
   case COMPOSE_MOUNT:
     return state.set('mounted', true);
   case COMPOSE_UNMOUNT:
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
index bbc973302..2ce27a454 100644
--- a/app/javascript/mastodon/reducers/status_lists.js
+++ b/app/javascript/mastodon/reducers/status_lists.js
@@ -3,6 +3,10 @@ import {
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+} from '../actions/interactions';
 
 const initialState = ImmutableMap({
   favourites: ImmutableMap({
@@ -27,12 +31,28 @@ const appendToList = (state, listType, statuses, next) => {
   }));
 };
 
+const prependOneToList = (state, listType, status) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('items', map.get('items').unshift(status.get('id')));
+  }));
+};
+
+const removeOneFromList = (state, listType, status) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('items', map.get('items').filter(item => item !== status.get('id')));
+  }));
+};
+
 export default function statusLists(state = initialState, action) {
   switch(action.type) {
   case FAVOURITED_STATUSES_FETCH_SUCCESS:
     return normalizeList(state, 'favourites', action.statuses, action.next);
   case FAVOURITED_STATUSES_EXPAND_SUCCESS:
     return appendToList(state, 'favourites', action.statuses, action.next);
+  case FAVOURITE_SUCCESS:
+    return prependOneToList(state, 'favourites', action.status);
+  case UNFAVOURITE_SUCCESS:
+    return removeOneFromList(state, 'favourites', action.status);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 3e40b0b42..38691dc43 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -7,6 +7,8 @@ import {
   FAVOURITE_SUCCESS,
   FAVOURITE_FAIL,
   UNFAVOURITE_SUCCESS,
+  PIN_SUCCESS,
+  UNPIN_SUCCESS,
 } from '../actions/interactions';
 import {
   STATUS_FETCH_SUCCESS,
@@ -114,6 +116,8 @@ export default function statuses(state = initialState, action) {
   case UNREBLOG_SUCCESS:
   case FAVOURITE_SUCCESS:
   case UNFAVOURITE_SUCCESS:
+  case PIN_SUCCESS:
+  case UNPIN_SUCCESS:
     return normalizeStatus(state, action.response);
   case FAVOURITE_REQUEST:
     return state.setIn([action.status.get('id'), 'favourited'], true);
diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js
index 44f95b17f..2af07e0fb 100644
--- a/app/javascript/mastodon/scroll.js
+++ b/app/javascript/mastodon/scroll.js
@@ -26,5 +26,5 @@ const scroll = (node, key, target) => {
   };
 };
 
-export const scrollRight = (node) => scroll(node, 'scrollLeft', node.scrollWidth);
+export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position);
 export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
index acb85f626..f63cff335 100644
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -31,8 +31,8 @@ const notify = options =>
       const group = cloneNotification(notifications[0]);
 
       group.title = formatGroupTitle(group.data.message, group.data.count + 1);
-      group.body = `${options.title}\n${group.body}`;
-      group.data = { ...group.data, count: group.data.count + 1 };
+      group.body  = `${options.title}\n${group.body}`;
+      group.data  = { ...group.data, count: group.data.count + 1 };
 
       return self.registration.showNotification(group.title, group);
     }
@@ -43,18 +43,18 @@ const notify = options =>
 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.body      = options.data.nsfw || options.data.content;
+  options.dir       = options.data.dir;
+  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.actions          = [expandAction];
+    options.hiddenActions    = options.data.actions.filter(action => action !== expandAction);
     options.data.hiddenImage = options.image;
-    options.image = undefined;
+    options.image            = undefined;
   } else {
     options.actions = options.data.actions;
   }
@@ -75,8 +75,8 @@ const cloneNotification = (notification) => {
 const expandNotification = (notification) => {
   const nextNotification = cloneNotification(notification);
 
-  nextNotification.body = notification.data.content;
-  nextNotification.image = notification.data.hiddenImage;
+  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);
@@ -105,8 +105,7 @@ const openUrl = url =>
       const webClients = clientList.filter(client => /\/web\//.test(client.url));
 
       if (webClients.length !== 0) {
-        const client = findBestClient(webClients);
-
+        const client       = findBestClient(webClients);
         const { pathname } = new URL(url);
 
         if (pathname.startsWith('/web/')) {
@@ -126,8 +125,7 @@ const openUrl = url =>
   });
 
 const removeActionFromNotification = (notification, action) => {
-  const actions = notification.actions.filter(act => act.action !== action.action);
-
+  const actions          = notification.actions.filter(act => act.action !== action.action);
   const nextNotification = cloneNotification(notification);
 
   nextNotification.actions = actions;
diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js
index 96ac63b52..3dbed09ea 100644
--- a/app/javascript/mastodon/web_push_subscription.js
+++ b/app/javascript/mastodon/web_push_subscription.js
@@ -48,7 +48,6 @@ export function register () {
 
   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;
     }
@@ -84,10 +83,8 @@ export function register () {
       })
       .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());
         }
 
@@ -103,7 +100,6 @@ export function register () {
         }
       });
   } else {
-    // eslint-disable-next-line no-console
     console.warn('Your browser does not support Web Push Notifications.');
   }
 }
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index e9bb4a42e..2058fad91 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -36,8 +36,20 @@ function main() {
 
     [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
       const datetime = new Date(content.getAttribute('datetime'));
+      content.title = dateTimeFormat.format(datetime);
       content.textContent = relativeFormat.format(datetime);
     });
+
+    [].forEach.call(document.querySelectorAll('.logo-button'), (content) => {
+      content.addEventListener('click', (e) => {
+        e.preventDefault();
+        window.open(e.target.href, 'mastodon-intent', 'width=400,height=400,resizable=no,menubar=no,status=no,scrollbars=yes');
+      });
+    });
+
+    if (window.parent) {
+      window.parent.postMessage(['setHeight', document.getElementsByTagName('html')[0].scrollHeight], '*');
+    }
   });
 
   delegate(document, '.video-player video', 'click', ({ target }) => {
diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js
new file mode 100644
index 000000000..51e4ae38b
--- /dev/null
+++ b/app/javascript/packs/share.js
@@ -0,0 +1,24 @@
+import loadPolyfills from '../mastodon/load_polyfills';
+
+require.context('../images/', true);
+
+function loaded() {
+  const ComposeContainer = require('../mastodon/containers/compose_container').default;
+  const React = require('react');
+  const ReactDOM = require('react-dom');
+  const mountNode = document.getElementById('mastodon-compose');
+
+  if (mountNode !== null) {
+    const props = JSON.parse(mountNode.getAttribute('data-props'));
+    ReactDOM.render(<ComposeContainer {...props} />, mountNode);
+  }
+}
+
+function main() {
+  const ready = require('../mastodon/ready').default;
+  ready(loaded);
+}
+
+loadPolyfills().then(main).catch(error => {
+  console.error(error);
+});
diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss
index 66da44086..28924738a 100644
--- a/app/javascript/styles/about.scss
+++ b/app/javascript/styles/about.scss
@@ -1,52 +1,96 @@
-.about-body {
-  .wrapper {
-    max-width: 600px;
-    margin: 0 auto;
+.landing-page {
+  p,
+  li {
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
+    line-height: 30px;
+    margin-bottom: 12px;
     color: $ui-primary-color;
-    padding-top: 50px;
-    padding-bottom: 50px;
 
-    &.thicc {
-      max-width: 800px;
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
     }
   }
 
+  em {
+    display: inline;
+    margin: 0;
+    padding: 0;
+    font-weight: 500;
+    background: transparent;
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+    color: lighten($ui-primary-color, 10%);
+  }
+
   h1 {
-    font: 46px/52px 'mastodon-font-sans-serif', sans-serif;
-    font-weight: 600;
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 26px;
+    line-height: 30px;
+    font-weight: 500;
     margin-bottom: 20px;
-    color: $ui-highlight-color;
-    padding: 20px 0;
+    color: $ui-secondary-color;
 
-    img {
-      margin-bottom: -5px;
-      margin-right: 5px;
-      width: 46px;
-      height: 46px;
+    small {
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      display: block;
+      font-size: 18px;
+      font-weight: 400;
+      color: $ui-base-lighter-color;
     }
   }
 
   h2 {
     font-family: 'mastodon-font-display', sans-serif;
-    font-size: 24px;
-    line-height: 28px;
-    font-weight: 400;
+    font-size: 22px;
+    line-height: 26px;
+    font-weight: 500;
     margin-bottom: 20px;
-    color: $primary-text-color;
+    color: $ui-secondary-color;
   }
 
   h3 {
     font-family: 'mastodon-font-display', sans-serif;
-    font-size: 20px;
-    line-height: 28px;
-    font-weight: 400;
+    font-size: 18px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h4 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 16px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h5 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 14px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $ui-secondary-color;
+  }
+
+  h6 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 12px;
+    line-height: 24px;
+    font-weight: 500;
     margin-bottom: 20px;
     color: $ui-secondary-color;
   }
 
   ul,
   ol {
-    list-style: inherit;
     margin-left: 20px;
 
     &[type='a'] {
@@ -58,219 +102,29 @@
     }
   }
 
-  li > ol,
-  li > ul {
-    margin-top: 20px;
-  }
-
-  p,
-  li {
-    font: 16px/28px 'mastodon-font-sans-serif', sans-serif;
-    font-weight: 400;
-    margin-bottom: 12px;
-
-    a {
-      color: $ui-highlight-color;
-      text-decoration: underline;
-    }
-  }
-
-  em {
-    display: inline-block;
-    padding: 7px 7px 5px;
-    margin: 0 2px;
-    background: $ui-primary-color;
-    color: $ui-base-color;
-    font: 16px/16px 'mastodon-font-sans-serif', sans-serif;
-    font-weight: 300;
-  }
-
-  .screenshot {
-    box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
-    margin-bottom: 26px;
-
-    img {
-      max-width: 100%;
-      height: auto;
-      display: block;
-    }
+  ul {
+    list-style: disc;
   }
 
-  .actions {
-    overflow: hidden;
-    margin-bottom: 20px;
-
-    .info {
-      float: right;
-      text-align: right;
-      line-height: 36px;
-
-      a {
-        color: $ui-primary-color;
-        text-decoration: underline;
-      }
-    }
+  ol {
+    list-style: decimal;
   }
 
-  @media screen and (max-width: 625px) {
-    .wrapper {
-      padding: 20px;
-    }
+  li > ol,
+  li > ul {
+    margin-top: 6px;
   }
-}
-
-.information-board {
-  background: darken($ui-base-color, 4%);
-  padding: 20px 0;
-
-  .panel {
-    position: absolute;
-    width: 280px;
-    box-sizing: border-box;
-    background: darken($ui-base-color, 8%);
-    padding: 20px;
-    padding-top: 10px;
-    border-radius: 4px 4px 0 0;
-    right: 0;
-    bottom: -40px;
-
-    .panel-header {
-      font-family: 'mastodon-font-display', sans-serif;
-      font-size: 14px;
-      line-height: 24px;
-      font-weight: 500;
-      color: $ui-base-lighter-color;
-      padding-bottom: 5px;
-      margin-bottom: 15px;
-      border-bottom: 1px solid lighten($ui-base-color, 4%);
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      overflow: hidden;
-
-      a,
-      span {
-        font-weight: 400;
-        color: lighten($ui-base-color, 34%);
-      }
 
-      a {
-        text-decoration: none;
-      }
-    }
+  hr {
+    border-color: rgba($ui-base-lighter-color, .6);
   }
 
   .container {
-    position: relative;
-    padding-right: 280px + 15px;
-  }
-
-  .information-board-sections {
-    display: flex;
-    justify-content: space-between;
-    flex-wrap: wrap;
-  }
-
-  .section {
-    flex: 1 0 0;
-    font: 16px/28px 'mastodon-font-sans-serif', sans-serif;
-    text-align: right;
-    padding: 10px 15px;
-
-    span,
-    strong {
-      display: block;
-    }
-
-    span {
-      font-size: 16px;
-
-      &:last-child {
-        color: $ui-secondary-color;
-      }
-    }
-
-    strong {
-      font-weight: 500;
-      font-size: 32px;
-      line-height: 48px;
-      color: $primary-text-color;
-    }
-  }
-}
-
-.owner {
-  text-align: center;
-
-  .avatar {
-    @include avatar-size(80px);
+    width: 100%;
+    box-sizing: border-box;
+    max-width: 800px;
     margin: 0 auto;
-    margin-bottom: 15px;
-
-    img {
-      @include avatar-radius();
-      @include avatar-size(80px);
-      display: block;
-    }
-  }
-
-  .name {
-    font-size: 14px;
-
-    a {
-      display: block;
-      color: $primary-text-color;
-      text-decoration: none;
-
-      &:hover {
-        .display_name {
-          text-decoration: underline;
-        }
-      }
-    }
-
-    .username {
-      display: block;
-      color: $ui-primary-color;
-    }
-  }
-}
-
-.features-list__row {
-  display: flex;
-  padding: 10px 0;
-  justify-content: space-between;
-
-  &:first-child {
-    padding-top: 0;
-  }
-
-  .visual {
-    flex: 0 0 auto;
-    display: flex;
-    align-items: center;
-    margin-left: 15px;
-
-    .fa {
-      display: block;
-      color: $ui-primary-color;
-      font-size: 48px;
-    }
-  }
-
-  .text {
-    font-size: 16px;
-    line-height: 30px;
-    color: $ui-base-lighter-color;
-
-    h6 {
-      font-weight: 500;
-      color: $ui-primary-color;
-    }
   }
-}
-
-.landing-page {
-  $lp-par-color: lighten($ui-base-color, 36%);
 
   .header-wrapper {
     padding-top: 15px;
@@ -284,13 +138,16 @@
 
       .hero .heading {
         padding-bottom: 30px;
+        font-family: 'mastodon-font-sans-serif', sans-serif;
+        font-size: 16px;
+        font-weight: 400;
+        font-size: 16px;
+        line-height: 30px;
+        color: $ui-primary-color;
 
-        p, li {
-          color: lighten($ui-base-color, 50%);
-        }
-
-        li {
-          margin: 2px 0;
+        a {
+          color: $ui-highlight-color;
+          text-decoration: underline;
         }
       }
     }
@@ -315,17 +172,6 @@
     }
   }
 
-  p,
-  li {
-    font: inherit;
-    font-weight: inherit;
-    margin-bottom: 0;
-  }
-
-  hr {
-    border-color: rgba($ui-base-lighter-color, .6);
-  }
-
   .header {
     line-height: 30px;
     overflow: hidden;
@@ -335,6 +181,62 @@
       justify-content: space-between;
     }
 
+    .links {
+      position: relative;
+      z-index: 4;
+
+      a {
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        color: $ui-primary-color;
+        text-decoration: none;
+        padding: 12px 16px;
+        line-height: 32px;
+        font-family: 'mastodon-font-display', sans-serif;
+        font-weight: 500;
+        font-size: 14px;
+
+        &:hover {
+          color: $ui-secondary-color;
+        }
+      }
+
+      .brand {
+        a {
+          padding-left: 0;
+          padding-right: 0;
+          color: $white;
+        }
+
+        img {
+          height: 32px;
+          position: relative;
+          top: 4px;
+          left: -10px;
+        }
+      }
+
+      ul {
+        list-style: none;
+        margin: 0;
+
+        li {
+          display: inline-block;
+          vertical-align: bottom;
+          margin: 0;
+
+          &:first-child a {
+            padding-left: 0;
+          }
+
+          &:last-child a {
+            padding-right: 0;
+          }
+        }
+      }
+    }
+
     .hero {
       margin-top: 50px;
       align-items: center;
@@ -387,6 +289,12 @@
         }
       }
 
+      .heading {
+        position: relative;
+        z-index: 4;
+        padding-bottom: 150px;
+      }
+
       .simple_form,
       .closed-registrations-message {
         background: darken($ui-base-color, 4%);
@@ -408,12 +316,6 @@
         }
       }
 
-      .heading {
-        position: relative;
-        z-index: 4;
-        padding-bottom: 150px;
-      }
-
       .closed-registrations-message {
         min-height: 330px;
         display: flex;
@@ -421,136 +323,140 @@
         justify-content: space-between;
       }
     }
+  }
 
-    .links {
+  .about-short {
+    background: darken($ui-base-color, 4%);
+    padding: 50px 0;
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
+    line-height: 30px;
+    color: $ui-primary-color;
+
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
+    }
+  }
+
+  .information-board {
+    background: darken($ui-base-color, 4%);
+    padding: 20px 0;
+
+    .container {
       position: relative;
-      z-index: 4;
+      padding-right: 280px + 15px;
+    }
 
-      ul {
-        list-style: none;
-        margin: 0;
+    .information-board-sections {
+      display: flex;
+      justify-content: space-between;
+      flex-wrap: wrap;
+    }
 
-        li {
-          display: inline-block;
-          vertical-align: bottom;
-          margin: 0;
+    .section {
+      flex: 1 0 0;
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      font-size: 16px;
+      line-height: 28px;
+      color: $primary-text-color;
+      text-align: right;
+      padding: 10px 15px;
 
-          &:last-child a {
-            padding-right: 0;
-          }
-        }
+      span,
+      strong {
+        display: block;
       }
 
-      a {
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        color: $ui-primary-color;
-        text-decoration: none;
-        padding: 12px 16px;
-        line-height: 32px;
-        font-family: 'mastodon-font-display', sans-serif;
-        font-weight: 500;
-        font-size: 14px;
-
-        &:hover {
+      span {
+        &:last-child {
           color: $ui-secondary-color;
         }
       }
 
-      .brand {
-        a {
-          padding-left: 0;
-          padding-right: 0;
-          color: $white;
-        }
-
-        img {
-          height: 32px;
-          position: relative;
-          top: 4px;
-          left: -10px;
-        }
+      strong {
+        font-weight: 500;
+        font-size: 32px;
+        line-height: 48px;
       }
     }
-  }
-
-  .container {
-    width: 100%;
-    box-sizing: border-box;
-    max-width: 800px;
-    margin: 0 auto;
-  }
-
-  .wrapper {
-    max-width: 800px;
-    margin: 0 auto;
-    padding: 0;
-  }
-
-  .learn-more-cta, .extended-description {
-    padding: 50px 0;
-    font-weight: 400;
-    color: $lp-par-color;
-    font: 16px/1.6 'mastodon-font-sans-serif', sans-serif;
 
-    ul,
-    ol {
-      list-style: inherit;
-      margin-left: 20px;
+    .panel {
+      position: absolute;
+      width: 280px;
+      box-sizing: border-box;
+      background: darken($ui-base-color, 8%);
+      padding: 20px;
+      padding-top: 10px;
+      border-radius: 4px 4px 0 0;
+      right: 0;
+      bottom: -40px;
 
-      &[type='a'] {
-        list-style-type: lower-alpha;
-      }
+      .panel-header {
+        font-family: 'mastodon-font-display', sans-serif;
+        font-size: 14px;
+        line-height: 24px;
+        font-weight: 500;
+        color: $ui-primary-color;
+        padding-bottom: 5px;
+        margin-bottom: 15px;
+        border-bottom: 1px solid lighten($ui-base-color, 4%);
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        overflow: hidden;
+
+        a,
+        span {
+          font-weight: 400;
+          color: darken($ui-primary-color, 10%);
+        }
 
-      &[type='i'] {
-        list-style-type: lower-roman;
+        a {
+          text-decoration: none;
+        }
       }
     }
 
-    li > ol,
-    li > ul {
-      margin-top: 20px;
-    }
+    .owner {
+      text-align: center;
 
-    p,
-    li {
-      color: $lp-par-color;
-      margin-bottom: 6px;
+      .avatar {
+        width: 80px;
+        height: 80px;
+        margin: 0 auto;
+        margin-bottom: 15px;
 
-      a {
-        color: $ui-highlight-color;
-        text-decoration: underline;
+        img {
+          display: block;
+          width: 80px;
+          height: 80px;
+          border-radius: 48px;
+        }
       }
-    }
-
-    li {
-      margin: 2px 0;
-    }
-  }
 
-  .learn-more-cta {
-    background: darken($ui-base-color, 4%);
-    padding: 50px 0;
-    p {
-      font-size: 16px;
-      line-height: 28px;
-    }
-  }
+      .name {
+        font-size: 14px;
 
-  h3 {
-    font-family: 'mastodon-font-display', sans-serif;
-    font-size: 16px;
-    line-height: 24px;
-    font-weight: 500;
-    margin-bottom: 20px;
-    color: $ui-primary-color;
-  }
+        a {
+          display: block;
+          color: $primary-text-color;
+          text-decoration: none;
+
+          &:hover {
+            .display_name {
+              text-decoration: underline;
+            }
+          }
+        }
 
-  p {
-    font-size: 16px;
-    line-height: 28px;
-    color: $lp-par-color;
+        .username {
+          display: block;
+          color: $ui-primary-color;
+        }
+      }
+    }
   }
 
   .features {
@@ -559,100 +465,121 @@
     .container {
       display: flex;
     }
-  }
 
-  #mastodon-timeline {
-    display: flex;
-    -webkit-overflow-scrolling: touch;
-    -ms-overflow-style: -ms-autohiding-scrollbar;
-    font-family: 'mastodon-font-sans-serif', sans-serif;
-    font-size: 13px;
-    line-height: 18px;
-    font-weight: 400;
-    color: $primary-text-color;
-    width: 330px;
-    margin-right: 30px;
-    flex: 0 0 auto;
-    background: $ui-base-color;
-    overflow: hidden;
-    box-shadow: 0 0 6px rgba($black, 0.1);
+    #mastodon-timeline {
+      display: flex;
+      -webkit-overflow-scrolling: touch;
+      -ms-overflow-style: -ms-autohiding-scrollbar;
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      font-size: 13px;
+      line-height: 18px;
+      font-weight: 400;
+      color: $primary-text-color;
+      width: 330px;
+      margin-right: 30px;
+      flex: 0 0 auto;
+      background: $ui-base-color;
+      overflow: hidden;
+      box-shadow: 0 0 6px rgba($black, 0.1);
+
+      .column-header {
+        color: inherit;
+        font-family: inherit;
+        font-size: 16px;
+        line-height: inherit;
+        font-weight: inherit;
+        margin: 0;
+        padding: 15px;
+      }
 
-    .column-header {
-      color: inherit;
-      font-family: inherit;
-      font-size: 16px;
-      line-height: inherit;
-      font-weight: inherit;
-      margin: 0;
-      padding: 15px;
-    }
+      .column {
+        padding: 0;
+        border-radius: 4px;
+        overflow: hidden;
+      }
 
-    .column {
-      padding: 0;
-      border-radius: 4px;
-      overflow: hidden;
-    }
+      .scrollable {
+        height: 400px;
+      }
 
-    .scrollable {
-      height: 400px;
-    }
+      p {
+        font-size: inherit;
+        line-height: inherit;
+        font-weight: inherit;
+        color: $primary-text-color;
+        margin-bottom: 20px;
 
-    p {
-      font-size: inherit;
-      line-height: inherit;
-      font-weight: inherit;
-      color: $primary-text-color;
-      margin-bottom: 20px;
+        &:last-child {
+          margin-bottom: 0;
+        }
 
-      &:last-child {
-        margin-bottom: 0;
+        a {
+          color: $ui-secondary-color;
+          text-decoration: none;
+        }
       }
+    }
 
-      a {
-        color: $ui-secondary-color;
-        text-decoration: none;
+    .about-mastodon {
+      max-width: 675px;
+
+      p {
+        margin-bottom: 20px;
       }
-    }
-  }
 
-  .about-mastodon {
-    max-width: 675px;
+      .features-list {
+        margin-top: 20px;
 
-    p {
-      margin-bottom: 20px;
-    }
+        .features-list__row {
+          display: flex;
+          padding: 10px 0;
+          justify-content: space-between;
 
-    .features-list {
-      margin-top: 20px;
-    }
-  }
+          &:first-child {
+            padding-top: 0;
+          }
 
-  em {
-    display: inline;
-    margin: 0;
-    padding: 0;
-    font-weight: 500;
-    background: transparent;
-    font-family: inherit;
-    font-size: inherit;
-    line-height: inherit;
-    color: $ui-primary-color;
+          .visual {
+            flex: 0 0 auto;
+            display: flex;
+            align-items: center;
+            margin-left: 15px;
+
+            .fa {
+              display: block;
+              color: $ui-primary-color;
+              font-size: 48px;
+            }
+          }
+
+          .text {
+            font-size: 16px;
+            line-height: 30px;
+            color: $ui-primary-color;
+
+            h6 {
+              font-size: inherit;
+              line-height: inherit;
+              margin-bottom: 0;
+            }
+          }
+        }
+      }
+    }
   }
 
-  h1 {
-    font-family: 'mastodon-font-display', sans-serif;
-    font-size: 26px;
+  .extended-description {
+    padding: 50px 0;
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
     line-height: 30px;
-    margin-bottom: 0;
-    font-weight: 500;
-    color: $ui-secondary-color;
+    color: $ui-primary-color;
 
-    small {
-      font-family: 'mastodon-font-sans-serif', sans-serif;
-      display: block;
-      font-size: 18px;
-      font-weight: 400;
-      color: $ui-base-lighter-color;
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
     }
   }
 
@@ -676,8 +603,15 @@
       padding: 0 20px;
     }
 
-    .information-board .container {
-      padding-right: 20px;
+    .information-board {
+
+      .container {
+        padding-right: 20px;
+      }
+
+      .section {
+        text-align: center;
+      }
 
       .panel {
         position: static;
@@ -691,10 +625,6 @@
       }
     }
 
-    .information-board .section {
-      text-align: center;
-    }
-
     .header-wrapper .mascot {
       left: 20px;
     }
@@ -712,6 +642,7 @@
 
       &.compact .hero .heading {
         padding-bottom: 20px;
+        text-align: initial;
       }
     }
 
@@ -720,51 +651,41 @@
       display: block;
     }
 
-    .links {
-      padding-top: 15px;
-      background: darken($ui-base-color, 4%);
-    }
-
     .header {
 
-      .hero {
-        margin-top: 30px;
-        padding: 0;
+      .links {
+        padding-top: 15px;
+        background: darken($ui-base-color, 4%);
 
-        .heading {
-          padding: 0 20px 20px;
+        a {
+          padding: 12px 8px;
         }
-      }
-
-      .floats {
-        display: none;
-      }
 
-      .heading,
-      .nav {
-        text-align: center;
-      }
+        .nav {
+          display: flex;
+          flex-flow: row wrap;
+          justify-content: space-around;
+        }
 
-      .nav {
-        display: flex;
-        flex-flow: row wrap;
-        justify-content: space-around;
+        .brand img {
+          left: 0;
+          top: 0;
+        }
       }
 
-      .links a {
-        padding: 12px 8px;
-      }
+      .hero {
+        margin-top: 30px;
+        padding: 0;
 
-      .heading h1 {
-        padding: 30px 0;
-      }
+        .floats {
+          display: none;
+        }
 
-      .links .brand img {
-        left: 0;
-        top: 0;
-      }
+        .heading {
+          padding: 30px 20px;
+          text-align: center;
+        }
 
-      .hero {
         .simple_form,
         .closed-registrations-message {
           background: darken($ui-base-color, 8%);
@@ -775,7 +696,7 @@
       }
     }
 
-    #mastodon-timeline {
+    .features #mastodon-timeline {
       height: 70vh;
       width: 100%;
       margin-bottom: 50px;
diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss
index 5a9105109..744650554 100644
--- a/app/javascript/styles/accounts.scss
+++ b/app/javascript/styles/accounts.scss
@@ -1,34 +1,48 @@
 .card {
-  display: flex;
-  background: $ui-base-color;
+  background-color: lighten($ui-base-color, 4%);
   background-size: cover;
   background-position: center;
   border-radius: 4px 4px 0 0;
   box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
   overflow: hidden;
+  position: relative;
+  display: flex;
+
+  &::after {
+    background: rgba(darken($ui-base-color, 8%), 0.5);
+    display: block;
+    content: "";
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 1;
+  }
 
   @media screen and (max-width: 740px) {
     border-radius: 0;
     box-shadow: none;
   }
 
-  .details {
+  .card__illustration {
+    padding: 60px 0;
     position: relative;
-    padding: 60px 0 0;
-    text-align: center;
-    flex: auto;
+    flex: 1 1 auto;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
 
-    &::after {
-      background: linear-gradient(rgba($base-shadow-color, 0.5), rgba($base-shadow-color, 0.8));
-      display: block;
-      content: "";
-      position: absolute;
-      left: 0;
-      top: 0;
-      width: 100%;
-      height: 100%;
-      z-index: 1;
-    }
+  .card__bio {
+    max-width: 260px;
+    flex: 1 1 auto;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    background: rgba(darken($ui-base-color, 8%), 0.8);
+    position: relative;
+    z-index: 2;
   }
 
   &.compact {
@@ -46,14 +60,15 @@
 
   .name {
     display: block;
-    position: relative;
     font-size: 20px;
     line-height: 18px * 1.5;
     color: $primary-text-color;
+    padding: 10px 15px;
+    padding-bottom: 0;
     font-weight: 500;
-    text-align: center;
-    text-shadow: 0 0 2px $base-shadow-color;
+    position: relative;
     z-index: 2;
+    margin-bottom: 30px;
 
     small {
       display: block;
@@ -64,56 +79,102 @@
   }
 
   .avatar {
-    position: relative;
-    @include avatar-size(120px);
+    width: 120px;
     margin: 0 auto;
-    margin-bottom: 15px;
+    position: relative;
     z-index: 2;
 
     img {
-      @include avatar-radius();
-      @include avatar-size(120px);
+      width: 120px;
+      height: 120px;
       display: block;
+      border-radius: 120px;
+      box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
     }
   }
 
   .controls {
     position: absolute;
-    top: 10px;
-    right: 10px;
+    top: 15px;
+    left: 15px;
     z-index: 2;
+
+    .icon-button {
+      color: rgba($white, 0.8);
+      text-decoration: none;
+      font-size: 13px;
+      line-height: 13px;
+      font-weight: 500;
+
+      .fa {
+        font-weight: 400;
+        margin-right: 5px;
+      }
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: $white;
+      }
+    }
+  }
+
+  .roles {
+    margin-bottom: 30px;
+    padding: 0 15px;
   }
 
   .details-counters {
-    display: inline-flex;
-    position: relative;
+    margin-top: 30px;
+    display: flex;
     flex-direction: row;
-    margin: 15px 0;
-    z-index: 2;
+    width: 100%;
   }
 
   .counter {
-    width: 80px;
+    width: 33.3%;
+    box-sizing: border-box;
+    flex: 0 0 auto;
     color: $ui-primary-color;
     padding: 5px 10px 0;
+    margin-bottom: 10px;
+    border-right: 1px solid lighten($ui-base-color, 4%);
     cursor: default;
+    text-align: center;
     position: relative;
 
-    & + .counter {
-      border-left: 1px solid $ui-primary-color;
+    a {
+      display: block;
+    }
+
+    &:last-child {
+      border-right: 0;
     }
 
-    & > * {
-      opacity: .7;
-      transition: opacity .3s ease;
+    &::after {
+      display: block;
+      content: "";
+      position: absolute;
+      bottom: -10px;
+      left: 0;
+      width: 100%;
+      border-bottom: 4px solid $ui-primary-color;
+      opacity: 0.5;
+      transition: all 400ms ease;
     }
 
-    &.active > *, &:hover > * {
-      opacity: 1;
+    &.active {
+      &::after {
+        border-bottom: 4px solid $ui-highlight-color;
+        opacity: 1;
+      }
     }
 
-    a {
-      display: block;
+    &:hover {
+      &::after {
+        opacity: 1;
+        transition-duration: 100ms;
+      }
     }
 
     a {
@@ -123,87 +184,40 @@
 
     .counter-label {
       font-size: 12px;
-      text-transform: uppercase;
       display: block;
       margin-bottom: 5px;
-      text-shadow: 0 0 2px $base-shadow-color;
     }
 
     .counter-number {
       font-weight: 500;
       font-size: 18px;
       color: $primary-text-color;
+      font-family: 'mastodon-font-display', sans-serif;
     }
   }
 
   .bio {
-    position: relative;
     font-size: 14px;
     line-height: 18px;
-    margin: 15px 0;
-    padding: 5px 10px;
+    padding: 0 15px;
     color: $ui-secondary-color;
-    z-index: 2;
-  }
-
-  .metadata {
-    position: relative;
-    min-width: 180px;
-    max-width: 40%;
-    background: rgba($base-shadow-color, 0.8);
-    color: $primary-text-color;
-    text-align: left;
-    overflow-y: auto;
-    white-space: pre-wrap;
-    z-index: 3;
-
-    .metadata-item {
-      border-bottom: 1px $ui-primary-color solid;
-      padding: 15px 10px;
-      font-size: 18px;
-      line-height: 24px;
-      overflow: hidden;
-      text-overflow: ellipsis;
-
-      a {
-        color: $ui-highlight-color;
-        text-decoration: none;
-
-        &:hover {
-          text-decoration: underline;
-        }
-      }
-
-      b {
-        display: block;
-        font-size: 12px;
-        line-height: 16px;
-        text-transform: uppercase;
-        color: $ui-primary-color;
-
-        a {
-          color: $ui-primary-color;
-        }
-      }
-    }
   }
-}
-
-
-
-@media screen and (max-width: 500px) {
 
-  .card {
+  @media screen and (max-width: 480px) {
     display: block;
 
-    .metadata {
+    .card__bio {
       max-width: none;
-      background: $base-shadow-color;
-      border-top: 1px $ui-primary-color solid;
+    }
 
-      .metadata-item {
-        padding: 15px 20px;
-      }
+    .name,
+    .roles {
+      text-align: center;
+      margin-bottom: 15px;
+    }
+
+    .bio {
+      margin-bottom: 15px;
     }
   }
 }
@@ -282,7 +296,9 @@
     }
 
     .next,
-    .prev {
+    .prev,
+    .next a,
+    .prev a {
       display: inline-block;
     }
   }
@@ -290,13 +306,15 @@
 
 .accounts-grid {
   box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
-  background: $simple-background-color;
+  background: darken($simple-background-color, 8%);
   border-radius: 0 0 4px 4px;
-  padding: 20px 10px;
+  padding: 20px 5px;
   padding-bottom: 10px;
   overflow: hidden;
   display: flex;
   flex-wrap: wrap;
+  z-index: 2;
+  position: relative;
 
   @media screen and (max-width: 740px) {
     border-radius: 0;
@@ -306,35 +324,64 @@
   .account-grid-card {
     box-sizing: border-box;
     width: 335px;
-    border: 1px solid $ui-secondary-color;
+    background: $simple-background-color;
     border-radius: 4px;
     color: $ui-base-color;
-    margin-bottom: 10px;
+    margin: 0 5px 10px;
+    position: relative;
 
-    &:nth-child(odd) {
-      margin-right: 10px;
+    @media screen and (max-width: 740px) {
+      width: calc(100% - 10px);
     }
 
     .account-grid-card__header {
       overflow: hidden;
-      padding: 10px;
-      border-bottom: 1px solid $ui-secondary-color;
+      height: 100px;
+      border-radius: 4px 4px 0 0;
+      background-color: lighten($ui-base-color, 4%);
+      background-size: cover;
+      background-position: center;
+      position: relative;
+
+      &::after {
+        background: rgba(darken($ui-base-color, 8%), 0.5);
+        display: block;
+        content: "";
+        position: absolute;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+        z-index: 1;
+      }
+    }
+
+    .account-grid-card__avatar {
+      box-sizing: border-box;
+      padding: 15px;
+      position: absolute;
+      z-index: 2;
+      top: 100px - (40px + 2px);
+      left: -2px;
     }
 
     .avatar {
-      @include avatar-size(60px);
-      float: left;
-      margin-right: 15px;
+      width: 80px;
+      height: 80px;
 
       img {
-        @include avatar-radius();
-        @include avatar-size(60px);
         display: block;
+        width: 80px;
+        height: 80px;
+        border-radius: 80px;
+        border: 2px solid $simple-background-color;
       }
     }
 
     .name {
+      padding: 15px;
       padding-top: 10px;
+      padding-left: 15px + 80px + 15px;
 
       a {
         display: block;
@@ -342,6 +389,7 @@
         text-decoration: none;
         text-overflow: ellipsis;
         overflow: hidden;
+        font-weight: 500;
 
         &:hover {
           .display_name {
@@ -352,30 +400,38 @@
     }
 
     .display_name {
-      font-size: 14px;
+      font-size: 16px;
       display: block;
+      text-overflow: ellipsis;
+      overflow: hidden;
     }
 
     .username {
-      color: $ui-highlight-color;
+      color: lighten($ui-base-color, 34%);
+      font-size: 14px;
+      font-weight: 400;
     }
 
     .note {
-      padding: 10px;
+      padding: 10px 15px;
       padding-top: 15px;
-      color: $ui-primary-color;
+      box-sizing: border-box;
+      color: lighten($ui-base-color, 26%);
       word-wrap: break-word;
+      min-height: 80px;
     }
   }
 }
 
 .nothing-here {
+  width: 100%;
+  display: block;
   color: $ui-primary-color;
   font-size: 14px;
   font-weight: 500;
   text-align: center;
-  padding: 15px 0;
-  padding-bottom: 25px;
+  padding: 60px 0;
+  padding-top: 55px;
   cursor: default;
 }
 
@@ -396,14 +452,15 @@
     }
 
     & > div {
-      @include avatar-size(48px);
       float: left;
       margin-right: 10px;
+      width: 48px;
+      height: 48px;
     }
 
     .avatar {
-      @include avatar-radius();
       display: block;
+      border-radius: 4px;
     }
 
     .display-name {
@@ -439,3 +496,43 @@
     color: $ui-base-color;
   }
 }
+
+.activity-stream-tabs {
+  background: $simple-background-color;
+  border-bottom: 1px solid $ui-secondary-color;
+  position: relative;
+  z-index: 2;
+
+  a {
+    display: inline-block;
+    padding: 15px;
+    text-decoration: none;
+    color: $ui-highlight-color;
+    text-transform: uppercase;
+    font-weight: 500;
+
+    &:hover,
+    &:active,
+    &:focus {
+      color: lighten($ui-highlight-color, 8%);
+    }
+
+    &.active {
+      color: $ui-base-color;
+      cursor: default;
+    }
+  }
+}
+
+.account-role {
+  display: inline-block;
+  padding: 4px 6px;
+  cursor: default;
+  border-radius: 3px;
+  font-size: 12px;
+  line-height: 12px;
+  font-weight: 500;
+  color: $success-green;
+  background-color: rgba($success-green, 0.1);
+  border: 1px solid rgba($success-green, 0.5);
+}
diff --git a/app/javascript/styles/admin.scss b/app/javascript/styles/admin.scss
index 4c3bbdfc5..b86de75b6 100644
--- a/app/javascript/styles/admin.scss
+++ b/app/javascript/styles/admin.scss
@@ -32,7 +32,7 @@
 
       a {
         display: block;
-        padding: 15px 25px;
+        padding: 15px;
         color: rgba($primary-text-color, 0.7);
         text-decoration: none;
         transition: all 200ms linear;
@@ -61,6 +61,7 @@
 
         a {
           border: 0;
+          padding: 15px 35px;
 
           &.selected {
             color: $primary-text-color;
@@ -98,7 +99,7 @@
 
     h6 {
       font-size: 16px;
-      color: $ui-primary-color;
+      color: $ui-secondary-color;
       line-height: 28px;
       font-weight: 400;
     }
@@ -123,10 +124,10 @@
     }
 
     .muted-hint {
-      color: lighten($ui-base-color, 27%);
+      color: $ui-primary-color;
 
       a {
-        color: $ui-primary-color;
+        color: $ui-highlight-color;
       }
     }
 
@@ -139,15 +140,23 @@
   .simple_form {
     max-width: 400px;
 
-    .label_input {
-      label.select {
-        width: 50%;
-      }
+    &.edit_user,
+    &.new_form_admin_settings,
+    &.new_form_two_factor_confirmation,
+    &.new_form_delete_confirmation,
+    &.new_import,
+    &.new_domain_block,
+    &.edit_domain_block {
+      max-width: none;
+    }
 
-      select {
-        width: 50%;
-        float: right;
-      }
+    .form_two_factor_confirmation_code,
+    .form_delete_confirmation_password {
+      max-width: 400px;
+    }
+
+    .actions {
+      max-width: 400px;
     }
   }
 
@@ -227,27 +236,25 @@
 
 .report-accounts {
   display: flex;
+  flex-wrap: wrap;
   margin-bottom: 20px;
 }
 
 .report-accounts__item {
-  flex: 1 1 0;
   display: flex;
+  flex: 250px;
   flex-direction: column;
+  margin: 0 5px;
 
   & > strong {
     display: block;
-    margin-bottom: 10px;
+    margin: 0 0 10px -5px;
     font-weight: 500;
     font-size: 14px;
     line-height: 18px;
     color: $ui-secondary-color;
   }
 
-  &:first-child {
-    margin-right: 10px;
-  }
-
   .account-card {
     flex: 1 1 auto;
   }
@@ -261,6 +268,11 @@
   .activity-stream {
     flex: 2 0 0;
     margin-right: 20px;
+    max-width: calc(100% - 60px);
+
+    .entry {
+      border-radius: 4px;
+    }
   }
 }
 
@@ -280,18 +292,25 @@
 
 .batch-form-box {
   display: flex;
-  margin-bottom: 10px;
+  flex-wrap: wrap;
+  margin-bottom: 5px;
 
   #form_status_batch_action {
-    margin-right: 5px;
+    margin: 0 5px 5px 0;
     font-size: 14px;
   }
 
+  input.button {
+    margin: 0 5px 5px 0;
+  }
+
   .media-spoiler-toggle-buttons {
     margin-left: auto;
 
     .button {
       overflow: visible;
+      margin: 0 0 5px 5px;
+      float: right;
     }
   }
 }
diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss
index 4e51b555c..05c93b42e 100644
--- a/app/javascript/styles/basics.scss
+++ b/app/javascript/styles/basics.scss
@@ -7,13 +7,28 @@ body {
   line-height: 18px;
   font-weight: 400;
   color: $primary-text-color;
-  padding-bottom: 140px;
+  padding-bottom: 20px;
   text-rendering: optimizelegibility;
   font-feature-settings: "kern";
   text-size-adjust: none;
   -webkit-tap-highlight-color: rgba(0,0,0,0);
   -webkit-tap-highlight-color: transparent;
 
+  &.system-font {
+    // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
+    // -apple-system => Safari <11 specific
+    // BlinkMacSystemFont => Chrome <56 on macOS specific
+    // Segoe UI => Windows 7/8/10
+    // Oxygen => KDE
+    // Ubuntu => Unity/Ubuntu
+    // Cantarell => GNOME
+    // Fira Sans => Firefox OS
+    // Droid Sans => Older Androids (<4.0)
+    // Helvetica Neue => Older macOS <10.11
+    // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
+    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif;
+  }
+
   &.app-body {
     position: fixed;
     width: 100%;
@@ -46,10 +61,6 @@ body {
     height: 100%;
     padding: 0;
   }
-
-  @media screen and (max-width: 400px) {
-    padding-bottom: 0;
-  }
 }
 
 button {
@@ -68,18 +79,3 @@ button {
   align-items: center;
   justify-content: center;
 }
-
-.system-font {
-  // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
-  // -apple-system => Safari <11 specific
-  // BlinkMacSystemFont => Chrome <56 on macOS specific
-  // Segoe UI => Windows 7/8/10
-  // Oxygen => KDE
-  // Ubuntu => Unity/Ubuntu
-  // Cantarell => GNOME
-  // Fira Sans => Firefox OS
-  // Droid Sans => Older Androids (<4.0)
-  // Helvetica Neue => Older macOS <10.11
-  // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
-  font-family: system-ui, -apple-system,BlinkMacSystemFont, "Segoe UI","Oxygen", "Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",mastodon-font-sans-serif, sans-serif;
-}
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 0d086ed45..2f2d6e1f0 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -238,6 +238,8 @@
   line-height: 0;
   display: inline-block;
   width: 0;
+  height: 0;
+  position: absolute;
 }
 
 .ellipsis {
@@ -395,6 +397,11 @@
       bottom: -1px;
       right: 8px;
     }
+
+    ::-webkit-scrollbar-track:hover,
+    ::-webkit-scrollbar-track:active {
+      background-color: rgba($base-overlay-background, 0.3);
+    }
   }
 }
 
@@ -1288,6 +1295,8 @@
   strong,
   span {
     display: block;
+    text-overflow: ellipsis;
+    overflow: hidden;
   }
 
   strong {
@@ -2130,7 +2139,7 @@
 
 .character-counter__wrapper {
   line-height: 36px;
-  margin-right: 16px;
+  margin: 0 16px 0 8px;
   padding-top: 10px;
 }
 
@@ -2313,6 +2322,18 @@ button.icon-button.active i.fa-retweet {
   background: lighten($ui-base-color, 8%);
 }
 
+.status-card.horizontal {
+  display: block;
+
+  .status-card__image {
+    width: 100%;
+  }
+
+  .status-card__image-image {
+    border-radius: 4px 4px 0 0;
+  }
+}
+
 .status-card__image-image {
   border-radius: 4px 0 0 4px;
   display: block;
@@ -2671,12 +2692,8 @@ button.icon-button.active i.fa-retweet {
 }
 
 .media-spoiler {
-  align-items: center;
   background: $base-overlay-background;
   color: $primary-text-color;
-  cursor: pointer;
-  display: flex;
-  flex-direction: column;
   border: 0;
   width: 100%;
   height: 100%;
@@ -3533,7 +3550,8 @@ button.icon-button.active i.fa-retweet {
 }
 
 .onboarding-modal,
-.error-modal {
+.error-modal,
+.embed-modal {
   background: $ui-secondary-color;
   color: $ui-base-color;
   border-radius: 8px;
@@ -4348,6 +4366,15 @@ noscript {
     margin: 30px auto;
     color: $ui-secondary-color;
     max-width: 400px;
+
+    a {
+      color: $ui-highlight-color;
+      text-decoration: underline;
+
+      &:hover {
+        text-decoration: none;
+      }
+    }
   }
 }
 
@@ -4455,3 +4482,61 @@ noscript {
     height: 100% !important;
   }
 }
+
+.embed-modal__html {
+  color: $ui-secondary-color;
+  outline: 0;
+  box-sizing: border-box;
+  display: block;
+  width: 100%;
+  border: none;
+  padding: 10px;
+  font-family: 'mastodon-font-monospace', monospace;
+  background: $ui-base-color;
+  color: $ui-primary-color;
+  font-size: 14px;
+  margin: 0;
+  margin-bottom: 15px;
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus,
+  &:active {
+    outline: 0 !important;
+  }
+
+  &:focus {
+    background: lighten($ui-base-color, 4%);
+  }
+
+  @media screen and (max-width: 600px) {
+    font-size: 16px;
+  }
+}
+
+.embed-modal {
+  h4 {
+    padding: 30px;
+    font-weight: 500;
+    font-size: 16px;
+    text-align: center;
+  }
+
+  .hint {
+    margin-bottom: 15px;
+  }
+}
+
+.embed-modal__container {
+  padding: 10px;
+}
+
+.embed-modal__iframe {
+  width: 100%;
+  min-width: 400px;
+  overflow: hidden;
+  border: 0;
+}
diff --git a/app/javascript/styles/containers.scss b/app/javascript/styles/containers.scss
index 536f4e5a1..af2589e23 100644
--- a/app/javascript/styles/containers.scss
+++ b/app/javascript/styles/containers.scss
@@ -44,6 +44,22 @@
   }
 }
 
+.compose-standalone {
+  .compose-form {
+    width: 400px;
+    margin: 0 auto;
+    padding: 20px 0;
+    margin-top: 40px;
+    box-sizing: border-box;
+
+    @media screen and (max-width: 400px) {
+      width: 100%;
+      margin-top: 0;
+      padding: 20px;
+    }
+  }
+}
+
 .account-header {
   width: 400px;
   margin: 0 auto;
@@ -56,7 +72,7 @@
   margin-bottom: -30px;
   margin-top: 40px;
 
-  @media screen and (max-width: 400px) {
+  @media screen and (max-width: 440px) {
     width: 100%;
     margin: 0;
     margin-bottom: 10px;
@@ -81,10 +97,13 @@
   .name {
     flex: 1 1 auto;
     color: $ui-secondary-color;
+    width: calc(100% - 88px);
 
     .username {
       display: block;
       font-weight: 500;
+      text-overflow: ellipsis;
+      overflow: hidden;
     }
   }
 
@@ -92,5 +111,6 @@
     display: block;
     font-size: 32px;
     line-height: 40px;
+    margin-left: 8px;
   }
 }
diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss
index 62094e98e..747610237 100644
--- a/app/javascript/styles/forms.scss
+++ b/app/javascript/styles/forms.scss
@@ -24,7 +24,7 @@ code {
 
   p.hint {
     margin-bottom: 15px;
-    color: lighten($ui-base-color, 32%);
+    color: $ui-primary-color;
 
     &.subtle-hint {
       text-align: center;
@@ -32,10 +32,10 @@ code {
       line-height: 18px;
       margin-top: 15px;
       margin-bottom: 0;
-      color: $ui-base-lighter-color;
+      color: $ui-primary-color;
 
       a {
-        color: $ui-primary-color;
+        color: $ui-highlight-color;
       }
     }
   }
@@ -53,7 +53,6 @@ code {
 
     label {
       flex: 0 0 auto;
-      width: 100px;
     }
 
     input {
@@ -65,12 +64,37 @@ code {
     padding: 15px 0;
     margin-bottom: 0;
 
+    .label_input {
+      flex-wrap: wrap;
+      align-items: flex-start;
+    }
+
+    &.select .label_input {
+      align-items: initial;
+    }
+
     .label_input > label {
       font-family: inherit;
       font-size: 16px;
       color: $primary-text-color;
       display: block;
       padding-top: 5px;
+      margin-bottom: 5px;
+      flex: 1;
+      min-width: 150px;
+      word-wrap: break-word;
+
+      &.select {
+        flex: 0;
+      }
+
+      & ~ * {
+        margin-left: 10px;
+      }
+    }
+
+    ul {
+      flex: 390px;
     }
 
     &.boolean {
@@ -359,17 +383,23 @@ code {
     color: $ui-secondary-color;
     font-weight: 500;
   }
+
+  @media screen and (max-width: 740px) and (min-width: 441px) {
+    margin-top: 40px;
+  }
 }
 
 .qr-wrapper {
   display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
 }
 
 .qr-code {
   flex: 0 0 auto;
   background: $simple-background-color;
   padding: 4px;
-  margin-bottom: 20px;
+  margin: 0 10px 20px 0;
   box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
   display: inline-block;
 
@@ -380,8 +410,9 @@ code {
 }
 
 .qr-alternative {
-  margin-left: 10px;
-  color: $ui-primary-color;
+  margin-bottom: 20px;
+  color: $ui-secondary-color;
+  flex: 150px;
 
   samp {
     display: block;
@@ -391,7 +422,6 @@ code {
 
 .table-form {
   p {
-    max-width: 400px;
     margin-bottom: 15px;
 
     strong {
@@ -403,7 +433,6 @@ code {
 .simple_form,
 .table-form {
   .warning {
-    max-width: 400px;
     box-sizing: border-box;
     background: rgba($error-value-color, 0.5);
     color: $primary-text-color;
diff --git a/app/javascript/styles/landing_strip.scss b/app/javascript/styles/landing_strip.scss
index d2ac5b822..15ff84912 100644
--- a/app/javascript/styles/landing_strip.scss
+++ b/app/javascript/styles/landing_strip.scss
@@ -5,6 +5,8 @@
   padding: 14px;
   border-radius: 4px;
   margin-bottom: 20px;
+  display: flex;
+  align-items: center;
 
   strong,
   a {
@@ -15,4 +17,15 @@
     color: inherit;
     text-decoration: underline;
   }
+
+  .logo {
+    width: 30px;
+    height: 30px;
+    flex: 0 0 auto;
+    margin-right: 15px;
+  }
+
+  @media screen and (max-width: 740px) {
+    margin-bottom: 0;
+  }
 }
diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss
index 4966fbc21..6c003d69a 100644
--- a/app/javascript/styles/rtl.scss
+++ b/app/javascript/styles/rtl.scss
@@ -8,7 +8,7 @@ body.rtl {
   }
 
   .character-counter__wrapper {
-    margin-right: 0;
+    margin-right: 8px;
     margin-left: 16px;
   }
 
@@ -32,6 +32,11 @@ body.rtl {
     right: auto;
   }
 
+  .column-header__back-button {
+    padding-left: 5px;
+    padding-right: 0;
+  }
+
   .column-header__setting-arrows {
     float: left;
   }
@@ -54,25 +59,64 @@ body.rtl {
     right: 10px;
   }
 
-  .status {
+  .status,
+  .activity-stream .status.light {
     padding-left: 10px;
     padding-right: 68px;
   }
 
-  .status__info .status__display-name {
+  .status__info .status__display-name,
+  .activity-stream .status.light .status__display-name {
     padding-left: 25px;
     padding-right: 0;
   }
 
+  .activity-stream .pre-header {
+    padding-right: 68px;
+    padding-left: 0;
+  }
+
+  .status__prepend {
+    margin-left: 0;
+    margin-right: 68px;
+  }
+
+  .status__prepend-icon-wrapper {
+    left: auto;
+    right: -26px;
+  }
+
+  .activity-stream .pre-header .pre-header__icon {
+    left: auto;
+    right: 42px;
+  }
+
+  .account__avatar-overlay-overlay {
+    right: auto;
+    left: 0;
+  }
+
   .column-back-button--slim-button {
     right: auto;
     left: 0;
   }
 
-  .status__relative-time {
+  .status__relative-time,
+  .activity-stream .status.light .status__header .status__meta {
     float: left;
   }
 
+  .activity-stream .detailed-status.light .detailed-status__display-name > div {
+    float: right;
+    margin-right: 0;
+    margin-left: 10px;
+  }
+
+  .activity-stream .detailed-status.light .detailed-status__meta span > span {
+    margin-left: 0;
+    margin-right: 6px;
+  }
+
   .status__action-bar-button {
     float: right;
     margin-right: 0;
@@ -129,6 +173,78 @@ body.rtl {
     right: -2.14285714em;
   }
 
+  .admin-wrapper .sidebar ul a i.fa,
+  a.table-action-link i.fa {
+    margin-right: 0;
+    margin-left: 5px;
+  }
+
+  .simple_form .check_boxes .checkbox label,
+  .simple_form .input.with_label.boolean label.checkbox {
+    padding-left: 0;
+    padding-right: 25px;
+  }
+
+  .simple_form .check_boxes .checkbox input[type="checkbox"],
+  .simple_form .input.boolean input[type="checkbox"] {
+    left: auto;
+    right: 0;
+  }
+
+  .simple_form .input-with-append .input input {
+    padding-left: 127px;
+    padding-right: 0;
+  }
+
+  .simple_form .input-with-append .append {
+    right: auto;
+    left: 0;
+  }
+
+  .table th,
+  .table td {
+    text-align: right;
+  }
+
+  .filters .filter-subset {
+    margin-right: 0;
+    margin-left: 45px;
+  }
+
+  .landing-page .header-wrapper .mascot {
+    right: 60px;
+    left: auto;
+  }
+
+  .landing-page .header .hero .floats .float-1 {
+    left: -120px;
+    right: auto;
+  }
+
+  .landing-page .header .hero .floats .float-2 {
+    left: 210px;
+    right: auto;
+  }
+
+  .landing-page .header .hero .floats .float-3 {
+    left: 110px;
+    right: auto;
+  }
+
+  .landing-page .header .links .brand img {
+    left: 0;
+  }
+
+  .landing-page .fa-external-link {
+    padding-right: 5px;
+    padding-left: 0 !important;
+  }
+
+  .landing-page .features #mastodon-timeline {
+    margin-right: 0;
+    margin-left: 30px;
+  }
+
   @media screen and (min-width: 1025px) {
     .column,
     .drawer {
@@ -139,11 +255,6 @@ body.rtl {
         padding-left: 5px;
         padding-right: 10px;
       }
-
-      &:last-child {
-        padding-right: 0;
-        padding-left: 10px;
-      }
     }
 
     .columns-area > div {
diff --git a/app/javascript/styles/stream_entries.scss b/app/javascript/styles/stream_entries.scss
index 90a43388b..00e430184 100644
--- a/app/javascript/styles/stream_entries.scss
+++ b/app/javascript/styles/stream_entries.scss
@@ -8,6 +8,7 @@
     .detailed-status.light,
     .status.light {
       border-bottom: 1px solid $ui-secondary-color;
+      animation: none;
     }
 
     &:last-child {
@@ -34,6 +35,14 @@
         }
       }
     }
+
+    @media screen and (max-width: 740px) {
+      &,
+      .detailed-status.light,
+      .status.light {
+        border-radius: 0 !important;
+      }
+    }
   }
 
   &.with-header {
@@ -44,6 +53,14 @@
         .status.light {
           border-radius: 0;
         }
+
+        &:last-child {
+          &,
+          .detailed-status.light,
+          .status.light {
+            border-radius: 0 0 4px 4px;
+          }
+        }
       }
     }
   }
@@ -400,3 +417,33 @@
     }
   }
 }
+
+.button.button-secondary.logo-button {
+  position: absolute;
+  right: 14px;
+  top: 14px;
+  font-size: 14px;
+
+  svg {
+    width: 20px;
+    height: auto;
+    vertical-align: middle;
+    margin-right: 5px;
+
+    path:first-child {
+      fill: $ui-primary-color;
+    }
+
+    path:last-child {
+      fill: $simple-background-color;
+    }
+  }
+
+  &:active,
+  &:focus,
+  &:hover {
+    svg path:first-child {
+      fill: lighten($ui-primary-color, 4%);
+    }
+  }
+}
diff --git a/app/javascript/styles/tables.scss b/app/javascript/styles/tables.scss
index 6e54c59c0..f6e57e196 100644
--- a/app/javascript/styles/tables.scss
+++ b/app/javascript/styles/tables.scss
@@ -46,7 +46,7 @@
   &.inline-table {
     td,
     th {
-      padding: 8px 0;
+      padding: 8px 2px;
     }
 
     & > tbody > tr:nth-child(odd) > td,
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
new file mode 100644
index 000000000..b06dd6194
--- /dev/null
+++ b/app/lib/activitypub/activity.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity
+  include JsonLdHelper
+
+  def initialize(json, account)
+    @json    = json
+    @account = account
+    @object  = @json['object']
+  end
+
+  def perform
+    raise NotImplementedError
+  end
+
+  class << self
+    def factory(json, account)
+      @json = json
+      klass&.new(json, account)
+    end
+
+    private
+
+    def klass
+      case @json['type']
+      when 'Create'
+        ActivityPub::Activity::Create
+      when 'Announce'
+        ActivityPub::Activity::Announce
+      when 'Delete'
+        ActivityPub::Activity::Delete
+      when 'Follow'
+        ActivityPub::Activity::Follow
+      when 'Like'
+        ActivityPub::Activity::Like
+      when 'Block'
+        ActivityPub::Activity::Block
+      when 'Update'
+        ActivityPub::Activity::Update
+      when 'Undo'
+        ActivityPub::Activity::Undo
+      when 'Accept'
+        ActivityPub::Activity::Accept
+      when 'Reject'
+        ActivityPub::Activity::Reject
+      end
+    end
+  end
+
+  protected
+
+  def status_from_uri(uri)
+    ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
+  end
+
+  def account_from_uri(uri)
+    ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+  end
+
+  def object_uri
+    @object_uri ||= value_or_id(@object)
+  end
+
+  def redis
+    Redis.current
+  end
+
+  def distribute(status)
+    notify_about_reblog(status) if reblog_of_local_account?(status)
+    notify_about_mentions(status)
+    crawl_links(status)
+    distribute_to_followers(status)
+  end
+
+  def reblog_of_local_account?(status)
+    status.reblog? && status.reblog.account.local?
+  end
+
+  def notify_about_reblog(status)
+    NotifyService.new.call(status.reblog.account, status)
+  end
+
+  def notify_about_mentions(status)
+    status.mentions.includes(:account).each do |mention|
+      next unless mention.account.local? && audience_includes?(mention.account)
+      NotifyService.new.call(mention.account, mention)
+    end
+  end
+
+  def crawl_links(status)
+    return if status.spoiler_text?
+    LinkCrawlWorker.perform_async(status.id)
+  end
+
+  def distribute_to_followers(status)
+    ::DistributionWorker.perform_async(status.id)
+  end
+
+  def delete_arrived_first?(uri)
+    redis.exists("delete_upon_arrival:#{@account.id}:#{uri}")
+  end
+
+  def delete_later!(uri)
+    redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
+  end
+end
diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb
new file mode 100644
index 000000000..bd90c9019
--- /dev/null
+++ b/app/lib/activitypub/activity/accept.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Accept < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Follow'
+      accept_follow
+    end
+  end
+
+  private
+
+  def accept_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
+    follow_request&.authorize!
+  end
+
+  def target_uri
+    @target_uri ||= value_or_id(@object['actor'])
+  end
+end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
new file mode 100644
index 000000000..c4da405c7
--- /dev/null
+++ b/app/lib/activitypub/activity/announce.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Announce < ActivityPub::Activity
+  def perform
+    original_status   = status_from_uri(object_uri)
+    original_status ||= fetch_remote_original_status
+
+    return if original_status.nil? || delete_arrived_first?(@json['id'])
+
+    status = Status.find_by(account: @account, reblog: original_status)
+
+    return status unless status.nil?
+
+    status = Status.create!(account: @account, reblog: original_status, uri: @json['id'])
+    distribute(status)
+    status
+  end
+
+  private
+
+  def fetch_remote_original_status
+    if object_uri.start_with?('http')
+      ActivityPub::FetchRemoteStatusService.new.call(object_uri)
+    elsif @object['url'].present?
+      ::FetchRemoteStatusService.new.call(@object['url'])
+    end
+  end
+end
diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb
new file mode 100644
index 000000000..f630d5db2
--- /dev/null
+++ b/app/lib/activitypub/activity/block.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Block < ActivityPub::Activity
+  def perform
+    target_account = account_from_uri(object_uri)
+
+    return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.blocking?(target_account)
+
+    UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
+    @account.block!(target_account)
+  end
+end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
new file mode 100644
index 000000000..081e80570
--- /dev/null
+++ b/app/lib/activitypub/activity/create.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Create < ActivityPub::Activity
+  def perform
+    return if delete_arrived_first?(object_uri) || unsupported_object_type?
+
+    status = find_existing_status
+
+    return status unless status.nil?
+
+    ApplicationRecord.transaction do
+      status = Status.create!(status_params)
+
+      process_tags(status)
+      process_attachments(status)
+    end
+
+    resolve_thread(status)
+    distribute(status)
+    forward_for_reply if status.public_visibility? || status.unlisted_visibility?
+
+    status
+  end
+
+  private
+
+  def find_existing_status
+    status   = status_from_uri(object_uri)
+    status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
+    status
+  end
+
+  def status_params
+    {
+      uri: @object['id'],
+      url: @object['url'] || @object['id'],
+      account: @account,
+      text: text_from_content || '',
+      language: language_from_content,
+      spoiler_text: @object['summary'] || '',
+      created_at: @object['published'] || Time.now.utc,
+      reply: @object['inReplyTo'].present?,
+      sensitive: @object['sensitive'] || false,
+      visibility: visibility_from_audience,
+      thread: replied_to_status,
+      conversation: conversation_from_uri(@object['conversation']),
+    }
+  end
+
+  def process_tags(status)
+    return unless @object['tag'].is_a?(Array)
+
+    @object['tag'].each do |tag|
+      case tag['type']
+      when 'Hashtag'
+        process_hashtag tag, status
+      when 'Mention'
+        process_mention tag, status
+      end
+    end
+  end
+
+  def process_hashtag(tag, status)
+    hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
+    hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
+
+    status.tags << hashtag
+  end
+
+  def process_mention(tag, status)
+    account = account_from_uri(tag['href'])
+    account = FetchRemoteAccountService.new.call(tag['href']) if account.nil?
+    return if account.nil?
+    account.mentions.create(status: status)
+  end
+
+  def process_attachments(status)
+    return unless @object['attachment'].is_a?(Array)
+
+    @object['attachment'].each do |attachment|
+      next if unsupported_media_type?(attachment['mediaType'])
+
+      href             = Addressable::URI.parse(attachment['url']).normalize.to_s
+      media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href)
+
+      next if skip_download?
+
+      media_attachment.file_remote_url = href
+      media_attachment.save
+    end
+  end
+
+  def resolve_thread(status)
+    return unless status.reply? && status.thread.nil?
+    ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
+  end
+
+  def conversation_from_uri(uri)
+    return nil if uri.nil?
+    return Conversation.find_by(id: TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if TagManager.instance.local_id?(uri)
+    Conversation.find_by(uri: uri) || Conversation.create!(uri: uri)
+  end
+
+  def visibility_from_audience
+    if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :public
+    elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :unlisted
+    elsif equals_or_includes?(@object['to'], @account.followers_url)
+      :private
+    else
+      :direct
+    end
+  end
+
+  def audience_includes?(account)
+    uri = ActivityPub::TagManager.instance.uri_for(account)
+    equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri)
+  end
+
+  def replied_to_status
+    return @replied_to_status if defined?(@replied_to_status)
+
+    if in_reply_to_uri.blank?
+      @replied_to_status = nil
+    else
+      @replied_to_status   = status_from_uri(in_reply_to_uri)
+      @replied_to_status ||= status_from_uri(@object['inReplyToAtomUri']) if @object['inReplyToAtomUri'].present?
+      @replied_to_status
+    end
+  end
+
+  def in_reply_to_uri
+    value_or_id(@object['inReplyTo'])
+  end
+
+  def text_from_content
+    if @object['content'].present?
+      @object['content']
+    elsif language_map?
+      @object['contentMap'].values.first
+    end
+  end
+
+  def language_from_content
+    return nil unless language_map?
+    @object['contentMap'].keys.first
+  end
+
+  def language_map?
+    @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
+  end
+
+  def unsupported_object_type?
+    @object.is_a?(String) || !%w(Article Note).include?(@object['type'])
+  end
+
+  def unsupported_media_type?(mime_type)
+    mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
+  end
+
+  def skip_download?
+    return @skip_download if defined?(@skip_download)
+    @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
+  end
+
+  def reply_to_local?
+    !replied_to_status.nil? && replied_to_status.account.local?
+  end
+
+  def forward_for_reply
+    return unless @json['signature'].present? && reply_to_local?
+    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id)
+  end
+end
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
new file mode 100644
index 000000000..4c6afb090
--- /dev/null
+++ b/app/lib/activitypub/activity/delete.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Delete < ActivityPub::Activity
+  def perform
+    if @account.uri == object_uri
+      delete_person
+    else
+      delete_note
+    end
+  end
+
+  private
+
+  def delete_person
+    SuspendAccountService.new.call(@account)
+  end
+
+  def delete_note
+    status   = Status.find_by(uri: object_uri, account: @account)
+    status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
+
+    delete_later!(object_uri)
+
+    return if status.nil?
+
+    forward_for_reblogs(status)
+    delete_now!(status)
+  end
+
+  def forward_for_reblogs(status)
+    return if @json['signature'].blank?
+
+    ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account_id|
+      [payload, account_id]
+    end
+  end
+
+  def delete_now!(status)
+    RemoveStatusService.new.call(status)
+  end
+
+  def payload
+    @payload ||= Oj.dump(@json)
+  end
+end
diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb
new file mode 100644
index 000000000..8adbbb9c3
--- /dev/null
+++ b/app/lib/activitypub/activity/follow.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Follow < ActivityPub::Activity
+  def perform
+    target_account = account_from_uri(object_uri)
+
+    return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account)
+
+    # Fast-forward repeat follow requests
+    if @account.following?(target_account)
+      AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true)
+      return
+    end
+
+    follow_request = FollowRequest.create!(account: @account, target_account: target_account)
+
+    if target_account.locked?
+      NotifyService.new.call(target_account, follow_request)
+    else
+      AuthorizeFollowService.new.call(@account, target_account)
+      NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account))
+    end
+  end
+end
diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb
new file mode 100644
index 000000000..674d5fe47
--- /dev/null
+++ b/app/lib/activitypub/activity/like.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Like < ActivityPub::Activity
+  def perform
+    original_status = status_from_uri(object_uri)
+
+    return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
+
+    favourite = original_status.favourites.create!(account: @account)
+    NotifyService.new.call(original_status.account, favourite)
+  end
+end
diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb
new file mode 100644
index 000000000..d815feeb6
--- /dev/null
+++ b/app/lib/activitypub/activity/reject.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Reject < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Follow'
+      reject_follow
+    end
+  end
+
+  private
+
+  def reject_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
+    follow_request&.reject!
+  end
+
+  def target_uri
+    @target_uri ||= value_or_id(@object['actor'])
+  end
+end
diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb
new file mode 100644
index 000000000..4b0905de2
--- /dev/null
+++ b/app/lib/activitypub/activity/undo.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Undo < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Announce'
+      undo_announce
+    when 'Follow'
+      undo_follow
+    when 'Like'
+      undo_like
+    when 'Block'
+      undo_block
+    end
+  end
+
+  private
+
+  def undo_announce
+    status = Status.find_by(uri: object_uri, account: @account)
+
+    if status.nil?
+      delete_later!(object_uri)
+    else
+      RemoveStatusService.new.call(status)
+    end
+  end
+
+  def undo_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    if @account.following?(target_account)
+      @account.unfollow!(target_account)
+    elsif @account.requested?(target_account)
+      FollowRequest.find_by(account: @account, target_account: target_account)&.destroy
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def undo_like
+    status = status_from_uri(target_uri)
+
+    return if status.nil? || !status.account.local?
+
+    if @account.favourited?(status)
+      favourite = status.favourites.where(account: @account).first
+      favourite&.destroy
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def undo_block
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    if @account.blocking?(target_account)
+      UnblockService.new.call(@account, target_account)
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def target_uri
+    @target_uri ||= value_or_id(@object['object'])
+  end
+end
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
new file mode 100644
index 000000000..0134b4015
--- /dev/null
+++ b/app/lib/activitypub/activity/update.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Update < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Person'
+      update_account
+    end
+  end
+
+  private
+
+  def update_account
+    return if @account.uri != object_uri
+    ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object)
+  end
+end
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 0a70207bc..6ed66a239 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -1,13 +1,34 @@
 # frozen_string_literal: true
 
 class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
+  CONTEXT = {
+    '@context': [
+      'https://www.w3.org/ns/activitystreams',
+      'https://w3id.org/security/v1',
+
+      {
+        'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
+        'sensitive'                 => 'as:sensitive',
+        'Hashtag'                   => 'as:Hashtag',
+        'ostatus'                   => 'http://ostatus.org#',
+        'atomUri'                   => 'ostatus:atomUri',
+        'inReplyToAtomUri'          => 'ostatus:inReplyToAtomUri',
+        'conversation'              => 'ostatus:conversation',
+      },
+    ],
+  }.freeze
+
   def self.default_key_transform
     :camel_lower
   end
 
+  def self.transform_key_casing!(value, _options)
+    ActivityPub::CaseTransform.camel_lower(value)
+  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))
+    serialized_hash = CONTEXT.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/case_transform.rb b/app/lib/activitypub/case_transform.rb
new file mode 100644
index 000000000..7f716f862
--- /dev/null
+++ b/app/lib/activitypub/case_transform.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module ActivityPub::CaseTransform
+  class << self
+    def camel_lower_cache
+      @camel_lower_cache ||= {}
+    end
+
+    def camel_lower(value)
+      case value
+      when Array then value.map { |item| camel_lower(item) }
+      when Hash then value.deep_transform_keys! { |key| camel_lower(key) }
+      when Symbol then camel_lower(value.to_s).to_sym
+      when String
+        camel_lower_cache[value] ||= if value.start_with?('_:')
+                                       '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
+                                     else
+                                       value.underscore.camelize(:lower)
+                                     end
+      else value
+      end
+    end
+  end
+end
diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb
new file mode 100644
index 000000000..adb8b6cdf
--- /dev/null
+++ b/app/lib/activitypub/linked_data_signature.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class ActivityPub::LinkedDataSignature
+  include JsonLdHelper
+
+  CONTEXT = 'https://w3id.org/identity/v1'
+
+  def initialize(json)
+    @json = json.with_indifferent_access
+  end
+
+  def verify_account!
+    return unless @json['signature'].is_a?(Hash)
+
+    type        = @json['signature']['type']
+    creator_uri = @json['signature']['creator']
+    signature   = @json['signature']['signatureValue']
+
+    return unless type == 'RsaSignature2017'
+
+    creator   = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account)
+    creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri)
+
+    return if creator.nil?
+
+    options_hash   = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+    document_hash  = hash(@json.without('signature'))
+    to_be_verified = options_hash + document_hash
+
+    if creator.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), to_be_verified)
+      creator
+    end
+  end
+
+  def sign!(creator)
+    options = {
+      'type'    => 'RsaSignature2017',
+      'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join,
+      'created' => Time.now.utc.iso8601,
+    }
+
+    options_hash  = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+    document_hash = hash(@json.without('signature'))
+    to_be_signed  = options_hash + document_hash
+
+    signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed))
+
+    @json.merge('signature' => options.merge('signatureValue' => signature))
+  end
+
+  private
+
+  def hash(obj)
+    Digest::SHA256.hexdigest(canonicalize(obj))
+  end
+end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index ec42bcad3..de575d9e6 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -6,6 +6,8 @@ class ActivityPub::TagManager
   include Singleton
   include RoutingHelper
 
+  CONTEXT = 'https://www.w3.org/ns/activitystreams'
+
   COLLECTIONS = {
     public: 'https://www.w3.org/ns/activitystreams#Public',
   }.freeze
@@ -17,6 +19,7 @@ class ActivityPub::TagManager
     when :person
       short_account_url(target)
     when :note, :comment, :activity
+      return activity_account_status_url(target.account, target) if target.reblog?
       short_account_status_url(target.account, target)
     end
   end
@@ -28,10 +31,17 @@ class ActivityPub::TagManager
     when :person
       account_url(target)
     when :note, :comment, :activity
+      return activity_account_status_url(target.account, target) if target.reblog?
       account_status_url(target.account, target)
     end
   end
 
+  def activity_uri_for(target)
+    return nil unless %i(note comment activity).include?(target.object_type) && target.local?
+
+    activity_account_status_url(target.account, target)
+  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
@@ -66,4 +76,32 @@ class ActivityPub::TagManager
 
     cc
   end
+
+  def local_uri?(uri)
+    uri  = Addressable::URI.parse(uri)
+    host = uri.normalized_host
+    host = "#{host}:#{uri.port}" if uri.port
+
+    !host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host))
+  end
+
+  def uri_to_local_id(uri, param = :id)
+    path_params = Rails.application.routes.recognize_path(uri)
+    path_params[param]
+  end
+
+  def uri_to_resource(uri, klass)
+    if local_uri?(uri)
+      case klass.name
+      when 'Account'
+        klass.find_local(uri_to_local_id(uri, :username))
+      else
+        klass.find_by(id: uri_to_local_id(uri))
+      end
+    elsif ::TagManager.instance.local_id?(uri)
+      klass.find_by(id: ::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s))
+    else
+      klass.find_by(uri: uri.split('#').first)
+    end
+  end
 end
diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb
index e1477f0eb..1dc7abee3 100644
--- a/app/lib/ostatus/activity/base.rb
+++ b/app/lib/ostatus/activity/base.rb
@@ -29,21 +29,43 @@ class OStatus::Activity::Base
   end
 
   def url
-    link = @xml.at_xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS)
+    link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' }
     link.nil? ? nil : link['href']
   end
 
+  def activitypub_uri
+    link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) }
+    link.nil? ? nil : link['href']
+  end
+
+  def activitypub_uri?
+    activitypub_uri.present?
+  end
+
   private
 
   def find_status(uri)
     if TagManager.instance.local_id?(uri)
       local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status')
       return Status.find_by(id: local_id)
+    elsif ActivityPub::TagManager.instance.local_uri?(uri)
+      local_id = ActivityPub::TagManager.instance.uri_to_local_id(uri)
+      return Status.find_by(id: local_id)
     end
 
     Status.find_by(uri: uri)
   end
 
+  def find_activitypub_status(uri, href)
+    tag_matches = /tag:([^,:]+)[^:]*:objectId=([\d]+)/.match(uri)
+    href_matches = %r{/users/([^/]+)}.match(href)
+
+    unless tag_matches.nil? || href_matches.nil?
+      uri = "https://#{tag_matches[1]}/users/#{href_matches[1]}/statuses/#{tag_matches[2]}"
+      Status.find_by(uri: uri)
+    end
+  end
+
   def redis
     Redis.current
   end
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index e22f746f2..1a23c9efa 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -9,6 +9,11 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
 
     return [nil, false] if @account.suspended?
 
+    if activitypub_uri? && [:public, :unlisted].include?(visibility_scope)
+      result = perform_via_activitypub
+      return result if result.first.present?
+    end
+
     Rails.logger.debug "Creating remote status #{id}"
 
     # Return early if status already exists in db
@@ -16,24 +21,28 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
 
     return [status, false] unless status.nil?
 
-    status = Status.create!(
-      uri: id,
-      url: url,
-      account: @account,
-      reblog: reblog,
-      text: content,
-      spoiler_text: content_warning,
-      created_at: published,
-      reply: thread?,
-      language: content_language,
-      visibility: visibility_scope,
-      conversation: find_or_create_conversation,
-      thread: thread? ? find_status(thread.first) : nil
-    )
-
-    save_mentions(status)
-    save_hashtags(status)
-    save_media(status)
+    cached_reblog = reblog
+
+    ApplicationRecord.transaction do
+      status = Status.create!(
+        uri: id,
+        url: url,
+        account: @account,
+        reblog: cached_reblog,
+        text: content,
+        spoiler_text: content_warning,
+        created_at: published,
+        reply: thread?,
+        language: content_language,
+        visibility: visibility_scope,
+        conversation: find_or_create_conversation,
+        thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil
+      )
+
+      save_mentions(status)
+      save_hashtags(status)
+      save_media(status)
+    end
 
     if thread? && status.thread.nil?
       Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}"
@@ -48,6 +57,10 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
     [status, true]
   end
 
+  def perform_via_activitypub
+    [find_status(activitypub_uri) || ActivityPub::FetchRemoteStatusService.new.call(activitypub_uri), false]
+  end
+
   def content
     @xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content
   end
diff --git a/app/lib/ostatus/activity/deletion.rb b/app/lib/ostatus/activity/deletion.rb
index 860faf501..c98f5ee0a 100644
--- a/app/lib/ostatus/activity/deletion.rb
+++ b/app/lib/ostatus/activity/deletion.rb
@@ -3,7 +3,9 @@
 class OStatus::Activity::Deletion < OStatus::Activity::Base
   def perform
     Rails.logger.debug "Deleting remote status #{id}"
-    status = Status.find_by(uri: id, account: @account)
+
+    status   = Status.find_by(uri: id, account: @account)
+    status ||= Status.find_by(uri: activitypub_uri, account: @account) if activitypub_uri?
 
     if status.nil?
       redis.setex("delete_upon_arrival:#{@account.id}:#{id}", 6 * 3_600, id)
diff --git a/app/lib/ostatus/activity/remote.rb b/app/lib/ostatus/activity/remote.rb
index ecec6886c..5b204b6d8 100644
--- a/app/lib/ostatus/activity/remote.rb
+++ b/app/lib/ostatus/activity/remote.rb
@@ -2,6 +2,10 @@
 
 class OStatus::Activity::Remote < OStatus::Activity::Base
   def perform
-    find_status(id) || FetchRemoteStatusService.new.call(url)
+    if activitypub_uri?
+      find_status(activitypub_uri) || FetchRemoteStatusService.new.call(url)
+    else
+      find_status(id) || FetchRemoteStatusService.new.call(url)
+    end
   end
 end
diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 0d62361be..81fae4140 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -79,6 +79,9 @@ class OStatus::AtomSerializer
 
     if stream_entry.status.nil?
       append_element(entry, 'content', 'Deleted status')
+    elsif stream_entry.status.destroyed?
+      append_element(entry, 'content', 'Deleted status')
+      append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(stream_entry.status)) if stream_entry.account.local?
     else
       serialize_status_attributes(entry, stream_entry.status)
     end
@@ -343,6 +346,8 @@ class OStatus::AtomSerializer
   end
 
   def serialize_status_attributes(entry, status)
+    append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(status)) if status.account.local?
+
     append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
     append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
 
diff --git a/app/lib/request.rb b/app/lib/request.rb
index e73c5ac20..c01e07925 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -12,15 +12,21 @@ class Request
     @headers = {}
 
     set_common_headers!
+    set_digest! if options.key?(:body)
   end
 
-  def on_behalf_of(account)
+  def on_behalf_of(account, key_id_format = :acct)
     raise ArgumentError unless account.local?
-    @account = account
+
+    @account       = account
+    @key_id_format = key_id_format
+
+    self
   end
 
   def add_headers(new_headers)
     @headers.merge!(new_headers)
+    self
   end
 
   def perform
@@ -40,8 +46,11 @@ class Request
     @headers['Date']         = Time.now.utc.httpdate
   end
 
+  def set_digest!
+    @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
+  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))
 
@@ -60,6 +69,15 @@ class Request
     @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
   end
 
+  def key_id
+    case @key_id_format
+    when :acct
+      @account.to_webfinger_s
+    when :uri
+      [ActivityPub::TagManager.instance.uri_for(@account), '#main-key'].join
+    end
+  end
+
   def timeout
     { write: 10, connect: 10, read: 10 }
   end
diff --git a/app/lib/stream_entry_finder.rb b/app/lib/status_finder.rb
index 0ea33229c..4d1aed297 100644
--- a/app/lib/stream_entry_finder.rb
+++ b/app/lib/status_finder.rb
@@ -1,20 +1,22 @@
 # frozen_string_literal: true
 
-class StreamEntryFinder
+class StatusFinder
   attr_reader :url
 
   def initialize(url)
     @url = url
   end
 
-  def stream_entry
+  def status
     verify_action!
 
+    raise ActiveRecord::RecordNotFound unless TagManager.instance.local_url?(url)
+
     case recognized_params[:controller]
     when 'stream_entries'
-      StreamEntry.find(recognized_params[:id])
+      StreamEntry.find(recognized_params[:id]).status
     when 'statuses'
-      Status.find(recognized_params[:id]).stream_entry
+      Status.find(recognized_params[:id])
     else
       raise ActiveRecord::RecordNotFound
     end
diff --git a/app/models/account.rb b/app/models/account.rb
index e217733f5..d0ebf5a5e 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -77,6 +77,10 @@ class Account < ApplicationRecord
   has_many :mentions, inverse_of: :account, dependent: :destroy
   has_many :notifications, inverse_of: :account, dependent: :destroy
 
+  # Pinned statuses
+  has_many :status_pins, inverse_of: :account, dependent: :destroy
+  has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
+
   # Media
   has_many :media_attachments, dependent: :destroy
 
@@ -91,7 +95,7 @@ class Account < ApplicationRecord
   scope :local, -> { where(domain: nil) }
   scope :without_followers, -> { where(followers_count: 0) }
   scope :with_followers, -> { where('followers_count > 0') }
-  scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
+  scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
   scope :partitioned, -> { order('row_number() over (partition by domain)') }
   scope :silenced, -> { where(silenced: true) }
   scope :suspended, -> { where(suspended: true) }
@@ -105,6 +109,7 @@ class Account < ApplicationRecord
            :current_sign_in_ip,
            :current_sign_in_at,
            :confirmed?,
+           :admin?,
            :locale,
            to: :user,
            prefix: true,
@@ -133,11 +138,11 @@ class Account < ApplicationRecord
   end
 
   def keypair
-    OpenSSL::PKey::RSA.new(private_key || public_key)
+    @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
   end
 
   def subscription(webhook_url)
-    OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url)
+    @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
   end
 
   def save_with_optional_media!
@@ -171,6 +176,10 @@ class Account < ApplicationRecord
       reorder(nil).pluck('distinct accounts.domain')
     end
 
+    def inboxes
+      reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")
+    end
+
     def triadic_closures(account, limit: 5, offset: 0)
       sql = <<-SQL.squish
         WITH first_degree AS (
@@ -263,7 +272,7 @@ class Account < ApplicationRecord
   def generate_keys
     return unless local?
 
-    keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 1024 : 2048)
+    keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048)
     self.private_key = keypair.to_pem
     self.public_key  = keypair.public_key.to_pem
   end
diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb
index b0ec689a7..8a5c9a22c 100644
--- a/app/models/concerns/account_avatar.rb
+++ b/app/models/concerns/account_avatar.rb
@@ -8,7 +8,7 @@ module AccountAvatar
   class_methods do
     def avatar_styles(file)
       styles = { original: '120x120#' }
-      styles[:static] = { animated: false } if file.content_type == 'image/gif'
+      styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
       styles
     end
 
diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb
index 542e25abe..aff2aa3f9 100644
--- a/app/models/concerns/account_header.rb
+++ b/app/models/concerns/account_header.rb
@@ -8,7 +8,7 @@ module AccountHeader
   class_methods do
     def header_styles(file)
       styles = { original: '700x335#' }
-      styles[:static] = { animated: false } if file.content_type == 'image/gif'
+      styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
       styles
     end
 
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 9ffed2910..b26520f5b 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -138,4 +138,8 @@ module AccountInteractions
   def reblogged?(status)
     status.proper.reblogs.where(account: self).exists?
   end
+
+  def pinned?(status)
+    status_pins.where(status: status).exists?
+  end
 end
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index 1bd87a642..270043a9e 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -10,6 +10,8 @@ module Remotable
       alt_method_name = "reset_#{attachment_name}!".to_sym
 
       define_method method_name do |url|
+        return if url.blank?
+
         begin
           parsed_url = Addressable::URI.parse(url).normalize
         rescue Addressable::URI::InvalidURIError
diff --git a/app/models/import.rb b/app/models/import.rb
index 815e02589..4656c3af6 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -28,4 +28,5 @@ class Import < ApplicationRecord
 
   has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
   validates_attachment_content_type :data, content_type: FILE_TYPES
+  validates_attachment_presence :data
 end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 1e8c6d00a..d83ca44f1 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -142,9 +142,11 @@ class MediaAttachment < ApplicationRecord
 
   def populate_meta
     meta = {}
+
     file.queued_for_write.each do |style, file|
       begin
         geo = Paperclip::Geometry.from_file file
+
         meta[style] = {
           width: geo.width.to_i,
           height: geo.height.to_i,
@@ -155,6 +157,7 @@ class MediaAttachment < ApplicationRecord
         meta[style] = {}
       end
     end
+
     meta
   end
 
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index c334c48aa..b7efac354 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -4,16 +4,13 @@
 # Table name: preview_cards
 #
 #  id                 :integer          not null, primary key
-#  status_id          :integer
 #  url                :string           default(""), not null
-#  title              :string
-#  description        :string
+#  title              :string           default(""), not null
+#  description        :string           default(""), not null
 #  image_file_name    :string
 #  image_content_type :string
 #  image_file_size    :integer
 #  image_updated_at   :datetime
-#  created_at         :datetime         not null
-#  updated_at         :datetime         not null
 #  type               :integer          default("link"), not null
 #  html               :text             default(""), not null
 #  author_name        :string           default(""), not null
@@ -22,6 +19,8 @@
 #  provider_url       :string           default(""), not null
 #  width              :integer          default(0), not null
 #  height             :integer          default(0), not null
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
 #
 
 class PreviewCard < ApplicationRecord
@@ -31,21 +30,37 @@ class PreviewCard < ApplicationRecord
 
   enum type: [:link, :photo, :video, :rich]
 
-  belongs_to :status
+  has_and_belongs_to_many :statuses
 
-  has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :image, styles: { original: '280x120>' }, convert_options: { all: '-quality 80 -strip' }
 
   include Attachmentable
   include Remotable
 
-  validates :url, presence: true
+  validates :url, presence: true, uniqueness: true
   validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :image, less_than: 1.megabytes
 
+  before_save :extract_dimensions, if: :link?
+
   def save_with_optional_image!
     save!
   rescue ActiveRecord::RecordInvalid
     self.image = nil
     save!
   end
+
+  private
+
+  def extract_dimensions
+    file = image.queued_for_write[:original]
+
+    return if file.nil?
+
+    geo         = Paperclip::Geometry.from_file(file)
+    self.width  = geo.width.to_i
+    self.height = geo.height.to_i
+  rescue Paperclip::Errors::NotIdentifiedByImageMagickError
+    nil
+  end
 end
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 8366d43c5..c3f867743 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -42,7 +42,7 @@ class RemoteFollow
 
   def acct_resource
     @_acct_resource ||= Goldfinger.finger("acct:#{acct}")
-  rescue Goldfinger::Error
+  rescue Goldfinger::Error, HTTP::ConnectionError
     nil
   end
 
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 7eb16af8f..c1645223b 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -25,6 +25,7 @@
 #
 
 class SessionActivation < ApplicationRecord
+  belongs_to :user, inverse_of: :session_activations, required: true
   belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
   belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy
 
diff --git a/app/models/status.rb b/app/models/status.rb
index 24eaf7071..f44f79aaf 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -47,10 +47,12 @@ class Status < ApplicationRecord
   has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
   has_many :mentions, dependent: :destroy
   has_many :media_attachments, dependent: :destroy
+
   has_and_belongs_to_many :tags
+  has_and_belongs_to_many :preview_cards
 
   has_one :notification, as: :activity, dependent: :destroy
-  has_one :preview_card, dependent: :destroy
+  has_one :stream_entry, as: :activity, inverse_of: :status
 
   validates :uri, uniqueness: true, unless: :local?
   validates :text, presence: true, unless: :reblog?
@@ -90,7 +92,11 @@ class Status < ApplicationRecord
   end
 
   def verb
-    reblog? ? :share : :post
+    if destroyed?
+      :delete
+    else
+      reblog? ? :share : :post
+    end
   end
 
   def object_type
@@ -110,7 +116,11 @@ class Status < ApplicationRecord
   end
 
   def title
-    reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
+    if destroyed?
+      "#{account.acct} deleted status"
+    else
+      reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
+    end
   end
 
   def hidden?
@@ -164,6 +174,10 @@ class Status < ApplicationRecord
       ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h
     end
 
+    def pins_map(status_ids, account_id)
+      StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |p| [p.status_id, true] }.to_h
+    end
+
     def reload_stale_associations!(cached_items)
       account_ids = []
 
diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb
new file mode 100644
index 000000000..a72c19750
--- /dev/null
+++ b/app/models/status_pin.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_pins
+#
+#  id         :integer          not null, primary key
+#  account_id :integer          not null
+#  status_id  :integer          not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class StatusPin < ApplicationRecord
+  belongs_to :account, required: true
+  belongs_to :status, required: true
+
+  validates_with StatusPinValidator
+end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index bf643c1f9..14f1a140c 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -26,6 +26,7 @@ class Subscription < ApplicationRecord
 
   scope :confirmed, -> { where(confirmed: true) }
   scope :future_expiration, -> { where(arel_table[:expires_at].gt(Time.now.utc)) }
+  scope :expired, -> { where(arel_table[:expires_at].lt(Time.now.utc)) }
   scope :active, -> { confirmed.future_expiration }
 
   def lease_seconds=(value)
diff --git a/app/models/user.rb b/app/models/user.rb
index 96a2d09b7..5e548c1ef 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -46,6 +46,8 @@ class User < ApplicationRecord
   belongs_to :account, inverse_of: :user, required: true
   accepts_nested_attributes_for :account
 
+  has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
+
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
   validates_with BlacklistedEmailValidator, if: :email_changed?
 
@@ -108,10 +110,21 @@ class User < ApplicationRecord
     settings.noindex
   end
 
+  def token_for_app(a)
+    return nil if a.nil? || a.owner != self
+    Doorkeeper::AccessToken
+      .find_or_create_by(application_id: a.id, resource_owner_id: id) do |t|
+
+      t.scopes = a.scopes
+      t.expires_in = Doorkeeper.configuration.access_token_expires_in
+      t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled?
+    end
+  end
+
   def activate_session(request)
     session_activations.activate(session_id: SecureRandom.hex,
                                  user_agent: request.user_agent,
-                                 ip: request.ip).session_id
+                                 ip: request.remote_ip).session_id
   end
 
   def exclusive_session(id)
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index e76f61278..cb15dfa37 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -13,59 +13,14 @@
 #
 
 require 'webpush'
-require_relative '../../models/setting'
 
 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)
-    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
-    Webpush.payload_send(
-      message: JSON.generate(
-        title: title,
-        dir: dir,
-        image: image,
-        badge: full_asset_url('badge.png', skip_pipeline: true),
-        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,
-          message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
-        }
-      ),
-      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
-    )
+    I18n.with_locale(session_activation.user.locale || I18n.default_locale) do
+      push_payload(message_from(notification), 48.hours.seconds)
+    end
   end
 
   def pushable?(notification)
@@ -73,120 +28,47 @@ class Web::PushSubscription < ApplicationRecord
   end
 
   def as_payload
-    payload = {
-      id: id,
-      endpoint: endpoint,
-    }
-
+    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
+  def access_token
+    find_or_create_access_token.token
   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('web-push-icon_favourite.png', skip_pipeline: true),
-          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('web-push-icon_expand.png', skip_pipeline: true), todo: 'expand', action: 'expand')
-    end
-
-    if can_boost
-      actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true), 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
+  private
 
-  def dir_str(body)
-    rtl?(body) ? 'rtl' : 'ltr'
-  end
+  def push_payload(message, ttl = 5.minutes.seconds)
+    # TODO: Make sure that the payload does not
+    # exceed 4KB - Webpush::PayloadTooLarge
 
-  def send_welcome_notification
     Webpush.payload_send(
-      message: JSON.generate(
-        title: translate('push_notifications.subscribed.title'),
-        icon: full_asset_url('android-chrome-192x192.png', skip_pipeline: true),
-        badge: full_asset_url('badge.png', skip_pipeline: true),
-        data: {
-          content: translate('push_notifications.subscribed.body'),
-          actions: [],
-          url: web_url('notifications'),
-          message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
-        }
-      ),
+      message: Oj.dump(message),
       endpoint: endpoint,
       p256dh: key_p256dh,
       auth: key_auth,
+      ttl: ttl,
       vapid: {
-        subject: "mailto:#{Setting.site_contact_email}",
+        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)
+  def message_from(notification)
+    serializable_resource = ActiveModelSerializers::SerializableResource.new(notification, serializer: Web::NotificationSerializer, scope: self, scope_name: :current_push_subscription)
+    serializable_resource.as_json
+  end
+
+  def find_or_create_access_token
     Doorkeeper::AccessToken.find_or_create_for(
       Doorkeeper::Application.find_by(superapp: true),
-      notification.account.user.id,
+      session_activation.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/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb
index 9507aad4a..70c496be8 100644
--- a/app/presenters/initial_state_presenter.rb
+++ b/app/presenters/initial_state_presenter.rb
@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
 class InitialStatePresenter < ActiveModelSerializers::Model
-  attributes :settings, :push_subscription, :token, :current_account, :admin
+  attributes :settings, :push_subscription, :token,
+             :current_account, :admin, :text
 end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 4de6b5e0d..b1afb9e1f 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -40,4 +40,8 @@ class InstancePresenter
       ''
     end
   end
+
+  def source_url
+    Mastodon::Version.source_url
+  end
 end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 03294015f..10b449504 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -1,19 +1,24 @@
 # frozen_string_literal: true
 
 class StatusRelationshipsPresenter
-  attr_reader :reblogs_map, :favourites_map, :mutes_map
+  attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map
 
-  def initialize(statuses, current_account_id = nil, reblogs_map: {}, favourites_map: {}, mutes_map: {})
+  def initialize(statuses, current_account_id = nil, options = {})
     if current_account_id.nil?
       @reblogs_map    = {}
       @favourites_map = {}
       @mutes_map      = {}
+      @pins_map       = {}
     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).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)
+      statuses            = statuses.compact
+      status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
+      conversation_ids    = statuses.map(&:conversation_id).compact.uniq
+      pinnable_status_ids = statuses.map(&:proper).select { |s| s.account_id == current_account_id && %w(public unlisted).include?(s.visibility) }.map(&:id)
+
+      @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
+      @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
+      @mutes_map       = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
+      @pins_map        = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
     end
   end
 end
diff --git a/app/serializers/activitypub/accept_follow_serializer.rb b/app/serializers/activitypub/accept_follow_serializer.rb
index ce900bc78..3e23591a5 100644
--- a/app/serializers/activitypub/accept_follow_serializer.rb
+++ b/app/serializers/activitypub/accept_follow_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::FollowSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join
+  end
+
   def type
     'Accept'
   end
diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb
index 69e2160c5..349495e84 100644
--- a/app/serializers/activitypub/activity_serializer.rb
+++ b/app/serializers/activitypub/activity_serializer.rb
@@ -6,11 +6,11 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
   has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer
 
   def id
-    [ActivityPub::TagManager.instance.uri_for(object), '/activity'].join
+    [ActivityPub::TagManager.instance.activity_uri_for(object)].join
   end
 
   def type
-    object.reblog? ? 'Announce' : 'Create'
+    announce? ? 'Announce' : 'Create'
   end
 
   def actor
@@ -24,4 +24,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
   def cc
     ActivityPub::TagManager.instance.cc(object)
   end
+
+  def announce?
+    object.reblog?
+  end
 end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index f5e626d73..25521eca9 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -4,11 +4,29 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   include RoutingHelper
 
   attributes :id, :type, :following, :followers,
-             :inbox, :outbox, :preferred_username,
-             :name, :summary, :icon, :image
+             :inbox, :outbox, :shared_inbox,
+             :preferred_username, :name, :summary,
+             :url, :manually_approves_followers
 
   has_one :public_key, serializer: ActivityPub::PublicKeySerializer
 
+  class ImageSerializer < ActiveModel::Serializer
+    include RoutingHelper
+
+    attributes :type, :url
+
+    def type
+      'Image'
+    end
+
+    def url
+      full_asset_url(object.url(:original))
+    end
+  end
+
+  has_one :icon,  serializer: ImageSerializer, if: :avatar_exists?
+  has_one :image, serializer: ImageSerializer, if: :header_exists?
+
   def id
     account_url(object)
   end
@@ -26,13 +44,17 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   end
 
   def inbox
-    nil
+    account_inbox_url(object)
   end
 
   def outbox
     account_outbox_url(object)
   end
 
+  def shared_inbox
+    inbox_url
+  end
+
   def preferred_username
     object.username
   end
@@ -46,14 +68,30 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   end
 
   def icon
-    full_asset_url(object.avatar.url(:original))
+    object.avatar
   end
 
   def image
-    full_asset_url(object.header.url(:original))
+    object.header
   end
 
   def public_key
     object
   end
+
+  def url
+    short_account_url(object)
+  end
+
+  def avatar_exists?
+    object.avatar.exists?
+  end
+
+  def header_exists?
+    object.header.exists?
+  end
+
+  def manually_approves_followers
+    object.locked
+  end
 end
diff --git a/app/serializers/activitypub/block_serializer.rb b/app/serializers/activitypub/block_serializer.rb
index a001b213b..b3bd9f868 100644
--- a/app/serializers/activitypub/block_serializer.rb
+++ b/app/serializers/activitypub/block_serializer.rb
@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::BlockSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
+  end
+
   def type
     'Block'
   end
diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb
index 77098b1b0..87a43b95d 100644
--- a/app/serializers/activitypub/delete_serializer.rb
+++ b/app/serializers/activitypub/delete_serializer.rb
@@ -1,8 +1,29 @@
 # frozen_string_literal: true
 
 class ActivityPub::DeleteSerializer < ActiveModel::Serializer
-  attributes :type, :actor
-  attribute :virtual_object, key: :object
+  class TombstoneSerializer < ActiveModel::Serializer
+    attributes :id, :type, :atom_uri
+
+    def id
+      ActivityPub::TagManager.instance.uri_for(object)
+    end
+
+    def type
+      'Tombstone'
+    end
+
+    def atom_uri
+      ::TagManager.instance.uri_for(object)
+    end
+  end
+
+  attributes :id, :type, :actor
+
+  has_one :object, serializer: TombstoneSerializer
+
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join
+  end
 
   def type
     'Delete'
@@ -11,8 +32,4 @@ class ActivityPub::DeleteSerializer < ActiveModel::Serializer
   def actor
     ActivityPub::TagManager.instance.uri_for(object.account)
   end
-
-  def virtual_object
-    ActivityPub::TagManager.instance.uri_for(object)
-  end
 end
diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb
index 1953a2d7b..86c9992fe 100644
--- a/app/serializers/activitypub/follow_serializer.rb
+++ b/app/serializers/activitypub/follow_serializer.rb
@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::FollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
+  end
+
   def type
     'Follow'
   end
diff --git a/app/serializers/activitypub/like_serializer.rb b/app/serializers/activitypub/like_serializer.rb
index 4226913f5..c1a7ff6f6 100644
--- a/app/serializers/activitypub/like_serializer.rb
+++ b/app/serializers/activitypub/like_serializer.rb
@@ -1,9 +1,13 @@
 # frozen_string_literal: true
 
 class ActivityPub::LikeSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
   attribute :virtual_object, key: :object
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join
+  end
+
   def type
     'Like'
   end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 4c13f8e59..d42f54263 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,7 +3,9 @@
 class ActivityPub::NoteSerializer < ActiveModel::Serializer
   attributes :id, :type, :summary, :content,
              :in_reply_to, :published, :url,
-             :attributed_to, :to, :cc, :sensitive
+             :attributed_to, :to, :cc, :sensitive,
+             :atom_uri, :in_reply_to_atom_uri,
+             :conversation
 
   has_many :media_attachments, key: :attachment
   has_many :virtual_tags, key: :tag
@@ -25,7 +27,13 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
   end
 
   def in_reply_to
-    ActivityPub::TagManager.instance.uri_for(object.thread) if object.reply?
+    return unless object.reply?
+
+    if object.thread.uri.nil? || object.thread.uri.start_with?('http')
+      ActivityPub::TagManager.instance.uri_for(object.thread)
+    else
+      object.thread.url
+    end
   end
 
   def published
@@ -52,6 +60,30 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
     object.mentions + object.tags
   end
 
+  def atom_uri
+    return unless object.local?
+
+    ::TagManager.instance.uri_for(object)
+  end
+
+  def in_reply_to_atom_uri
+    return unless object.reply?
+
+    ::TagManager.instance.uri_for(object.thread)
+  end
+
+  def conversation
+    if object.conversation.uri?
+      object.conversation.uri
+    else
+      TagManager.instance.unique_tag(object.conversation.created_at, object.conversation.id, 'Conversation')
+    end
+  end
+
+  def local?
+    object.account.local?
+  end
+
   class MediaAttachmentSerializer < ActiveModel::Serializer
     include RoutingHelper
 
diff --git a/app/serializers/activitypub/reject_follow_serializer.rb b/app/serializers/activitypub/reject_follow_serializer.rb
index 28584d627..7814f4f57 100644
--- a/app/serializers/activitypub/reject_follow_serializer.rb
+++ b/app/serializers/activitypub/reject_follow_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::FollowSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join
+  end
+
   def type
     'Reject'
   end
diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb
new file mode 100644
index 000000000..839847e22
--- /dev/null
+++ b/app/serializers/activitypub/undo_announce_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class ActivityPub::UndoAnnounceSerializer < ActiveModel::Serializer
+  attributes :id, :type, :actor
+
+  has_one :object, serializer: ActivityPub::ActivitySerializer
+
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#announces/', object.id, '/undo'].join
+  end
+
+  def type
+    'Undo'
+  end
+
+  def actor
+    ActivityPub::TagManager.instance.uri_for(object.account)
+  end
+end
diff --git a/app/serializers/activitypub/undo_block_serializer.rb b/app/serializers/activitypub/undo_block_serializer.rb
index f71faa729..2f43d8402 100644
--- a/app/serializers/activitypub/undo_block_serializer.rb
+++ b/app/serializers/activitypub/undo_block_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UndoBlockSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::BlockSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id, '/undo'].join
+  end
+
   def type
     'Undo'
   end
diff --git a/app/serializers/activitypub/undo_follow_serializer.rb b/app/serializers/activitypub/undo_follow_serializer.rb
index fe91f5f1c..e5b7f143d 100644
--- a/app/serializers/activitypub/undo_follow_serializer.rb
+++ b/app/serializers/activitypub/undo_follow_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UndoFollowSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::FollowSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id, '/undo'].join
+  end
+
   def type
     'Undo'
   end
diff --git a/app/serializers/activitypub/undo_like_serializer.rb b/app/serializers/activitypub/undo_like_serializer.rb
index db9cd1d0d..25f4ccaae 100644
--- a/app/serializers/activitypub/undo_like_serializer.rb
+++ b/app/serializers/activitypub/undo_like_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UndoLikeSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::LikeSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id, '/undo'].join
+  end
+
   def type
     'Undo'
   end
diff --git a/app/serializers/activitypub/update_serializer.rb b/app/serializers/activitypub/update_serializer.rb
index 322305da8..ebc667d96 100644
--- a/app/serializers/activitypub/update_serializer.rb
+++ b/app/serializers/activitypub/update_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class ActivityPub::UpdateSerializer < ActiveModel::Serializer
-  attributes :type, :actor
+  attributes :id, :type, :actor
 
   has_one :object, serializer: ActivityPub::ActorSerializer
 
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.updated_at.to_i].join
+  end
+
   def type
     'Update'
   end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 0191948b1..32ffcc688 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -19,7 +19,6 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:boost_modal]    = object.current_account.user.setting_boost_modal
       store[:delete_modal]   = object.current_account.user.setting_delete_modal
       store[:auto_play_gif]  = object.current_account.user.setting_auto_play_gif
-      store[:system_font_ui] = object.current_account.user.setting_system_font_ui
     end
 
     store
@@ -34,6 +33,8 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:default_sensitive] = object.current_account.user.setting_default_sensitive
     end
 
+    store[:text] = object.text if object.text
+
     store
   end
 
diff --git a/app/serializers/oembed_serializer.rb b/app/serializers/oembed_serializer.rb
index 78376d253..4f9293043 100644
--- a/app/serializers/oembed_serializer.rb
+++ b/app/serializers/oembed_serializer.rb
@@ -21,7 +21,7 @@ class OEmbedSerializer < ActiveModel::Serializer
   end
 
   def author_url
-    account_url(object.account)
+    short_account_url(object.account)
   end
 
   def provider_name
@@ -37,13 +37,16 @@ class OEmbedSerializer < ActiveModel::Serializer
   end
 
   def html
-    tag :iframe,
-        src: embed_account_stream_entry_url(object.account, object),
-        style: 'width: 100%; overflow: hidden',
-        frameborder: '0',
-        scrolling: 'no',
-        width: width,
-        height: height
+    attributes = {
+      src: embed_short_account_status_url(object.account, object),
+      class: 'mastodon-embed',
+      frameborder: '0',
+      scrolling: 'no',
+      width: width,
+      height: height,
+    }
+
+    content_tag :iframe, nil, attributes
   end
 
   def width
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index 8e32f9cb3..a97137909 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -2,7 +2,7 @@
 
 class REST::InstanceSerializer < ActiveModel::Serializer
   attributes :uri, :title, :description, :email,
-             :version, :urls
+             :version, :urls, :stats
 
   def uri
     Rails.configuration.x.local_domain
@@ -24,7 +24,21 @@ class REST::InstanceSerializer < ActiveModel::Serializer
     Mastodon::Version.to_s
   end
 
+  def stats
+    {
+      user_count: instance_presenter.user_count,
+      status_count: instance_presenter.status_count,
+      domain_count: instance_presenter.domain_count,
+    }
+  end
+
   def urls
     { streaming_api: Rails.configuration.x.streaming_api_base_url }
   end
+
+  private
+
+  def instance_presenter
+    @instance_presenter ||= InstancePresenter.new
+  end
 end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 246b12a90..298a3bb40 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -8,6 +8,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :favourited, if: :current_user?
   attribute :reblogged, if: :current_user?
   attribute :muted, if: :current_user?
+  attribute :pinned, if: :pinnable?
 
   belongs_to :reblog, serializer: REST::StatusSerializer
   belongs_to :application
@@ -57,6 +58,21 @@ class REST::StatusSerializer < ActiveModel::Serializer
     end
   end
 
+  def pinned
+    if instance_options && instance_options[:relationships]
+      instance_options[:relationships].pins_map[object.id] || false
+    else
+      current_user.account.pinned?(object)
+    end
+  end
+
+  def pinnable?
+    current_user? &&
+      current_user.account_id == object.account_id &&
+      !object.reblog? &&
+      %w(public unlisted).include?(object.visibility)
+  end
+
   class ApplicationSerializer < ActiveModel::Serializer
     attributes :name, :website
   end
diff --git a/app/serializers/web/notification_serializer.rb b/app/serializers/web/notification_serializer.rb
new file mode 100644
index 000000000..e5524fe7a
--- /dev/null
+++ b/app/serializers/web/notification_serializer.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+
+class Web::NotificationSerializer < ActiveModel::Serializer
+  include RoutingHelper
+  include StreamEntriesHelper
+
+  class DataSerializer < ActiveModel::Serializer
+    include RoutingHelper
+    include StreamEntriesHelper
+    include ActionView::Helpers::SanitizeHelper
+
+    attributes :content, :nsfw, :url, :actions,
+               :access_token, :message, :dir
+
+    def content
+      decoder.decode(strip_tags(body))
+    end
+
+    def dir
+      rtl?(body) ? 'rtl' : 'ltr'
+    end
+
+    def nsfw
+      return if object.target_status.nil?
+      object.target_status.spoiler_text.presence
+    end
+
+    def url
+      case object.type
+      when :mention
+        web_url("statuses/#{object.target_status.id}")
+      when :follow
+        web_url("accounts/#{object.from_account.id}")
+      when :favourite
+        web_url("statuses/#{object.target_status.id}")
+      when :reblog
+        web_url("statuses/#{object.target_status.id}")
+      end
+    end
+
+    def actions
+      return @actions if defined?(@actions)
+
+      @actions = []
+
+      if object.type == :mention
+        @actions << expand_action if collapsed?
+        @actions << favourite_action
+        @actions << reblog_action if rebloggable?
+      end
+
+      @actions
+    end
+
+    def access_token
+      return if actions.empty?
+      current_push_subscription.access_token
+    end
+
+    def message
+      I18n.t('push_notifications.group.title')
+    end
+
+    private
+
+    def body
+      case object.type
+      when :mention
+        object.target_status.text
+      when :follow
+        object.from_account.note
+      when :favourite
+        object.target_status.text
+      when :reblog
+        object.target_status.text
+      end
+    end
+
+    def decoder
+      @decoder ||= HTMLEntities.new
+    end
+
+    def expand_action
+      {
+        title: I18n.t('push_notifications.mention.action_expand'),
+        icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true),
+        todo: 'expand',
+        action: 'expand',
+      }
+    end
+
+    def favourite_action
+      {
+        title: I18n.t('push_notifications.mention.action_favourite'),
+        icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true),
+        todo: 'request',
+        method: 'POST',
+        action: "/api/v1/statuses/#{object.target_status.id}/favourite",
+      }
+    end
+
+    def reblog_action
+      {
+        title: I18n.t('push_notifications.mention.action_boost'),
+        icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true),
+        todo: 'request',
+        method: 'POST',
+        action: "/api/v1/statuses/#{object.target_status.id}/reblog",
+      }
+    end
+
+    def collapsed?
+      !object.target_status.nil? && (object.target_status.sensitive? || object.target_status.spoiler_text.present?)
+    end
+
+    def rebloggable?
+      !object.target_status.nil? && !object.target_status.hidden?
+    end
+  end
+
+  attributes :title, :image, :badge, :tag,
+             :timestamp, :icon
+
+  has_one :data, serializer: DataSerializer
+
+  def title
+    case object.type
+    when :mention
+      I18n.t('push_notifications.mention.title', name: name)
+    when :follow
+      I18n.t('push_notifications.follow.title', name: name)
+    when :favourite
+      I18n.t('push_notifications.favourite.title', name: name)
+    when :reblog
+      I18n.t('push_notifications.reblog.title', name: name)
+    end
+  end
+
+  def image
+    return if object.target_status.nil? || object.target_status.media_attachments.empty?
+    full_asset_url(object.target_status.media_attachments.first.file.url(:small))
+  end
+
+  def badge
+    full_asset_url('badge.png', skip_pipeline: true)
+  end
+
+  def tag
+    object.id
+  end
+
+  def timestamp
+    object.created_at
+  end
+
+  def icon
+    object.from_account.avatar_static_url
+  end
+
+  def data
+    object
+  end
+
+  private
+
+  def name
+    display_name(object.from_account)
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
new file mode 100644
index 000000000..3eeca585e
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteAccountService < BaseService
+  include JsonLdHelper
+
+  # Should be called when uri has already been checked for locality
+  # Does a WebFinger roundtrip on each call
+  def call(uri, prefetched_json = nil)
+    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+    return unless supported_context? && expected_type?
+
+    @uri      = @json['id']
+    @username = @json['preferredUsername']
+    @domain   = Addressable::URI.parse(uri).normalized_host
+
+    return unless verified_webfinger?
+
+    ActivityPub::ProcessAccountService.new.call(@username, @domain, @json)
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def verified_webfinger?
+    webfinger                            = Goldfinger.finger("acct:#{@username}@#{@domain}")
+    confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+
+    return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
+
+    webfinger                            = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}")
+    confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+    self_reference                       = webfinger.link('self')
+
+    return false if self_reference&.href != @uri
+
+    @username = confirmed_username
+    @domain   = confirmed_domain
+
+    true
+  rescue Goldfinger::Error
+    false
+  end
+
+  def split_acct(acct)
+    acct.gsub(/\Aacct:/, '').split('@')
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def expected_type?
+    @json['type'] == 'Person'
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb
new file mode 100644
index 000000000..ebd64071e
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_key_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteKeyService < BaseService
+  include JsonLdHelper
+
+  # Returns account that owns the key
+  def call(uri, prefetched_json = nil)
+    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+    return unless supported_context?(@json) && expected_type?
+    return find_account(uri, @json) if person?
+
+    @owner = fetch_resource(owner_uri)
+
+    return unless supported_context?(@owner) && confirmed_owner?
+
+    find_account(owner_uri, @owner)
+  end
+
+  private
+
+  def find_account(uri, prefetched_json)
+    account   = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+    account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json)
+    account
+  end
+
+  def expected_type?
+    person? || public_key?
+  end
+
+  def person?
+    @json['type'] == 'Person'
+  end
+
+  def public_key?
+    @json['publicKeyPem'].present? && @json['owner'].present?
+  end
+
+  def owner_uri
+    @owner_uri ||= value_or_id(@json['owner'])
+  end
+
+  def confirmed_owner?
+    @owner['type'] == 'Person' && value_or_id(@owner['publicKey']) == @json['id']
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
new file mode 100644
index 000000000..68ca58d62
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteStatusService < BaseService
+  include JsonLdHelper
+
+  # Should be called when uri has already been checked for locality
+  def call(uri, prefetched_json = nil)
+    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+    return unless supported_context?
+
+    activity = activity_json
+    actor_id = value_or_id(activity['actor'])
+
+    return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id)
+
+    actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account)
+    actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil?
+
+    ActivityPub::Activity.factory(activity, actor).perform
+  end
+
+  private
+
+  def activity_json
+    if %w(Note Article).include? @json['type']
+      {
+        'type'   => 'Create',
+        'actor'  => first_of_value(@json['attributedTo']),
+        'object' => @json,
+      }
+    else
+      @json
+    end
+  end
+
+  def trustworthy_attribution?(uri, attributed_to)
+    Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def expected_type?(json)
+    %w(Create Announce).include? json['type']
+  end
+end
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
new file mode 100644
index 000000000..a26b39cb5
--- /dev/null
+++ b/app/services/activitypub/process_account_service.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessAccountService < BaseService
+  include JsonLdHelper
+
+  # Should be called with confirmed valid JSON
+  # and WebFinger-resolved username and domain
+  def call(username, domain, json)
+    return unless json['inbox'].present?
+
+    @json     = json
+    @uri      = @json['id']
+    @username = username
+    @domain   = domain
+    @account  = Account.find_by(uri: @uri)
+
+    create_account  if @account.nil?
+    upgrade_account if @account.ostatus?
+    update_account
+
+    @account
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def create_account
+    @account = Account.new
+    @account.protocol    = :activitypub
+    @account.username    = @username
+    @account.domain      = @domain
+    @account.uri         = @uri
+    @account.suspended   = true if auto_suspend?
+    @account.silenced    = true if auto_silence?
+    @account.private_key = nil
+    @account.save!
+  end
+
+  def update_account
+    @account.last_webfingered_at = Time.now.utc
+    @account.protocol            = :activitypub
+    @account.inbox_url           = @json['inbox'] || ''
+    @account.outbox_url          = @json['outbox'] || ''
+    @account.shared_inbox_url    = @json['sharedInbox'] || ''
+    @account.followers_url       = @json['followers'] || ''
+    @account.url                 = @json['url'] || @uri
+    @account.display_name        = @json['name'] || ''
+    @account.note                = @json['summary'] || ''
+    @account.avatar_remote_url   = image_url('icon')
+    @account.header_remote_url   = image_url('image')
+    @account.public_key          = public_key || ''
+    @account.locked              = @json['manuallyApprovesFollowers'] || false
+    @account.save!
+  end
+
+  def upgrade_account
+    ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
+  end
+
+  def image_url(key)
+    value = first_of_value(@json[key])
+
+    return if value.nil?
+    return @json[key]['url'] if @json[key].is_a?(Hash)
+
+    image = fetch_resource(value)
+    image['url'] if image
+  end
+
+  def public_key
+    value = first_of_value(@json['publicKey'])
+
+    return if value.nil?
+    return value['publicKeyPem'] if value.is_a?(Hash)
+
+    key = fetch_resource(value)
+    key['publicKeyPem'] if key
+  end
+
+  def auto_suspend?
+    domain_block && domain_block.suspend?
+  end
+
+  def auto_silence?
+    domain_block && domain_block.silence?
+  end
+
+  def domain_block
+    return @domain_block if defined?(@domain_block)
+    @domain_block = DomainBlock.find_by(domain: @domain)
+  end
+end
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
new file mode 100644
index 000000000..bc04c50ba
--- /dev/null
+++ b/app/services/activitypub/process_collection_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessCollectionService < BaseService
+  include JsonLdHelper
+
+  def call(body, account)
+    @account = account
+    @json    = Oj.load(body, mode: :strict)
+
+    return if @account.suspended? || !supported_context?
+
+    return if different_actor? && verify_account!.nil?
+
+    case @json['type']
+    when 'Collection', 'CollectionPage'
+      process_items @json['items']
+    when 'OrderedCollection', 'OrderedCollectionPage'
+      process_items @json['orderedItems']
+    else
+      process_items [@json]
+    end
+  rescue Oj::ParseError
+    nil
+  end
+
+  private
+
+  def different_actor?
+    @json['actor'].present? && value_or_id(@json['actor']) != @account.uri && @json['signature'].present?
+  end
+
+  def process_items(items)
+    items.reverse_each.map { |item| process_item(item) }.compact
+  end
+
+  def supported_context?
+    super(@json)
+  end
+
+  def process_item(item)
+    activity = ActivityPub::Activity.factory(item, @account)
+    activity&.perform
+  end
+
+  def verify_account!
+    @account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
+  end
+end
diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb
index 41815a393..b1bff8962 100644
--- a/app/services/authorize_follow_service.rb
+++ b/app/services/authorize_follow_service.rb
@@ -1,14 +1,36 @@
 # frozen_string_literal: true
 
 class AuthorizeFollowService < BaseService
-  def call(source_account, target_account)
-    follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
-    follow_request.authorize!
-    NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
+  def call(source_account, target_account, options = {})
+    if options[:skip_follow_request]
+      follow_request = FollowRequest.new(account: source_account, target_account: target_account)
+    else
+      follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
+      follow_request.authorize!
+    end
+
+    create_notification(follow_request) unless source_account.local?
+    follow_request
   end
 
   private
 
+  def create_notification(follow_request)
+    if follow_request.account.ostatus?
+      NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
+    elsif follow_request.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
+    end
+  end
+
+  def build_json(follow_request)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow_request,
+      serializer: ActivityPub::AcceptFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow_request.target_account))
+  end
+
   def build_xml(follow_request)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request))
   end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index ab810c628..86eaa5735 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -15,19 +15,26 @@ class BatchedRemoveStatusService < BaseService
     @mentions = statuses.map { |s| [s.id, s.mentions.includes(:account).to_a] }.to_h
     @tags     = statuses.map { |s| [s.id, s.tags.pluck(:name)] }.to_h
 
-    @stream_entry_batches = []
-    @salmon_batches       = []
-    @json_payloads        = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h
+    @stream_entry_batches  = []
+    @salmon_batches        = []
+    @activity_json_batches = []
+    @json_payloads         = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h
+    @activity_json         = {}
+    @activity_xml          = {}
 
     # Ensure that rendered XML reflects destroyed state
-    Status.where(id: statuses.map(&:id)).in_batches.destroy_all
+    statuses.each(&:destroy)
 
     # Batch by source account
     statuses.group_by(&:account_id).each do |_, account_statuses|
       account = account_statuses.first.account
 
       unpush_from_home_timelines(account_statuses)
-      batch_stream_entries(account_statuses) if account.local?
+
+      if account.local?
+        batch_stream_entries(account, account_statuses)
+        batch_activity_json(account, account_statuses)
+      end
     end
 
     # Cannot be batched
@@ -36,17 +43,32 @@ class BatchedRemoveStatusService < BaseService
       batch_salmon_slaps(status) if status.local?
     end
 
-    Pubsubhubbub::DistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
+    Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
     NotificationWorker.push_bulk(@salmon_batches) { |batch| batch }
+    ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch }
   end
 
   private
 
-  def batch_stream_entries(statuses)
-    stream_entry_ids = statuses.map { |s| s.stream_entry.id }
+  def batch_stream_entries(account, statuses)
+    statuses.each do |status|
+      @stream_entry_batches << [build_xml(status.stream_entry), account.id]
+    end
+  end
 
-    stream_entry_ids.each_slice(100) do |batch_of_stream_entry_ids|
-      @stream_entry_batches << [batch_of_stream_entry_ids]
+  def batch_activity_json(account, statuses)
+    account.followers.inboxes.each do |inbox_url|
+      statuses.each do |status|
+        @activity_json_batches << [build_json(status), account.id, inbox_url]
+      end
+    end
+
+    statuses.each do |status|
+      other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id)
+
+      other_recipients.each do |target_account|
+        @activity_json_batches << [build_json(status), account.id, target_account.inbox_url]
+      end
     end
   end
 
@@ -78,11 +100,10 @@ class BatchedRemoveStatusService < BaseService
   def batch_salmon_slaps(status)
     return if @mentions[status.id].empty?
 
-    payload    = stream_entry_to_xml(status.stream_entry.reload)
-    recipients = @mentions[status.id].map(&:account).reject(&:local?).uniq(&:domain).map(&:id)
+    recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id)
 
     recipients.each do |recipient_id|
-      @salmon_batches << [payload, status.account_id, recipient_id]
+      @salmon_batches << [build_xml(status.stream_entry), status.account_id, recipient_id]
     end
   end
 
@@ -111,4 +132,24 @@ class BatchedRemoveStatusService < BaseService
   def redis
     Redis.current
   end
+
+  def build_json(status)
+    return @activity_json[status.id] if @activity_json.key?(status.id)
+
+    @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new(
+      status,
+      serializer: status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json)
+  end
+
+  def build_xml(stream_entry)
+    return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)
+
+    @activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry)
+  end
+
+  def sign_json(status, json)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account))
+  end
 end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index a6b3c4cdb..1473bc841 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -30,7 +30,7 @@ class BlockDomainService < BaseService
 
   def suspend_accounts!
     blocked_domain_accounts.where(suspended: false).find_each do |account|
-      account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
+      UnsubscribeService.new.call(account) if account.subscribed?
       SuspendAccountService.new.call(account)
     end
   end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index 5d7bf6a3b..b39c3eef2 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -12,11 +12,28 @@ class BlockService < BaseService
     block = account.block!(target_account)
 
     BlockWorker.perform_async(account.id, target_account.id)
-    NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local?
+    create_notification(block) unless target_account.local?
+    block
   end
 
   private
 
+  def create_notification(block)
+    if block.target_account.ostatus?
+      NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id)
+    elsif block.target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url)
+    end
+  end
+
+  def build_json(block)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      block,
+      serializer: ActivityPub::BlockSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(block.account))
+  end
+
   def build_xml(block)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block))
   end
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 291f9e56e..44df3ed13 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -15,18 +15,32 @@ class FavouriteService < BaseService
     return favourite unless favourite.nil?
 
     favourite = Favourite.create!(account: account, status: status)
-
-    if status.local?
-      NotifyService.new.call(favourite.status.account, favourite)
-    else
-      NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id)
-    end
-
+    create_notification(favourite)
     favourite
   end
 
   private
 
+  def create_notification(favourite)
+    status = favourite.status
+
+    if status.account.local?
+      NotifyService.new.call(status.account, favourite)
+    elsif status.account.ostatus?
+      NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
+    elsif status.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
+    end
+  end
+
+  def build_json(favourite)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      favourite,
+      serializer: ActivityPub::LikeSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(favourite.account))
+  end
+
   def build_xml(favourite)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite))
   end
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 3ac441e3e..9c5777b5d 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -1,21 +1,17 @@
 # frozen_string_literal: true
 
 class FetchAtomService < BaseService
+  include JsonLdHelper
+
   def call(url)
     return if url.blank?
 
-    response = Request.new(:head, url).perform
-
-    Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
+    result = process(url)
 
-    response = Request.new(:get, url).perform if response.code == 405
+    # retry without ActivityPub
+    result ||= process(url) if @unsupported_activity
 
-    Rails.logger.debug "Remote status GET request returned code #{response.code}"
-
-    return nil if response.code != 200
-    return [url, fetch(url)] if response.mime_type == 'application/atom+xml'
-    return process_headers(url, response) if response['Link'].present?
-    process_html(fetch(url))
+    result
   rescue OpenSSL::SSL::SSLError => e
     Rails.logger.debug "SSL error: #{e}"
     nil
@@ -26,27 +22,67 @@ class FetchAtomService < BaseService
 
   private
 
-  def process_html(body)
-    Rails.logger.debug 'Processing HTML'
+  def process(url, terminal = false)
+    @url = url
+    perform_request
+    process_response(terminal)
+  end
+
+  def perform_request
+    accept = 'text/html'
+    accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity
+
+    @response = Request.new(:get, @url)
+                       .add_headers('Accept' => accept)
+                       .perform
+  end
 
-    page = Nokogiri::HTML(body)
-    alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
+  def process_response(terminal = false)
+    return nil if @response.code != 200
 
-    return nil if alternate_link.nil?
-    [alternate_link['href'], fetch(alternate_link['href'])]
+    if @response.mime_type == 'application/atom+xml'
+      [@url, @response.to_s, :ostatus]
+    elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type)
+      if supported_activity?(@response.to_s)
+        [@url, @response.to_s, :activitypub]
+      else
+        @unsupported_activity = true
+        nil
+      end
+    elsif @response['Link'] && !terminal
+      process_headers
+    elsif @response.mime_type == 'text/html' && !terminal
+      process_html
+    end
   end
 
-  def process_headers(url, response)
-    Rails.logger.debug 'Processing link header'
+  def process_html
+    page = Nokogiri::HTML(@response.to_s)
+
+    json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) }
+    atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
+
+    result ||= process(json_link['href'], terminal: true) unless json_link.nil? || @unsupported_activity
+    result ||= process(atom_link['href'], terminal: true) unless atom_link.nil?
+
+    result
+  end
+
+  def process_headers
+    link_header = LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
+
+    json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
+    atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
 
-    link_header    = LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link'])
-    alternate_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
+    result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity
+    result ||= process(atom_link.href, terminal: true) unless atom_link.nil?
 
-    return process_html(fetch(url)) if alternate_link.nil?
-    [alternate_link.href, fetch(alternate_link.href)]
+    result
   end
 
-  def fetch(url)
-    Request.new(:get, url).perform.to_s
+  def supported_activity?(body)
+    json = body_to_json(body)
+    return false unless supported_context?(json)
+    json['type'] == 'Person' ? json['inbox'].present? : true
   end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 20c85e0ea..c38e9e7df 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -4,29 +4,45 @@ class FetchLinkCardService < BaseService
   URL_PATTERN = %r{https?://\S+}
 
   def call(status)
-    # Get first http/https URL that isn't local
-    url = parse_urls(status)
+    @status = status
+    @url    = parse_urls
 
-    return if url.nil?
+    return if @url.nil? || @status.preview_cards.any?
 
-    url  = url.to_s
-    card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
-    res  = Request.new(:head, url).perform
+    @url = @url.to_s
 
-    return if res.code != 200 || res.mime_type != 'text/html'
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        @card = PreviewCard.find_by(url: @url)
+        process_url if @card.nil?
+      end
+    end
 
-    attempt_opengraph(card, url) unless attempt_oembed(card, url)
+    attach_card unless @card.nil?
   rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError
     nil
   end
 
   private
 
-  def parse_urls(status)
-    if status.local?
-      urls = status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
+  def process_url
+    @card = PreviewCard.new(url: @url)
+    res   = Request.new(:head, @url).perform
+
+    return if res.code != 200 || res.mime_type != 'text/html'
+
+    attempt_oembed || attempt_opengraph
+  end
+
+  def attach_card
+    @status.preview_cards << @card
+  end
+
+  def parse_urls
+    if @status.local?
+      urls = @status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
     else
-      html  = Nokogiri::HTML(status.text)
+      html  = Nokogiri::HTML(@status.text)
       links = html.css('a')
       urls  = links.map { |a| Addressable::URI.parse(a['href']).normalize unless skip_link?(a) }.compact
     end
@@ -44,41 +60,41 @@ class FetchLinkCardService < BaseService
     a['rel']&.include?('tag') || a['class']&.include?('u-url')
   end
 
-  def attempt_oembed(card, url)
-    response = OEmbed::Providers.get(url)
+  def attempt_oembed
+    response = OEmbed::Providers.get(@url)
 
-    card.type          = response.type
-    card.title         = response.respond_to?(:title)         ? response.title         : ''
-    card.author_name   = response.respond_to?(:author_name)   ? response.author_name   : ''
-    card.author_url    = response.respond_to?(:author_url)    ? response.author_url    : ''
-    card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
-    card.provider_url  = response.respond_to?(:provider_url)  ? response.provider_url  : ''
-    card.width         = 0
-    card.height        = 0
+    @card.type          = response.type
+    @card.title         = response.respond_to?(:title)         ? response.title         : ''
+    @card.author_name   = response.respond_to?(:author_name)   ? response.author_name   : ''
+    @card.author_url    = response.respond_to?(:author_url)    ? response.author_url    : ''
+    @card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
+    @card.provider_url  = response.respond_to?(:provider_url)  ? response.provider_url  : ''
+    @card.width         = 0
+    @card.height        = 0
 
-    case card.type
+    case @card.type
     when 'link'
-      card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
+      @card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
     when 'photo'
-      card.url    = response.url
-      card.width  = response.width.presence  || 0
-      card.height = response.height.presence || 0
+      @card.url    = response.url
+      @card.width  = response.width.presence  || 0
+      @card.height = response.height.presence || 0
     when 'video'
-      card.width  = response.width.presence  || 0
-      card.height = response.height.presence || 0
-      card.html   = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
+      @card.width  = response.width.presence  || 0
+      @card.height = response.height.presence || 0
+      @card.html   = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
     when 'rich'
       # Most providers rely on <script> tags, which is a no-no
       return false
     end
 
-    card.save_with_optional_image!
+    @card.save_with_optional_image!
   rescue OEmbed::NotFound
     false
   end
 
-  def attempt_opengraph(card, url)
-    response = Request.new(:get, url).perform
+  def attempt_opengraph
+    response = Request.new(:get, @url).perform
 
     return if response.code != 200 || response.mime_type != 'text/html'
 
@@ -88,19 +104,23 @@ class FetchLinkCardService < BaseService
     detector.strip_tags = true
 
     guess = detector.detect(html, response.charset)
-    page = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
+    page  = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
 
-    card.type             = :link
-    card.title            = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
-    card.description      = meta_property(page, 'og:description') || meta_property(page, 'description')
-    card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
+    @card.type             = :link
+    @card.title            = meta_property(page, 'og:title') || page.at_xpath('//title')&.content || ''
+    @card.description      = meta_property(page, 'og:description') || meta_property(page, 'description') || ''
+    @card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
 
-    return if card.title.blank?
+    return if @card.title.blank?
 
-    card.save_with_optional_image!
+    @card.save_with_optional_image!
   end
 
   def meta_property(html, property)
     html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
   end
+
+  def lock_options
+    { redis: Redis.current, key: "fetch:#{@url}" }
+  end
 end
diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb
index 8eed0d454..7c618a0b0 100644
--- a/app/services/fetch_remote_account_service.rb
+++ b/app/services/fetch_remote_account_service.rb
@@ -3,16 +3,20 @@
 class FetchRemoteAccountService < BaseService
   include AuthorExtractor
 
-  def call(url, prefetched_body = nil)
+  def call(url, prefetched_body = nil, protocol = :ostatus)
     if prefetched_body.nil?
-      atom_url, body = FetchAtomService.new.call(url)
+      resource_url, body, protocol = FetchAtomService.new.call(url)
     else
-      atom_url = url
-      body     = prefetched_body
+      resource_url = url
+      body         = prefetched_body
     end
 
-    return nil if atom_url.nil?
-    process_atom(atom_url, body)
+    case protocol
+    when :ostatus
+      process_atom(resource_url, body)
+    when :activitypub
+      ActivityPub::FetchRemoteAccountService.new.call(resource_url, body)
+    end
   end
 
   private
diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb
index 2c1c1f05f..341664272 100644
--- a/app/services/fetch_remote_resource_service.rb
+++ b/app/services/fetch_remote_resource_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class FetchRemoteResourceService < BaseService
+  include JsonLdHelper
+
   attr_reader :url
 
   def call(url)
@@ -14,11 +16,11 @@ class FetchRemoteResourceService < BaseService
   private
 
   def process_url
-    case xml_root
-    when 'feed'
-      FetchRemoteAccountService.new.call(atom_url, body)
-    when 'entry'
-      FetchRemoteStatusService.new.call(atom_url, body)
+    case type
+    when 'Person'
+      FetchRemoteAccountService.new.call(atom_url, body, protocol)
+    when 'Note'
+      FetchRemoteStatusService.new.call(atom_url, body, protocol)
     end
   end
 
@@ -31,7 +33,26 @@ class FetchRemoteResourceService < BaseService
   end
 
   def body
-    fetched_atom_feed.last
+    fetched_atom_feed.second
+  end
+
+  def protocol
+    fetched_atom_feed.third
+  end
+
+  def type
+    return json_data['type'] if protocol == :activitypub
+
+    case xml_root
+    when 'feed'
+      'Person'
+    when 'entry'
+      'Note'
+    end
+  end
+
+  def json_data
+    @_json_data ||= body_to_json(body)
   end
 
   def xml_root
diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb
index b9f5f97b1..18af18059 100644
--- a/app/services/fetch_remote_status_service.rb
+++ b/app/services/fetch_remote_status_service.rb
@@ -3,16 +3,20 @@
 class FetchRemoteStatusService < BaseService
   include AuthorExtractor
 
-  def call(url, prefetched_body = nil)
+  def call(url, prefetched_body = nil, protocol = :ostatus)
     if prefetched_body.nil?
-      atom_url, body = FetchAtomService.new.call(url)
+      resource_url, body, protocol = FetchAtomService.new.call(url)
     else
-      atom_url = url
-      body     = prefetched_body
+      resource_url = url
+      body         = prefetched_body
     end
 
-    return nil if atom_url.nil?
-    process_atom(atom_url, body)
+    case protocol
+    when :ostatus
+      process_atom(resource_url, body)
+    when :activitypub
+      ActivityPub::FetchRemoteStatusService.new.call(resource_url, body)
+    end
   end
 
   private
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 3155feaa4..a92eb6b88 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -14,7 +14,7 @@ class FollowService < BaseService
 
     return if source_account.following?(target_account)
 
-    if target_account.locked?
+    if target_account.locked? || target_account.activitypub?
       request_follow(source_account, target_account)
     else
       direct_follow(source_account, target_account)
@@ -28,9 +28,11 @@ class FollowService < BaseService
 
     if target_account.local?
       NotifyService.new.call(target_account, follow_request)
-    else
+    elsif target_account.ostatus?
       NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
       AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
+    elsif target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url)
     end
 
     follow_request
@@ -63,4 +65,12 @@ class FollowService < BaseService
   def build_follow_xml(follow)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_salmon(follow))
   end
+
+  def build_json(follow_request)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow_request,
+      serializer: ActivityPub::FollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow_request.account))
+  end
 end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 0ecd8a9cd..56011a005 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -42,6 +42,8 @@ class PostStatusService < BaseService
     # match both with and without U+FE0F (the emoji variation selector)
     unless /👁\ufe0f?\z/.match?(status.content)
       Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+      ActivityPub::DistributionWorker.perform_async(status.id)
+      ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local?
     end
 
     if options[:idempotency].present?
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index cc99cde03..d04e926e7 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -67,10 +67,13 @@ class ProcessInteractionService < BaseService
 
   def follow!(account, target_account)
     follow = account.follow!(target_account)
+    FollowRequest.find_by(account: account, target_account: target_account)&.destroy
     NotifyService.new.call(target_account, follow)
   end
 
   def follow_request!(account, target_account)
+    return if account.requested?(target_account)
+
     follow_request = FollowRequest.create!(account: account, target_account: target_account)
     NotifyService.new.call(target_account, follow_request)
   end
@@ -88,6 +91,7 @@ class ProcessInteractionService < BaseService
 
   def unfollow!(account, target_account)
     account.unfollow!(target_account)
+    FollowRequest.find_by(account: account, target_account: target_account)&.destroy
   end
 
   def reflect_block!(account, target_account)
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 438033d22..dc386c9e7 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -28,18 +28,32 @@ class ProcessMentionsService < BaseService
     end
 
     status.mentions.includes(:account).each do |mention|
-      mentioned_account = mention.account
-
-      if mentioned_account.local?
-        NotifyService.new.call(mentioned_account, mention)
-      else
-        NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
-      end
+      create_notification(status, mention)
     end
   end
 
   private
 
+  def create_notification(status, mention)
+    mentioned_account = mention.account
+
+    if mentioned_account.local?
+      NotifyService.new.call(mentioned_account, mention)
+    elsif mentioned_account.ostatus? && (Rails.configuration.x.use_ostatus_privacy || !status.stream_entry.hidden?)
+      NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
+    elsif mentioned_account.activitypub? && !mentioned_account.following?(status.account)
+      ActivityPub::DeliveryWorker.perform_async(build_json(mention.status), mention.status.account_id, mentioned_account.inbox_url)
+    end
+  end
+
+  def build_json(status)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      status,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(status.account))
+  end
+
   def follow_remote_account_service
     @follow_remote_account_service ||= ResolveRemoteAccountService.new
   end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 497cdb4f5..52e3ba0e0 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -20,17 +20,35 @@ class ReblogService < BaseService
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
 
     DistributionWorker.perform_async(reblog.id)
+
     unless /👁$/.match?(reblogged_status.content)
       Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
+      ActivityPub::DistributionWorker.perform_async(reblog.id)
     end
 
+    create_notification(reblog)
+    reblog
+  end
+
+  private
 
-    if reblogged_status.local?
-      NotifyService.new.call(reblog.reblog.account, reblog)
-    else
-      NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id)
+  def create_notification(reblog)
+    reblogged_status = reblog.reblog
+
+    if reblogged_status.account.local?
+      NotifyService.new.call(reblogged_status.account, reblog)
+    elsif reblogged_status.account.ostatus?
+      NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), reblog.account_id, reblogged_status.account_id)
+    elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
+      ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
     end
+  end
 
-    reblog
+  def build_json(reblog)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      reblog,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(reblog.account))
   end
 end
diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb
index fd7e66c23..c1f7bcb60 100644
--- a/app/services/reject_follow_service.rb
+++ b/app/services/reject_follow_service.rb
@@ -4,11 +4,28 @@ class RejectFollowService < BaseService
   def call(source_account, target_account)
     follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
     follow_request.reject!
-    NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
+    create_notification(follow_request) unless source_account.local?
+    follow_request
   end
 
   private
 
+  def create_notification(follow_request)
+    if follow_request.account.ostatus?
+      NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
+    elsif follow_request.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
+    end
+  end
+
+  def build_json(follow_request)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow_request,
+      serializer: ActivityPub::RejectFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow_request.target_account))
+  end
+
   def build_xml(follow_request)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request))
   end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index a5281f586..83fc77043 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -22,8 +22,8 @@ class RemoveStatusService < BaseService
 
     return unless @account.local?
 
-    remove_from_mentioned(@stream_entry.reload)
-    Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id)
+    remove_from_remote_followers
+    remove_from_remote_affected
   end
 
   private
@@ -38,15 +38,52 @@ class RemoveStatusService < BaseService
     end
   end
 
-  def remove_from_mentioned(stream_entry)
-    salmon_xml       = stream_entry_to_xml(stream_entry)
-    target_accounts  = @mentions.map(&:account).reject(&:local?).uniq(&:domain)
+  def remove_from_remote_affected
+    # People who got mentioned in the status, or who
+    # reblogged it from someone else might not follow
+    # the author and wouldn't normally receive the
+    # delete notification - so here, we explicitly
+    # send it to them
 
-    NotificationWorker.push_bulk(target_accounts) do |target_account|
-      [salmon_xml, stream_entry.account_id, target_account.id]
+    target_accounts = (@mentions.map(&:account).reject(&:local?) + @reblogs.map(&:account).reject(&:local?)).uniq(&:id)
+
+    # Ostatus
+    NotificationWorker.push_bulk(target_accounts.select(&:ostatus?).uniq(&:domain)) do |target_account|
+      [salmon_xml, @account.id, target_account.id]
+    end
+
+    # ActivityPub
+    ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |target_account|
+      [signed_activity_json, @account.id, target_account.inbox_url]
+    end
+  end
+
+  def remove_from_remote_followers
+    # OStatus
+    Pubsubhubbub::RawDistributionWorker.perform_async(salmon_xml, @account.id)
+
+    # ActivityPub
+    ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
+      [signed_activity_json, @account.id, inbox_url]
     end
   end
 
+  def salmon_xml
+    @salmon_xml ||= stream_entry_to_xml(@stream_entry)
+  end
+
+  def signed_activity_json
+    @signed_activity_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(activity_json).sign!(@account))
+  end
+
+  def activity_json
+    @activity_json ||= ActiveModelSerializers::SerializableResource.new(
+      @status,
+      serializer: @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+
   def remove_reblogs
     # We delete reblogs of the status before the original status,
     # because once original status is gone, reblogs will disappear
diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb
index e0e2ebc83..7031c98f5 100644
--- a/app/services/resolve_remote_account_service.rb
+++ b/app/services/resolve_remote_account_service.rb
@@ -2,6 +2,7 @@
 
 class ResolveRemoteAccountService < BaseService
   include OStatus2::MagicKey
+  include JsonLdHelper
 
   DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
 
@@ -12,6 +13,7 @@ class ResolveRemoteAccountService < BaseService
   # @return [Account]
   def call(uri, update_profile = true, redirected = nil)
     @username, @domain = uri.split('@')
+    @update_profile    = update_profile
 
     return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
 
@@ -42,10 +44,11 @@ class ResolveRemoteAccountService < BaseService
       if lock.acquired?
         @account = Account.find_remote(@username, @domain)
 
-        create_account if @account.nil?
-        update_account
-
-        update_account_profile if update_profile
+        if activitypub_ready?
+          handle_activitypub
+        else
+          handle_ostatus
+        end
       end
     end
 
@@ -58,18 +61,46 @@ class ResolveRemoteAccountService < BaseService
   private
 
   def links_missing?
-    @webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? ||
+    !(activitypub_ready? || ostatus_ready?)
+  end
+
+  def ostatus_ready?
+    !(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? ||
       @webfinger.link('salmon').nil? ||
       @webfinger.link('http://webfinger.net/rel/profile-page').nil? ||
       @webfinger.link('magic-public-key').nil? ||
       canonical_uri.nil? ||
-      hub_url.nil?
+      hub_url.nil?)
   end
 
   def webfinger_update_due?
     @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago
   end
 
+  def activitypub_ready?
+    !@webfinger.link('self').nil? &&
+      ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) &&
+      actor_json['inbox'].present?
+  end
+
+  def handle_ostatus
+    create_account if @account.nil?
+    update_account
+    update_account_profile if update_profile?
+  end
+
+  def update_profile?
+    @update_profile
+  end
+
+  def handle_activitypub
+    return if actor_json.nil?
+
+    @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json)
+  rescue Oj::ParseError
+    nil
+  end
+
   def create_account
     Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}"
 
@@ -81,6 +112,7 @@ class ResolveRemoteAccountService < BaseService
 
   def update_account
     @account.last_webfingered_at = Time.now.utc
+    @account.protocol            = :ostatus
     @account.remote_url          = atom_url
     @account.salmon_url          = salmon_url
     @account.url                 = url
@@ -111,6 +143,10 @@ class ResolveRemoteAccountService < BaseService
     @salmon_url ||= @webfinger.link('salmon').href
   end
 
+  def actor_url
+    @actor_url ||= @webfinger.link('self').href
+  end
+
   def url
     @url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href
   end
@@ -149,6 +185,13 @@ class ResolveRemoteAccountService < BaseService
     @atom_body = response.to_s
   end
 
+  def actor_json
+    return @actor_json if defined?(@actor_json)
+
+    json        = fetch_resource(actor_url)
+    @actor_json = supported_context?(json) && json['type'] == 'Person' ? json : nil
+  end
+
   def atom
     return @atom if defined?(@atom)
     @atom = Nokogiri::XML(atom_body)
diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb
index d3e41e691..bfa7ff8c8 100644
--- a/app/services/subscribe_service.rb
+++ b/app/services/subscribe_service.rb
@@ -2,7 +2,7 @@
 
 class SubscribeService < BaseService
   def call(account)
-    return unless account.ostatus?
+    return if account.hub_url.blank?
 
     @account        = account
     @account.secret = SecureRandom.hex
@@ -42,7 +42,7 @@ class SubscribeService < BaseService
   end
 
   def some_local_account
-    @some_local_account ||= Account.local.first
+    @some_local_account ||= Account.local.where(suspended: false).first
   end
 
   # Any response in the 3xx or 4xx range, except for 429 (rate limit)
diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb
index ff15c7275..869f62d1c 100644
--- a/app/services/unblock_service.rb
+++ b/app/services/unblock_service.rb
@@ -5,11 +5,28 @@ class UnblockService < BaseService
     return unless account.blocking?(target_account)
 
     unblock = account.unblock!(target_account)
-    NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local?
+    create_notification(unblock) unless target_account.local?
+    unblock
   end
 
   private
 
+  def create_notification(unblock)
+    if unblock.target_account.ostatus?
+      NotificationWorker.perform_async(build_xml(unblock), unblock.account_id, unblock.target_account_id)
+    elsif unblock.target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url)
+    end
+  end
+
+  def build_json(unblock)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      unblock,
+      serializer: ActivityPub::UndoBlockSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(unblock.account))
+  end
+
   def build_xml(block)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unblock_salmon(block))
   end
diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb
index 564aaee46..2fda11bd6 100644
--- a/app/services/unfavourite_service.rb
+++ b/app/services/unfavourite_service.rb
@@ -4,14 +4,30 @@ class UnfavouriteService < BaseService
   def call(account, status)
     favourite = Favourite.find_by!(account: account, status: status)
     favourite.destroy!
-
-    NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local?
-
+    create_notification(favourite) unless status.local?
     favourite
   end
 
   private
 
+  def create_notification(favourite)
+    status = favourite.status
+
+    if status.account.ostatus?
+      NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
+    elsif status.account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
+    end
+  end
+
+  def build_json(favourite)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      favourite,
+      serializer: ActivityPub::UndoLikeSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(favourite.account))
+  end
+
   def build_xml(favourite)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfavourite_salmon(favourite))
   end
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 388909586..73a64929f 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -5,14 +5,51 @@ class UnfollowService < BaseService
   # @param [Account] source_account Where to unfollow from
   # @param [Account] target_account Which to unfollow
   def call(source_account, target_account)
-    follow = source_account.unfollow!(target_account)
-    return unless follow
-    NotificationWorker.perform_async(build_xml(follow), source_account.id, target_account.id) unless target_account.local?
-    UnmergeWorker.perform_async(target_account.id, source_account.id)
+    @source_account = source_account
+    @target_account = target_account
+
+    unfollow! || undo_follow_request!
   end
 
   private
 
+  def unfollow!
+    follow = Follow.find_by(account: @source_account, target_account: @target_account)
+
+    return unless follow
+
+    follow.destroy!
+    create_notification(follow) unless @target_account.local?
+    UnmergeWorker.perform_async(@target_account.id, @source_account.id)
+    follow
+  end
+
+  def undo_follow_request!
+    follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account)
+
+    return unless follow_request
+
+    follow_request.destroy!
+    create_notification(follow_request) unless @target_account.local?
+    follow_request
+  end
+
+  def create_notification(follow)
+    if follow.target_account.ostatus?
+      NotificationWorker.perform_async(build_xml(follow), follow.account_id, follow.target_account_id)
+    elsif follow.target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url)
+    end
+  end
+
+  def build_json(follow)
+    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      follow,
+      serializer: ActivityPub::UndoFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json).sign!(follow.account))
+  end
+
   def build_xml(follow)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow))
   end
diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb
index c5e0e73fe..865f783bc 100644
--- a/app/services/unsubscribe_service.rb
+++ b/app/services/unsubscribe_service.rb
@@ -2,7 +2,7 @@
 
 class UnsubscribeService < BaseService
   def call(account)
-    return unless account.ostatus?
+    return if account.hub_url.blank?
 
     @account  = account
     @response = build_request.perform
diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb
new file mode 100644
index 000000000..09ea377e7
--- /dev/null
+++ b/app/services/update_account_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class UpdateAccountService < BaseService
+  def call(account, params, raise_error: false)
+    was_locked = account.locked
+    update_method = raise_error ? :update! : :update
+    account.send(update_method, params).tap do |ret|
+      next unless ret
+      authorize_all_follow_requests(account) if was_locked && !account.locked
+    end
+  end
+
+  private
+
+  def authorize_all_follow_requests(account)
+    follow_requests = FollowRequest.where(target_account: account)
+    AuthorizeFollowWorker.push_bulk(follow_requests) do |req|
+      [req.account_id, req.target_account_id]
+    end
+  end
+end
diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb
new file mode 100644
index 000000000..f557df6af
--- /dev/null
+++ b/app/validators/status_pin_validator.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class StatusPinValidator < ActiveModel::Validator
+  def validate(pin)
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog?
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted).include?(pin.status.visibility)
+  end
+end
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 6d621ce8b..99d7d2972 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -63,9 +63,9 @@
   .footer-links
     .container
       %p
-        = link_to t('about.source_code'), 'https://github.com/glitch-soc/mastodon'
+        = link_to t('about.source_code'), @instance_presenter.source_url
         - if @instance_presenter.commit_hash == ""
-          %strong= @instance_presenter.version_number
+          %strong= " (#{@instance_presenter.version_number})"
         - else
           %strong= "#{@instance_presenter.version_number}, "
           %strong= "#{@instance_presenter.commit_hash}"
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 3e04dd038..5962436fc 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -58,7 +58,7 @@
                 = @instance_presenter.closed_registrations_message.html_safe
             = link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block'
 
-  .learn-more-cta
+  .about-short
     .container
       %h3= t('about.description_headline', domain: site_hostname)
       %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
@@ -76,7 +76,7 @@
   .footer-links
     .container
       %p
-        = link_to t('about.source_code'), 'https://github.com/glitch-soc/mastodon'
+        = link_to t('about.source_code'), @instance_presenter.source_url
         - if @instance_presenter.commit_hash == ""
           %strong= " (#{@instance_presenter.version_number})"
         - else
diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml
index 0571d1d5e..305eb2c44 100644
--- a/app/views/accounts/_grid_card.html.haml
+++ b/app/views/accounts/_grid_card.html.haml
@@ -1,8 +1,9 @@
 .account-grid-card
-  .account-grid-card__header
+  .account-grid-card__header{ style: "background-image: url(#{account.header.url(:original)})" }
+  .account-grid-card__avatar
     .avatar= image_tag account.avatar.url(:original)
-    .name
-      = link_to TagManager.instance.url_for(account) do
-        %span.display_name.emojify= display_name(account)
-        %span.username @#{account.acct}
+  .name
+    = link_to TagManager.instance.url_for(account) do
+      %span.display_name.emojify= display_name(account)
+      %span.username @#{account.acct}
   %p.note.emojify= truncate(strip_tags(account.note), length: 150)
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index ed8a6f091..c16b7bf1f 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -1,41 +1,57 @@
 - processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account
 .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" }
-  .details
+  .card__illustration
     - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account)
       .controls
         - if current_account.following?(account)
-          = link_to t('accounts.unfollow'), account_unfollow_path(account), data: { method: :post }, class: 'button'
+          = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do
+            = fa_icon 'user-times'
+            = t('accounts.unfollow')
         - else
-          = link_to t('accounts.follow'), account_follow_path(account), data: { method: :post }, class: 'button'
+          = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do
+            = fa_icon 'user-plus'
+            = t('accounts.follow')
     - elsif !user_signed_in?
       .controls
         .remote-follow
-          = link_to t('accounts.remote_follow'), account_remote_follow_path(account), class: 'button'
+          = link_to account_remote_follow_path(account), class: 'icon-button' do
+            = fa_icon 'user-plus'
+            = t('accounts.remote_follow')
+
     .avatar= image_tag account.avatar.url(:original), class: 'u-photo'
+
+  .card__bio
     %h1.name
       %span.p-name.emojify= display_name(account)
       %small
-        %span @#{account.username}
+        %span @#{account.local_username_and_domain}
         = fa_icon('lock') if account.locked?
+
+    - if account.user_admin?
+      .roles
+        .account-role
+          = t 'accounts.roles.admin'
     .bio
       .account__header__content.p-note.emojify!=processed_bio[:text]
+      - if processed_bio[:metadata].length > 0
+        .metadata<
+          - processed_bio[:metadata].each do |i|
+            .metadata-item><
+              %b.emojify>!=i[0]
+              %span.emojify>!=i[1]
 
     .details-counters
       .counter{ class: active_nav_class(short_account_url(account)) }
         = link_to short_account_url(account), class: 'u-url u-uid' do
+          %span.counter-number= number_to_human account.statuses_count
           %span.counter-label= t('accounts.posts')
-          %span.counter-number= number_with_delimiter account.statuses_count
+
       .counter{ class: active_nav_class(account_following_index_url(account)) }
         = link_to account_following_index_url(account) do
+          %span.counter-number= number_to_human account.following_count
           %span.counter-label= t('accounts.following')
-          %span.counter-number= number_with_delimiter account.following_count
+
       .counter{ class: active_nav_class(account_followers_url(account)) }
         = link_to account_followers_url(account) do
+          %span.counter-number= number_to_human account.followers_count
           %span.counter-label= t('accounts.followers')
-          %span.counter-number= number_with_delimiter account.followers_count
-  - if processed_bio[:metadata].length > 0
-    .metadata<
-      - processed_bio[:metadata].each do |i|
-        .metadata-item><
-          %b.emojify>!=i[0]
-          %span.emojify>!=i[1]
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index 150c14791..e0f9f869a 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -7,6 +7,7 @@
 
   %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/
   %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
+  %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/
 
   %meta{ property: 'og:type', content: 'profile' }/
   = render 'og', account: @account, url: short_account_url(@account, only_path: false)
@@ -19,13 +20,21 @@
 
   = render 'header', account: @account
 
+  .activity-stream-tabs
+    = active_link_to t('accounts.posts'), short_account_url(@account)
+    = active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account)
+    = active_link_to t('accounts.media'), short_account_media_url(@account)
+
   - if @statuses.empty?
     .accounts-grid
       = render 'nothing_here'
   - else
     .activity-stream.with-header
+      - if params[:page].to_i.zero?
+        = render partial: 'stream_entries/status', collection: @pinned_statuses, as: :status, locals: { pinned: true }
+
       = render partial: 'stream_entries/status', collection: @statuses, as: :status
 
   - if @statuses.size == 20
     .pagination
-      = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), short_account_url(@account, max_id: @statuses.last.id), class: 'next', rel: 'next'
+      = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), @next_url, class: 'next', rel: 'next'
diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml
index c513776b7..5265d77f6 100644
--- a/app/views/admin/accounts/_account.html.haml
+++ b/app/views/admin/accounts/_account.html.haml
@@ -4,6 +4,9 @@
   %td.domain
     - unless account.local?
       = link_to account.domain, admin_accounts_path(by_domain: account.domain)
+  %td.protocol
+    - unless account.local?
+      = account.protocol.humanize
   %td.confirmed
     - if account.local?
       - if account.user_confirmed?
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 07c8d1632..1f36aeb31 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -55,6 +55,7 @@
     %tr
       %th= t('admin.accounts.username')
       %th= t('admin.accounts.domain')
+      %th= t('admin.accounts.protocol')
       %th= t('admin.accounts.confirmed')
       %th= fa_icon 'paper-plane-o'
       %th
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 5ad1fd6ee..dc2f16cc9 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -24,7 +24,8 @@
         %th= t('admin.accounts.most_recent_activity')
         %td
           - if @account.user_current_sign_in_at
-            = l @account.user_current_sign_in_at
+            %time.formatted{ datetime: @account.user_current_sign_in_at.iso8601, title: l(@account.user_current_sign_in_at) }
+              = l @account.user_current_sign_in_at
           - else
             Never
     - else
@@ -32,18 +33,31 @@
         %th= t('admin.accounts.profile_url')
         %td= link_to @account.url, @account.url
       %tr
-        %th= t('admin.accounts.feed_url')
-        %td= link_to @account.remote_url, @account.remote_url
-      %tr
-        %th= t('admin.accounts.push_subscription_expires')
-        %td
-          - if @account.subscribed?
-            = l @account.subscription_expires_at
-          - else
-            = t('admin.accounts.not_subscribed')
-      %tr
-        %th= t('admin.accounts.salmon_url')
-        %td= link_to @account.salmon_url, @account.salmon_url
+        %th= t('admin.accounts.protocol')
+        %td= @account.protocol.humanize
+
+      - if @account.ostatus?
+        %tr
+          %th= t('admin.accounts.feed_url')
+          %td= link_to @account.remote_url, @account.remote_url
+        %tr
+          %th= t('admin.accounts.push_subscription_expires')
+          %td
+            - if @account.subscribed?
+              %time.formatted{ datetime: @account.subscription_expires_at.iso8601, title: l(@account.subscription_expires_at) }
+                = l @account.subscription_expires_at
+            - else
+              = t('admin.accounts.not_subscribed')
+        %tr
+          %th= t('admin.accounts.salmon_url')
+          %td= link_to @account.salmon_url, @account.salmon_url
+      - elsif @account.activitypub?
+        %tr
+          %th= t('admin.accounts.inbox_url')
+          %td= link_to @account.inbox_url, @account.inbox_url
+        %tr
+          %th= t('admin.accounts.outbox_url')
+          %td= link_to @account.outbox_url, @account.outbox_url
 
     %tr
       %th= t('admin.accounts.follows')
@@ -74,9 +88,10 @@
     - if @account.user&.otp_required_for_login?
       = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button'
   - else
-    = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button'
-    - if @account.subscribed?
-      = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative'
+    - if @account.ostatus?
+      = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button'
+      - if @account.subscribed?
+        = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative'
     = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button'
 
 %div{ style: 'float: left' }
diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml
index f016a4883..145f5cd9e 100644
--- a/app/views/auth/registrations/edit.html.haml
+++ b/app/views/auth/registrations/edit.html.haml
@@ -1,7 +1,7 @@
 - content_for :page_title do
   = t('auth.change_password')
 
-= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f|
+= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: 'auth_edit' }) do |f|
   = render 'shared/error_messages', object: resource
 
   = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index ec6e53461..a13d0702b 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -10,4 +10,4 @@
     = image_tag asset_pack_path('logo.svg'), alt: 'Mastodon'
 
     %div
-      = t('errors.noscript')
+      = t('errors.noscript_html')
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 399d70bc0..a157090e0 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -37,7 +37,8 @@
 
     = yield :header_tags
 
-  - body_classes ||= @body_classes
+  - body_classes ||= @body_classes || ''
+  - body_classes += ' system-font' if current_account&.user&.setting_system_font_ui
 
   %body{ class: add_rtl_body_class(body_classes) }
     = content_for?(:content) ? yield(:content) : yield
diff --git a/app/views/settings/applications/_fields.html.haml b/app/views/settings/applications/_fields.html.haml
new file mode 100644
index 000000000..b21f3cca6
--- /dev/null
+++ b/app/views/settings/applications/_fields.html.haml
@@ -0,0 +1,21 @@
+.fields-group
+  = f.input :name, placeholder: t('activerecord.attributes.doorkeeper/application.name')
+  = f.input :website, placeholder: t('activerecord.attributes.doorkeeper/application.website')
+
+.fields-group
+  = f.input :redirect_uri, wrapper: :with_block_label, label: t('activerecord.attributes.doorkeeper/application.redirect_uri'), hint: t('doorkeeper.applications.help.redirect_uri')
+
+  %p.hint= t('doorkeeper.applications.help.native_redirect_uri', native_redirect_uri: Doorkeeper.configuration.native_redirect_uri)
+
+.field-group
+  = f.input :scopes,
+    label: t('activerecord.attributes.doorkeeper/application.scopes'),
+    collection: Doorkeeper.configuration.scopes,
+    wrapper: :with_label,
+    include_blank: false,
+    label_method: lambda { |scope| safe_join([scope, content_tag(:span, t("doorkeeper.scopes.#{scope}"), class: 'hint')]) },
+    selected: f.object.scopes.all,
+    required: false,
+    as: :check_boxes,
+    collection_wrapper_tag: 'ul',
+    item_wrapper_tag: 'li'
diff --git a/app/views/settings/applications/index.html.haml b/app/views/settings/applications/index.html.haml
new file mode 100644
index 000000000..eea550388
--- /dev/null
+++ b/app/views/settings/applications/index.html.haml
@@ -0,0 +1,19 @@
+- content_for :page_title do
+  = t('doorkeeper.applications.index.title')
+
+%table.table
+  %thead
+    %tr
+      %th= t('doorkeeper.applications.index.application')
+      %th= t('doorkeeper.applications.index.scopes')
+      %th
+  %tbody
+    - @applications.each do |application|
+      %tr
+        %td= link_to application.name, settings_application_path(application)
+        %th= application.scopes
+        %td
+          = table_link_to 'times', t('doorkeeper.applications.index.delete'), settings_application_path(application), method: :delete, data: { confirm: t('doorkeeper.applications.confirmations.destroy') }
+
+= paginate @applications
+= link_to t('doorkeeper.applications.index.new'), new_settings_application_path, class: 'button'
diff --git a/app/views/settings/applications/new.html.haml b/app/views/settings/applications/new.html.haml
new file mode 100644
index 000000000..5274a430c
--- /dev/null
+++ b/app/views/settings/applications/new.html.haml
@@ -0,0 +1,8 @@
+- content_for :page_title do
+  = t('doorkeeper.applications.new.title')
+
+= simple_form_for @application, url: settings_applications_path do |f|
+  = render 'fields', f: f
+  
+  .actions
+    = f.button :button, t('doorkeeper.applications.buttons.submit'), type: :submit
diff --git a/app/views/settings/applications/show.html.haml b/app/views/settings/applications/show.html.haml
new file mode 100644
index 000000000..4d8555111
--- /dev/null
+++ b/app/views/settings/applications/show.html.haml
@@ -0,0 +1,30 @@
+- content_for :page_title do
+  = t('doorkeeper.applications.show.title', name: @application.name)
+
+%p.hint= t('applications.warning')
+
+%table.table
+  %tbody
+    %tr  
+      %th= t('doorkeeper.applications.show.application_id')
+      %td
+        %code= @application.uid
+    %tr
+      %th= t('doorkeeper.applications.show.secret')
+      %td
+        %code= @application.secret
+    %tr
+      %th{ rowspan: 2}= t('applications.your_token')
+      %td
+        %code= current_user.token_for_app(@application).token
+    %tr
+      %td= table_link_to 'refresh', t('applications.regenerate_token'), regenerate_settings_application_path(@application), method: :post
+
+%hr/
+
+= simple_form_for @application, url: settings_application_path(@application), method: :put do |f|
+  = render 'fields', f: f
+    
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
+
diff --git a/app/views/shared/_landing_strip.html.haml b/app/views/shared/_landing_strip.html.haml
index 35461a8cb..ae26fc1ff 100644
--- a/app/views/shared/_landing_strip.html.haml
+++ b/app/views/shared/_landing_strip.html.haml
@@ -1,5 +1,8 @@
 .landing-strip
-  = t('landing_strip_html', name: content_tag(:span, display_name(account), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path))
+  = image_tag asset_pack_path('logo.svg'), class: 'logo'
 
-  - if open_registrations?
-    = t('landing_strip_signup_html', sign_up_path: new_user_registration_path)
+  %div
+    = t('landing_strip_html', name: content_tag(:span, display_name(account), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path))
+
+    - if open_registrations?
+      = t('landing_strip_signup_html', sign_up_path: new_user_registration_path)
diff --git a/app/views/shares/show.html.haml b/app/views/shares/show.html.haml
new file mode 100644
index 000000000..44b6f145f
--- /dev/null
+++ b/app/views/shares/show.html.haml
@@ -0,0 +1,5 @@
+- content_for :header_tags do
+  %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
+  = javascript_pack_tag 'share', integrity: true, crossorigin: 'anonymous'
+
+#mastodon-compose{ data: { props: Oj.dump(default_props) } }
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 157a7e7fb..ab803eebd 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -1,4 +1,9 @@
 .detailed-status.light
+  - if embedded_view?
+    = link_to "web+mastodon://follow?uri=#{status.account.local_username_and_domain}", class: 'button button-secondary logo-button', target: '_new' do
+      = render file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')
+      = t('accounts.follow')
+
   = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name p-author h-card', target: stream_link_target, rel: 'noopener' do
     %div
       .avatar
diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml
index 50a373743..e2e1fdd12 100644
--- a/app/views/stream_entries/_status.html.haml
+++ b/app/views/stream_entries/_status.html.haml
@@ -1,4 +1,5 @@
 :ruby
+  pinned          ||= false
   include_threads ||= false
   is_predecessor  ||= false
   is_successor    ||= false
@@ -25,6 +26,12 @@
         = link_to TagManager.instance.url_for(status.account), class: 'status__display-name muted' do
           %strong.emojify= display_name(status.account)
         = t('stream_entries.reblogged')
+  - elsif pinned
+    .pre-header
+      .pre-header__icon
+        = fa_icon('thumb-tack fw')
+      %span
+        = t('stream_entries.pinned')
 
   = render (centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status'), status: status.proper
 
diff --git a/app/views/stream_entries/embed.html.haml b/app/views/stream_entries/embed.html.haml
index 5df82528b..b703c15d2 100644
--- a/app/views/stream_entries/embed.html.haml
+++ b/app/views/stream_entries/embed.html.haml
@@ -1,2 +1,3 @@
-.activity-stream.activity-stream-headless
-  = render @type, @type.to_sym => @stream_entry.activity, centered: true
+- cache @stream_entry.activity do
+  .activity-stream.activity-stream-headless
+    = render "stream_entries/#{@type}", @type.to_sym => @stream_entry.activity, centered: true
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index 80ea30eb1..5ef72f804 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -4,6 +4,7 @@
 
   %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') }/
+  %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@stream_entry.activity) }/
 
   %meta{ property: 'og:site_name', content: site_title }/
   %meta{ property: 'og:type', content: 'article' }/
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
new file mode 100644
index 000000000..cd67b6710
--- /dev/null
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class ActivityPub::DeliveryWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push', retry: 5, dead: false
+
+  HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
+
+  def perform(json, source_account_id, inbox_url)
+    @json           = json
+    @source_account = Account.find(source_account_id)
+    @inbox_url      = inbox_url
+
+    perform_request
+
+    raise Mastodon::UnexpectedResponseError, @response unless response_successful?
+  rescue => e
+    raise e.class, "Delivery failed for #{inbox_url}: #{e.message}"
+  end
+
+  private
+
+  def build_request
+    request = Request.new(:post, @inbox_url, body: @json)
+    request.on_behalf_of(@source_account, :uri)
+    request.add_headers(HEADERS)
+  end
+
+  def perform_request
+    @response = build_request.perform
+  end
+
+  def response_successful?
+    @response.code > 199 && @response.code < 300
+  end
+end
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
new file mode 100644
index 000000000..14bb933c0
--- /dev/null
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class ActivityPub::DistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(status_id)
+    @status  = Status.find(status_id)
+    @account = @status.account
+
+    return if skip_distribution?
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [signed_payload, @account.id, inbox_url]
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def skip_distribution?
+    @status.direct_visibility?
+  end
+
+  def inboxes
+    @inboxes ||= @account.followers.inboxes
+  end
+
+  def signed_payload
+    @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account))
+  end
+
+  def payload
+    @payload ||= ActiveModelSerializers::SerializableResource.new(
+      @status,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+end
diff --git a/app/workers/activitypub/post_upgrade_worker.rb b/app/workers/activitypub/post_upgrade_worker.rb
new file mode 100644
index 000000000..4154b8582
--- /dev/null
+++ b/app/workers/activitypub/post_upgrade_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ActivityPub::PostUpgradeWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(domain)
+    Account.where(domain: domain)
+           .where(protocol: :ostatus)
+           .where.not(last_webfingered_at: nil)
+           .in_batches
+           .update_all(last_webfingered_at: nil)
+  end
+end
diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb
new file mode 100644
index 000000000..bb9adf64b
--- /dev/null
+++ b/app/workers/activitypub/processing_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessingWorker
+  include Sidekiq::Worker
+
+  sidekiq_options backtrace: true
+
+  def perform(account_id, body)
+    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id))
+  end
+end
diff --git a/app/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb
new file mode 100644
index 000000000..d73466f6e
--- /dev/null
+++ b/app/workers/activitypub/raw_distribution_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ActivityPub::RawDistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(json, source_account_id)
+    @account = Account.find(source_account_id)
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [json, @account.id, inbox_url]
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def inboxes
+    @inboxes ||= @account.followers.inboxes
+  end
+end
diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb
new file mode 100644
index 000000000..f9127340f
--- /dev/null
+++ b/app/workers/activitypub/reply_distribution_worker.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class ActivityPub::ReplyDistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(status_id)
+    @status  = Status.find(status_id)
+    @account = @status.thread.account
+
+    return if skip_distribution?
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [signed_payload, @status.account_id, inbox_url]
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def skip_distribution?
+    @status.private_visibility? || @status.direct_visibility?
+  end
+
+  def inboxes
+    @inboxes ||= @account.followers.inboxes
+  end
+
+  def signed_payload
+    @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@status.account))
+  end
+
+  def payload
+    @payload ||= ActiveModelSerializers::SerializableResource.new(
+      @status,
+      serializer: ActivityPub::ActivitySerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+end
diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb
new file mode 100644
index 000000000..f3377dcec
--- /dev/null
+++ b/app/workers/activitypub/update_distribution_worker.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class ActivityPub::UpdateDistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(account_id)
+    @account = Account.find(account_id)
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [payload, @account.id, inbox_url]
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def inboxes
+    @inboxes ||= @account.followers.inboxes
+  end
+
+  def payload
+    @payload ||= ActiveModelSerializers::SerializableResource.new(
+      @account,
+      serializer: ActivityPub::UpdateSerializer,
+      adapter: ActivityPub::Adapter
+    ).to_json
+  end
+end
diff --git a/app/workers/authorize_follow_worker.rb b/app/workers/authorize_follow_worker.rb
new file mode 100644
index 000000000..0d5014624
--- /dev/null
+++ b/app/workers/authorize_follow_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class AuthorizeFollowWorker
+  include Sidekiq::Worker
+
+  def perform(source_account_id, target_account_id)
+    source_account = Account.find(source_account_id)
+    target_account = Account.find(target_account_id)
+
+    AuthorizeFollowService.new.call(source_account, target_account)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb
index ea246128d..2a5e60fa0 100644
--- a/app/workers/pubsubhubbub/distribution_worker.rb
+++ b/app/workers/pubsubhubbub/distribution_worker.rb
@@ -14,7 +14,7 @@ class Pubsubhubbub::DistributionWorker
     @subscriptions = active_subscriptions.to_a
 
     distribute_public!(stream_entries.reject(&:hidden?))
-    distribute_hidden!(stream_entries.select(&:hidden?))
+    distribute_hidden!(stream_entries.select(&:hidden?)) if Rails.configuration.x.use_ostatus_privacy
   end
 
   private
diff --git a/app/workers/pubsubhubbub/raw_distribution_worker.rb b/app/workers/pubsubhubbub/raw_distribution_worker.rb
new file mode 100644
index 000000000..16962a623
--- /dev/null
+++ b/app/workers/pubsubhubbub/raw_distribution_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::RawDistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(xml, source_account_id)
+    @account       = Account.find(source_account_id)
+    @subscriptions = active_subscriptions.to_a
+
+    Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions) do |subscription|
+      [subscription.id, xml]
+    end
+  end
+
+  private
+
+  def active_subscriptions
+    Subscription.where(account: @account).active.select('id, callback_url, domain')
+  end
+end
diff --git a/app/workers/pubsubhubbub/unsubscribe_worker.rb b/app/workers/pubsubhubbub/unsubscribe_worker.rb
new file mode 100644
index 000000000..a271715b7
--- /dev/null
+++ b/app/workers/pubsubhubbub/unsubscribe_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::UnsubscribeWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push', retry: false, unique: :until_executed, dead: false
+
+  def perform(account_id)
+    account = Account.find(account_id)
+    logger.debug "PuSH unsubscribing from #{account.acct}"
+    ::UnsubscribeService.new.call(account)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb
index 402eed7c6..dbebaa2c3 100644
--- a/app/workers/scheduler/feed_cleanup_scheduler.rb
+++ b/app/workers/scheduler/feed_cleanup_scheduler.rb
@@ -5,8 +5,6 @@ class Scheduler::FeedCleanupScheduler
   include Sidekiq::Worker
 
   def perform
-    logger.info 'Cleaning out home feeds of inactive users'
-
     redis.pipelined do
       inactive_users.pluck(:account_id).each do |account_id|
         redis.del(FeedManager.instance.key(:home, account_id))
diff --git a/app/workers/scheduler/media_cleanup_scheduler.rb b/app/workers/scheduler/media_cleanup_scheduler.rb
index a95f512be..ce32ce314 100644
--- a/app/workers/scheduler/media_cleanup_scheduler.rb
+++ b/app/workers/scheduler/media_cleanup_scheduler.rb
@@ -5,7 +5,6 @@ class Scheduler::MediaCleanupScheduler
   include Sidekiq::Worker
 
   def perform
-    logger.info 'Cleaning out unattached media attachments'
     unattached_media.find_each(&:destroy)
   end
 
diff --git a/app/workers/scheduler/subscriptions_cleanup_scheduler.rb b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb
new file mode 100644
index 000000000..3b9211e81
--- /dev/null
+++ b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'sidekiq-scheduler'
+
+class Scheduler::SubscriptionsCleanupScheduler
+  include Sidekiq::Worker
+
+  def perform
+    Subscription.expired.in_batches.delete_all
+  end
+end
diff --git a/app/workers/scheduler/subscriptions_scheduler.rb b/app/workers/scheduler/subscriptions_scheduler.rb
index 7bfd002f4..469a3d2a6 100644
--- a/app/workers/scheduler/subscriptions_scheduler.rb
+++ b/app/workers/scheduler/subscriptions_scheduler.rb
@@ -7,8 +7,6 @@ class Scheduler::SubscriptionsScheduler
   include Sidekiq::Worker
 
   def perform
-    logger.info 'Queueing PuSH re-subscriptions'
-
     Pubsubhubbub::SubscribeWorker.push_bulk(expiring_accounts.pluck(:id))
   end