about summary refs log tree commit diff
diff options
context:
space:
mode:
authorShel R <Yiskah.Raphen@gmail.com>2017-04-07 21:54:32 -0400
committerGitHub <noreply@github.com>2017-04-07 21:54:32 -0400
commit7b7bff04df135aa76329696fbbceee9b80a70645 (patch)
treea535a961e2af14bdc013274717b8098516cfee64
parenta31f5765afb2598937b418cce0b5bacdf71c5ba8 (diff)
parente6596087977516270c8a805db962eb8b06e99d96 (diff)
Merge branch 'master' into master
-rw-r--r--.dockerignore1
-rw-r--r--.env.production.sample14
-rw-r--r--.slugignore3
-rw-r--r--Dockerfile12
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock2
-rw-r--r--Procfile2
-rw-r--r--README.md2
-rw-r--r--Vagrantfile16
-rw-r--r--app/assets/images/background-photo.jpegbin894792 -> 214464 bytes
-rw-r--r--app/controllers/accounts_controller.rb3
-rw-r--r--app/controllers/application_controller.rb9
-rw-r--r--app/controllers/concerns/localized.rb19
-rw-r--r--app/controllers/oauth/authorizations_controller.rb9
-rw-r--r--app/controllers/oauth/authorized_applications_controller.rb16
-rw-r--r--app/controllers/stream_entries_controller.rb4
-rw-r--r--app/helpers/stream_entries_helper.rb4
-rw-r--r--app/lib/atom_serializer.rb351
-rw-r--r--app/lib/feed_manager.rb11
-rw-r--r--app/lib/inline_renderer.rb13
-rw-r--r--app/lib/tag_manager.rb2
-rw-r--r--app/models/account.rb4
-rw-r--r--app/models/status.rb6
-rw-r--r--app/models/stream_entry.rb28
-rw-r--r--app/services/after_block_service.rb18
-rw-r--r--app/services/authorize_follow_service.rb27
-rw-r--r--app/services/block_service.rb18
-rw-r--r--app/services/concerns/stream_entry_renderer.rb3
-rw-r--r--app/services/fan_out_on_write_service.rb11
-rw-r--r--app/services/favourite_service.rb22
-rw-r--r--app/services/follow_remote_account_service.rb16
-rw-r--r--app/services/follow_service.rb44
-rw-r--r--app/services/notify_service.rb2
-rw-r--r--app/services/post_status_service.rb4
-rw-r--r--app/services/process_feed_service.rb6
-rw-r--r--app/services/process_interaction_service.rb10
-rw-r--r--app/services/reject_follow_service.rb27
-rw-r--r--app/services/remove_status_service.rb28
-rw-r--r--app/services/unblock_service.rb18
-rw-r--r--app/services/unfavourite_service.rb22
-rw-r--r--app/services/unfollow_service.rb21
-rw-r--r--app/views/accounts/show.atom.ruby27
-rw-r--r--app/views/layouts/application.html.haml6
-rw-r--r--app/views/oauth/authorized_applications/index.html.haml (renamed from app/views/doorkeeper/authorized_applications/index.html.haml)0
-rw-r--r--app/views/stream_entries/_status.html.haml2
-rw-r--r--app/views/stream_entries/show.atom.ruby9
-rw-r--r--app/views/user_mailer/confirmation_instructions.fi.html.erb5
-rw-r--r--app/views/user_mailer/confirmation_instructions.fi.text.erb5
-rw-r--r--app/views/user_mailer/password_change.fi.html.erb3
-rw-r--r--app/views/user_mailer/password_change.fi.text.erb3
-rw-r--r--app/views/user_mailer/reset_password_instructions.fi.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.fi.text.erb8
-rw-r--r--app/workers/admin/suspension_worker.rb2
-rw-r--r--app/workers/application_worker.rb2
-rw-r--r--app/workers/distribution_worker.rb5
-rw-r--r--app/workers/import_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb3
-rw-r--r--app/workers/pubsubhubbub/distribution_worker.rb8
-rw-r--r--app/workers/push_update_worker.rb15
-rw-r--r--app/workers/remote_profile_update_worker.rb20
-rw-r--r--app/workers/salmon_worker.rb2
-rw-r--r--config/initializers/rabl_init.rb1
-rw-r--r--config/locales/en.yml4
-rw-r--r--config/locales/fi.yml12
-rw-r--r--config/puma.rb2
-rw-r--r--config/routes.rb2
-rw-r--r--db/migrate/20170406215816_add_notifications_and_favourites_indices.rb7
-rw-r--r--db/schema.rb6
-rw-r--r--docs/Running-Mastodon/Administration-guide.md2
-rw-r--r--docs/Running-Mastodon/Heroku-guide.md53
-rw-r--r--docs/Running-Mastodon/Production-guide.md4
-rw-r--r--docs/Running-Mastodon/Scalingo-guide.md4
-rw-r--r--docs/Running-Mastodon/Vagrant-guide.md4
-rw-r--r--docs/Using-Mastodon/Apps.md1
-rw-r--r--docs/Using-Mastodon/FAQ.md3
-rw-r--r--docs/Using-Mastodon/List-of-Mastodon-instances.md8
-rw-r--r--docs/Using-Mastodon/User-guide.md64
-rw-r--r--scalingo.json2
-rw-r--r--spec/fabricators/media_attachment_fabricator.rb2
-rw-r--r--spec/fabricators/status_fabricator.rb1
-rw-r--r--spec/models/account_spec.rb68
-rw-r--r--spec/models/status_spec.rb25
-rw-r--r--spec/services/post_status_service_spec.rb168
-rw-r--r--spec/services/process_feed_service_spec.rb1
84 files changed, 997 insertions, 411 deletions
diff --git a/.dockerignore b/.dockerignore
index 7892e503c..21d1f59a1 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,3 +5,4 @@ public/assets
 node_modules
 storybook
 neo4j
+vendor/bundle
diff --git a/.env.production.sample b/.env.production.sample
index a7f9eb4bf..d7c04e235 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -25,7 +25,11 @@ OTP_SECRET=
 # Only allow registrations with the following e-mail domains
 # EMAIL_DOMAIN_WHITELIST=example1.com|example2.de|etc
 
+# Optionally change default language
+# DEFAULT_LOCALE=de
+
 # E-mail configuration
+# Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers
 SMTP_SERVER=smtp.mailgun.org
 SMTP_PORT=587
 SMTP_LOGIN=
@@ -44,6 +48,16 @@ SMTP_FROM_ADDRESS=notifications@example.com
 # S3_PROTOCOL=http
 # S3_HOSTNAME=192.168.1.123:9000
 
+# S3 (Minio Config (optional) Please check Minio instance for details)
+# S3_ENABLED=true
+# S3_BUCKET=
+# AWS_ACCESS_KEY_ID=
+# AWS_SECRET_ACCESS_KEY=
+# S3_REGION=
+# S3_PROTOCOL=https
+# S3_HOSTNAME=
+# S3_ENDPOINT=
+
 # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
 # S3_CLOUDFRONT_HOST=
 
diff --git a/.slugignore b/.slugignore
index cbf0615e7..b0141b0e2 100644
--- a/.slugignore
+++ b/.slugignore
@@ -1,2 +1,5 @@
 node_modules/
 .cache/
+docs/
+spec/
+storybook/
diff --git a/Dockerfile b/Dockerfile
index bcc911343..57a8f34e9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,16 @@
 FROM ruby:2.3.1-alpine
 
+LABEL maintainer="https://github.com/tootsuite/mastodon" \
+      description="A GNU Social-compatible microblogging server"
+
 ENV RAILS_ENV=production \
     NODE_ENV=production
 
+EXPOSE 3000 4000
+
 WORKDIR /mastodon
 
-COPY . /mastodon
+COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/
 
 RUN BUILD_DEPS=" \
     postgresql-dev \
@@ -24,8 +29,11 @@ RUN BUILD_DEPS=" \
  && npm install -g npm@3 && npm install -g yarn \
  && bundle install --deployment --without test development \
  && yarn \
- && npm cache clean \
+ && yarn cache clean \
+ && npm -g cache clean \
  && apk del $BUILD_DEPS \
  && rm -rf /tmp/* /var/cache/apk/*
 
+COPY . /mastodon
+
 VOLUME /mastodon/public/system /mastodon/public/assets
diff --git a/Gemfile b/Gemfile
index b5705e9d1..65bd5eb49 100644
--- a/Gemfile
+++ b/Gemfile
@@ -34,6 +34,7 @@ gem 'doorkeeper'
 gem 'rabl'
 gem 'rqrcode'
 gem 'twitter-text'
+gem 'ox'
 gem 'oj'
 gem 'hiredis'
 gem 'redis', '~>3.2', require: ['redis', 'redis/connection/hiredis']
diff --git a/Gemfile.lock b/Gemfile.lock
index 408d85ade..f2a199931 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -240,6 +240,7 @@ GEM
       addressable (~> 2.4)
       http (~> 2.0)
       nokogiri (~> 1.6)
+    ox (2.4.11)
     paperclip (5.1.0)
       activemodel (>= 4.2.0)
       activesupport (>= 4.2.0)
@@ -482,6 +483,7 @@ DEPENDENCIES
   nokogiri
   oj
   ostatus2
+  ox
   paperclip (~> 5.1)
   paperclip-av-transcoder
   pg
diff --git a/Procfile b/Procfile
index 6cdd89518..646e26059 100644
--- a/Procfile
+++ b/Procfile
@@ -1,2 +1,2 @@
 web: bundle exec puma -C config/puma.rb
-worker: bundle exec sidekiq -q default -q mailers -q push
+worker: bundle exec sidekiq -q default -q push -q pull -q mailers
diff --git a/README.md b/README.md
index db60b66f7..9b43e8077 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,8 @@ Consult the example configuration file, `.env.production.sample` for the full li
 
 ## Running with Docker and Docker-Compose
 
+[![](https://images.microbadger.com/badges/version/gargron/mastodon.svg)](https://microbadger.com/images/gargron/mastodon "Get your own version badge on microbadger.com") [![](https://images.microbadger.com/badges/image/gargron/mastodon.svg)](https://microbadger.com/images/gargron/mastodon "Get your own image badge on microbadger.com")
+
 The project now includes a `Dockerfile` and a `docker-compose.yml`. You need to turn `.env.production.sample` into `.env.production` with all the variables set before you can:
 
     docker-compose build
diff --git a/Vagrantfile b/Vagrantfile
index 154d0e895..cd7f74473 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -84,6 +84,16 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
   config.vm.provider :virtualbox do |vb|
     vb.name = "mastodon"
     vb.customize ["modifyvm", :id, "--memory", "1024"]
+
+    # Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions.
+    # https://github.com/mitchellh/vagrant/issues/1172
+    vb.customize ["modifyvm", :id, "--natdnsproxy1", "off"]
+    vb.customize ["modifyvm", :id, "--natdnshostresolver1", "off"]
+
+    # Use "virtio" network interfaces for better performance.
+    vb.customize ["modifyvm", :id, "--nictype1", "virtio"]
+    vb.customize ["modifyvm", :id, "--nictype2", "virtio"]
+
   end
 
   config.vm.hostname = "mastodon.dev"
@@ -91,12 +101,14 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
   # This uses the vagrant-hostsupdater plugin, and lets you
   # access the development site at http://mastodon.dev.
   # To install:
-  #   $ vagrant plugin install hostsupdater
+  #   $ vagrant plugin install vagrant-hostsupdater
   if defined?(VagrantPlugins::HostsUpdater)
-    config.vm.network :private_network, ip: "192.168.42.42"
+    config.vm.network :private_network, ip: "192.168.42.42", nictype: "virtio"
     config.hostsupdater.remove_on_suspend = false
   end
 
+  config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'vers=3', 'tcp']
+
   # Otherwise, you can access the site at http://localhost:3000
   config.vm.network :forwarded_port, guest: 80, host: 3000
 
diff --git a/app/assets/images/background-photo.jpeg b/app/assets/images/background-photo.jpeg
index b0a88ff35..d7937fd4b 100644
--- a/app/assets/images/background-photo.jpeg
+++ b/app/assets/images/background-photo.jpeg
Binary files differdiff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index dc1aeb5ea..619c04be2 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -16,7 +16,8 @@ class AccountsController < ApplicationController
       end
 
       format.atom do
-        @entries = @account.stream_entries.order('id desc').where(activity_type: 'Status').where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
+        @entries = @account.stream_entries.order('id desc').where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
+        render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a))
       end
 
       format.activitystreams2
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c06142fd4..f00f9c1e3 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class ApplicationController < ActionController::Base
+  include Localized
+
   # Prevent CSRF attacks by raising an exception.
   # For APIs, you may want to use :null_session instead.
   protect_from_forgery with: :exception
@@ -14,7 +16,6 @@ class ApplicationController < ActionController::Base
   rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
-  before_action :set_locale
   before_action :set_user_activity
   before_action :check_suspension, if: :user_signed_in?
 
@@ -28,12 +29,6 @@ class ApplicationController < ActionController::Base
     store_location_for(:user, request.url)
   end
 
-  def set_locale
-    I18n.locale = current_user.try(:locale) || I18n.default_locale
-  rescue I18n::InvalidLocale
-    I18n.locale = I18n.default_locale
-  end
-
   def require_admin!
     redirect_to root_path unless current_user&.admin?
   end
diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb
new file mode 100644
index 000000000..b6f868090
--- /dev/null
+++ b/app/controllers/concerns/localized.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Localized
+  extend ActiveSupport::Concern
+
+  included do
+    before_action :set_locale
+  end
+
+  def set_locale
+    I18n.locale = current_user.try(:locale) || default_locale
+  rescue I18n::InvalidLocale
+    I18n.locale = default_locale
+  end
+
+  def default_locale
+    ENV.fetch('DEFAULT_LOCALE') { I18n.default_locale }
+  end
+end
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index 7c25266d8..cdbfde0fb 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -1,9 +1,10 @@
 # frozen_string_literal: true
 
 class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
+  include Localized
+
   skip_before_action :authenticate_resource_owner!
 
-  before_action :set_locale
   before_action :store_current_location
   before_action :authenticate_resource_owner!
 
@@ -12,10 +13,4 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
   def store_current_location
     store_location_for(:user, request.url)
   end
-
-  def set_locale
-    I18n.locale = current_user.try(:locale) || I18n.default_locale
-  rescue I18n::InvalidLocale
-    I18n.locale = I18n.default_locale
-  end
 end
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
new file mode 100644
index 000000000..09dd5d3c4
--- /dev/null
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
+  include Localized
+
+  skip_before_action :authenticate_resource_owner!
+
+  before_action :store_current_location
+  before_action :authenticate_resource_owner!
+
+  private
+
+  def store_current_location
+    store_location_for(:user, request.url)
+  end
+end
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index de38b3602..469a8c33e 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -19,7 +19,9 @@ class StreamEntriesController < ApplicationController
         end
       end
 
-      format.atom
+      format.atom do
+        render xml: AtomSerializer.render(AtomSerializer.new.entry(@stream_entry, true))
+      end
     end
   end
 
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index a26e912a3..38e63ed8d 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -34,10 +34,6 @@ module StreamEntriesHelper
     user_signed_in? && @favourited.key?(status.id) ? 'favourited' : ''
   end
 
-  def proper_status(status)
-    status.reblog? ? status.reblog : status
-  end
-
   def rtl?(text)
     return false if text.empty?
 
diff --git a/app/lib/atom_serializer.rb b/app/lib/atom_serializer.rb
new file mode 100644
index 000000000..b9dcee6b3
--- /dev/null
+++ b/app/lib/atom_serializer.rb
@@ -0,0 +1,351 @@
+# frozen_string_literal: true
+
+class AtomSerializer
+  include RoutingHelper
+
+  class << self
+    def render(element)
+      document = Ox::Document.new(version: '1.0')
+      document << element
+      ('<?xml version="1.0"?>' + Ox.dump(element)).force_encoding('UTF-8')
+    end
+  end
+
+  def author(account)
+    author = Ox::Element.new('author')
+
+    uri = TagManager.instance.uri_for(account)
+
+    append_element(author, 'id', uri)
+    append_element(author, 'activity:object-type', TagManager::TYPES[:person])
+    append_element(author, 'uri', uri)
+    append_element(author, 'name', account.username)
+    append_element(author, 'email', account.local? ? "#{account.acct}@#{Rails.configuration.x.local_domain}" : account.acct)
+    append_element(author, 'summary', account.note)
+    append_element(author, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(account))
+    append_element(author, 'link', nil, rel: :avatar, type: account.avatar_content_type, 'media:width': 120, 'media:height': 120, href: full_asset_url(account.avatar.url(:original)))
+    append_element(author, 'link', nil, rel: :header, type: account.header_content_type, 'media:width': 700, 'media:height': 335, href: full_asset_url(account.header.url(:original)))
+    append_element(author, 'poco:preferredUsername', account.username)
+    append_element(author, 'poco:displayName', account.display_name) unless account.display_name.blank?
+    append_element(author, 'poco:note', Formatter.instance.simplified_format(account).to_str) unless account.note.blank?
+    append_element(author, 'mastodon:scope', account.locked? ? :private : :public)
+
+    author
+  end
+
+  def feed(account, stream_entries)
+    feed = Ox::Element.new('feed')
+
+    add_namespaces(feed)
+
+    append_element(feed, 'id', account_url(account, format: 'atom'))
+    append_element(feed, 'title', account.display_name)
+    append_element(feed, 'subtitle', account.note)
+    append_element(feed, 'updated', account.updated_at.iso8601)
+    append_element(feed, 'logo', full_asset_url(account.avatar.url(:original)))
+
+    feed << author(account)
+
+    append_element(feed, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(account))
+    append_element(feed, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_url(account, format: 'atom'))
+    append_element(feed, 'link', nil, rel: :next, type: 'application/atom+xml', href: account_url(account, format: 'atom', max_id: stream_entries.last.id)) if stream_entries.size == 20
+    append_element(feed, 'link', nil, rel: :hub, href: api_push_url)
+    append_element(feed, 'link', nil, rel: :salmon, href: api_salmon_url(account.id))
+
+    stream_entries.each do |stream_entry|
+      feed << entry(stream_entry)
+    end
+
+    feed
+  end
+
+  def entry(stream_entry, root = false)
+    entry = Ox::Element.new('entry')
+
+    add_namespaces(entry) if root
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type))
+    append_element(entry, 'published', stream_entry.created_at.iso8601)
+    append_element(entry, 'updated', stream_entry.updated_at.iso8601)
+    append_element(entry, 'title', stream_entry&.status&.title)
+
+    entry << author(stream_entry.account) if root
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[stream_entry.object_type])
+    append_element(entry, 'activity:verb', TagManager::VERBS[stream_entry.verb])
+
+    entry << object(stream_entry.target) if stream_entry.targeted?
+
+    serialize_status_attributes(entry, stream_entry.status) unless stream_entry.status.nil?
+
+    append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: account_stream_entry_url(stream_entry.account, stream_entry))
+    append_element(entry, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom'))
+    append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded?
+
+    entry
+  end
+
+  def object(status)
+    object = Ox::Element.new('activity:object')
+
+    append_element(object, 'id', TagManager.instance.uri_for(status))
+    append_element(object, 'published', status.created_at.iso8601)
+    append_element(object, 'updated', status.updated_at.iso8601)
+    append_element(object, 'title', status.title)
+
+    object << author(status.account)
+
+    append_element(object, 'activity:object-type', TagManager::TYPES[status.object_type])
+    append_element(object, 'activity:verb', TagManager::VERBS[status.verb])
+
+    serialize_status_attributes(object, status)
+
+    append_element(object, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(status))
+    append_element(object, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(status.thread), href: TagManager.instance.url_for(status.thread)) if status.reply? && !status.thread.nil?
+
+    object
+  end
+
+  def follow_salmon(follow)
+    entry = Ox::Element.new('entry')
+    add_namespaces(entry)
+
+    description = "#{follow.account.acct} started following #{follow.target_account.acct}"
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(follow.created_at, follow.id, 'Follow'))
+    append_element(entry, 'title', description)
+    append_element(entry, 'content', description, type: :html)
+
+    entry << author(follow.account)
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', TagManager::VERBS[:follow])
+
+    object = author(follow.target_account)
+    object.value = 'activity:object'
+
+    entry << object
+    entry
+  end
+
+  def follow_request_salmon(follow_request)
+    entry = Ox::Element.new('entry')
+    add_namespaces(entry)
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(follow_request.created_at, follow_request.id, 'FollowRequest'))
+    append_element(entry, 'title', "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}")
+
+    entry << author(follow_request.account)
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', TagManager::VERBS[:request_friend])
+
+    object = author(follow_request.target_account)
+    object.value = 'activity:object'
+
+    entry << object
+    entry
+  end
+
+  def authorize_follow_request_salmon(follow_request)
+    entry = Ox::Element.new('entry')
+    add_namespaces(entry)
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
+    append_element(entry, 'title', "#{follow_request.target_account.acct} authorizes follow request by #{follow_request.account.acct}")
+
+    entry << author(follow_request.target_account)
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', TagManager::VERBS[:authorize])
+
+    object = Ox::Element.new('activity:object')
+    object << author(follow_request.account)
+
+    append_element(object, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(object, 'activity:verb', TagManager::VERBS[:request_friend])
+
+    inner_object = author(follow_request.target_account)
+    inner_object.value = 'activity:object'
+
+    object << inner_object
+    entry  << object
+    entry
+  end
+
+  def reject_follow_request_salmon(follow_request)
+    entry = Ox::Element.new('entry')
+    add_namespaces(entry)
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
+    append_element(entry, 'title', "#{follow_request.target_account.acct} rejects follow request by #{follow_request.account.acct}")
+
+    entry << author(follow_request.target_account)
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', TagManager::VERBS[:reject])
+
+    object = Ox::Element.new('activity:object')
+    object << author(follow_request.account)
+
+    append_element(object, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(object, 'activity:verb', TagManager::VERBS[:request_friend])
+
+    inner_object = author(follow_request.target_account)
+    inner_object.value = 'activity:object'
+
+    object << inner_object
+    entry  << object
+    entry
+  end
+
+  def unfollow_salmon(follow)
+    entry = Ox::Element.new('entry')
+    add_namespaces(entry)
+
+    description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow.id, 'Follow'))
+    append_element(entry, 'title', description)
+    append_element(entry, 'content', description, type: :html)
+
+    entry << author(follow.account)
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', TagManager::VERBS[:unfollow])
+
+    object = author(follow.target_account)
+    object.value = 'activity:object'
+
+    entry << object
+    entry
+  end
+
+  def block_salmon(block)
+    entry = Ox::Element.new('entry')
+    add_namespaces(entry)
+
+    description = "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}"
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
+    append_element(entry, 'title', description)
+
+    entry << author(block.account)
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', TagManager::VERBS[:block])
+
+    object = author(block.target_account)
+    object.value = 'activity:object'
+
+    entry << object
+    entry
+  end
+
+  def unblock_salmon(block)
+    entry = Ox::Element.new('entry')
+    add_namespaces(entry)
+
+    description = "#{block.account.acct} no longer blocks #{block.target_account.acct}"
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
+    append_element(entry, 'title', description)
+
+    entry << author(block.account)
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', TagManager::VERBS[:unblock])
+
+    object = author(block.target_account)
+    object.value = 'activity:object'
+
+    entry << object
+    entry
+  end
+
+  def favourite_salmon(favourite)
+    entry = Ox::Element.new('entry')
+    add_namespaces(entry)
+
+    description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}"
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(favourite.created_at, favourite.id, 'Favourite'))
+    append_element(entry, 'title', description)
+    append_element(entry, 'content', description, type: :html)
+
+    entry << author(favourite.account)
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', TagManager::VERBS[:favorite])
+
+    entry << object(favourite.status)
+
+    append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
+
+    entry
+  end
+
+  def unfavourite_salmon(favourite)
+    entry = Ox::Element.new('entry')
+    add_namespaces(entry)
+
+    description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}"
+
+    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, favourite.id, 'Favourite'))
+    append_element(entry, 'title', description)
+    append_element(entry, 'content', description, type: :html)
+
+    entry << author(favourite.account)
+
+    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', TagManager::VERBS[:unfavorite])
+
+    entry << object(favourite.status)
+
+    append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
+
+    entry
+  end
+
+  private
+
+  def append_element(parent, name, content = nil, attributes = {})
+    element = Ox::Element.new(name)
+    attributes.each { |k, v| element[k] = v.to_s }
+    element << content.to_s unless content.nil?
+    parent  << element
+  end
+
+  def add_namespaces(parent)
+    parent['xmlns']          = TagManager::XMLNS
+    parent['xmlns:thr']      = TagManager::THR_XMLNS
+    parent['xmlns:activity'] = TagManager::AS_XMLNS
+    parent['xmlns:poco']     = TagManager::POCO_XMLNS
+    parent['xmlns:media']    = TagManager::MEDIA_XMLNS
+    parent['xmlns:ostatus']  = TagManager::OS_XMLNS
+    parent['xmlns:mastodon'] = TagManager::MTDN_XMLNS
+  end
+
+  def serialize_status_attributes(entry, status)
+    append_element(entry, 'summary', status.spoiler_text) unless status.spoiler_text.blank?
+    append_element(entry, 'content', Formatter.instance.format(status.proper).to_str, type: 'html')
+
+    status.mentions.each do |mentioned|
+      append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:person], href: TagManager.instance.uri_for(mentioned.account))
+    end
+
+    append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:collection], href: TagManager::COLLECTIONS[:public]) if status.public_visibility?
+
+    status.tags.each do |tag|
+      append_element(entry, 'category', nil, term: tag.name)
+    end
+
+    append_element(entry, 'category', nil, term: 'nsfw') if status.sensitive?
+
+    status.media_attachments.each do |media|
+      append_element(entry, 'link', nil, rel: :enclosure, type: media.file_content_type, length: media.file_file_size, href: full_asset_url(media.file.url(:original, false)))
+    end
+
+    append_element(entry, 'mastodon:scope', status.visibility)
+  end
+end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 88f6f4a46..58d9fb1fc 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -34,12 +34,7 @@ class FeedManager
       trim(timeline_type, account.id)
     end
 
-    broadcast(account.id, event: 'update', payload: inline_render(account, 'api/v1/statuses/show', status))
-  end
-
-  def broadcast(timeline_id, options = {})
-    options[:queued_at] = (Time.now.to_f * 1000.0).to_i
-    redis.publish("timeline:#{timeline_id}", Oj.dump(options))
+    PushUpdateWorker.perform_async(account.id, status.id)
   end
 
   def trim(type, account_id)
@@ -81,10 +76,6 @@ class FeedManager
     end
   end
 
-  def inline_render(target_account, template, object)
-    Rabl::Renderer.new(template, object, view_path: 'app/views', format: :json, scope: InlineRablScope.new(target_account)).render
-  end
-
   private
 
   def redis
diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb
new file mode 100644
index 000000000..8e04ad1d5
--- /dev/null
+++ b/app/lib/inline_renderer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class InlineRenderer
+  def self.render(status, current_account, template)
+    Rabl::Renderer.new(
+      template,
+      status,
+      view_path: 'app/views',
+      format: :json,
+      scope: InlineRablScope.new(current_account)
+    ).render
+  end
+end
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index 2a5e7a409..07b2fb91e 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -78,6 +78,8 @@ class TagManager
     case target.object_type
     when :person
       account_url(target)
+    when :note, :comment, :activity
+      unique_tag(target.created_at, target.id, 'Status')
     else
       unique_tag(target.stream_entry.created_at, target.stream_entry.activity_id, target.stream_entry.activity_type)
     end
diff --git a/app/models/account.rb b/app/models/account.rb
index 6968607a2..cbba8b5b6 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -125,11 +125,11 @@ class Account < ApplicationRecord
   end
 
   def favourited?(status)
-    (status.reblog? ? status.reblog : status).favourites.where(account: self).count.positive?
+    status.proper.favourites.where(account: self).count.positive?
   end
 
   def reblogged?(status)
-    (status.reblog? ? status.reblog : status).reblogs.where(account: self).count.positive?
+    status.proper.reblogs.where(account: self).count.positive?
   end
 
   def keypair
diff --git a/app/models/status.rb b/app/models/status.rb
index 6948ad77c..7e3dd3e28 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -62,8 +62,12 @@ class Status < ApplicationRecord
     reply? ? :comment : :note
   end
 
+  def proper
+    reblog? ? reblog : self
+  end
+
   def content
-    reblog? ? reblog.text : text
+    proper.text
   end
 
   def target
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index ae7ae446e..8aff5ae06 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -5,25 +5,21 @@ class StreamEntry < ApplicationRecord
 
   belongs_to :account, inverse_of: :stream_entries
   belongs_to :activity, polymorphic: true
-
   belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id', inverse_of: :stream_entry
 
   validates :account, :activity, presence: true
 
-  STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze
+  STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, :media_attachments, :tags, mentions: :account], thread: [:stream_entry, :account]].freeze
 
+  default_scope { where(activity_type: 'Status') }
   scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) }
 
   def object_type
-    if orphaned?
-      :activity
-    else
-      targeted? ? :activity : activity.object_type
-    end
+    orphaned? || targeted? ? :activity : status.object_type
   end
 
   def verb
-    orphaned? ? :delete : activity.verb
+    orphaned? ? :delete : status.verb
   end
 
   def targeted?
@@ -31,15 +27,15 @@ class StreamEntry < ApplicationRecord
   end
 
   def target
-    orphaned? ? nil : activity.target
+    orphaned? ? nil : status.target
   end
 
   def title
-    orphaned? ? nil : activity.title
+    orphaned? ? nil : status.title
   end
 
   def content
-    orphaned? ? nil : activity.content
+    orphaned? ? nil : status.content
   end
 
   def threaded?
@@ -47,20 +43,16 @@ class StreamEntry < ApplicationRecord
   end
 
   def thread
-    orphaned? ? nil : activity.thread
+    orphaned? ? nil : status.thread
   end
 
   def mentions
-    activity.respond_to?(:mentions) ? activity.mentions.map(&:account) : []
-  end
-
-  def activity
-    !new_record? ? send(activity_type.underscore) || super : super
+    orphaned? ? [] : status.mentions.map(&:account)
   end
 
   private
 
   def orphaned?
-    activity.nil?
+    status.nil?
   end
 end
diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb
index 8c6197f2c..0f478bcb7 100644
--- a/app/services/after_block_service.rb
+++ b/app/services/after_block_service.rb
@@ -9,20 +9,20 @@ class AfterBlockService < BaseService
   private
 
   def clear_timelines(account, target_account)
-    mentions_key = FeedManager.instance.key(:mentions, account.id)
-    home_key     = FeedManager.instance.key(:home, account.id)
+    home_key = FeedManager.instance.key(:home, account.id)
 
-    target_account.statuses.select('id').find_each do |status|
-      redis.zrem(mentions_key, status.id)
-      redis.zrem(home_key, status.id)
+    redis.pipelined do
+      target_account.statuses.select('id').find_each do |status|
+        redis.zrem(home_key, status.id)
+      end
     end
   end
 
   def clear_notifications(account, target_account)
-    Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all
-    Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all
-    Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all
-    Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all
+    Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).delete_all
+    Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).delete_all
+    Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).delete_all
+    Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).delete_all
   end
 
   def redis
diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb
index ac465bdb2..97c76bee1 100644
--- a/app/services/authorize_follow_service.rb
+++ b/app/services/authorize_follow_service.rb
@@ -10,31 +10,6 @@ class AuthorizeFollowService < BaseService
   private
 
   def build_xml(follow_request)
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        unique_id xml, Time.now.utc, follow_request.id, 'FollowRequest'
-        title xml, "#{follow_request.target_account.acct} authorizes follow request by #{follow_request.account.acct}"
-
-        author(xml) do
-          include_author xml, follow_request.target_account
-        end
-
-        object_type xml, :activity
-        verb xml, :authorize
-
-        target(xml) do
-          author(xml) do
-            include_author xml, follow_request.account
-          end
-
-          object_type xml, :activity
-          verb xml, :request_friend
-
-          target(xml) do
-            include_author xml, follow_request.target_account
-          end
-        end
-      end
-    end.to_xml
+    AtomSerializer.render(AtomSerializer.new.authorize_follow_request_salmon(follow_request))
   end
 end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index bd914d8be..d59b47afb 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -18,22 +18,6 @@ class BlockService < BaseService
   private
 
   def build_xml(block)
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        unique_id xml, block.created_at, block.id, 'Block'
-        title xml, "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}"
-
-        author(xml) do
-          include_author xml, block.account
-        end
-
-        object_type xml, :activity
-        verb xml, :block
-
-        target(xml) do
-          include_author xml, block.target_account
-        end
-      end
-    end.to_xml
+    AtomSerializer.render(AtomSerializer.new.block_salmon(block))
   end
 end
diff --git a/app/services/concerns/stream_entry_renderer.rb b/app/services/concerns/stream_entry_renderer.rb
index a4255daea..ef176d8a6 100644
--- a/app/services/concerns/stream_entry_renderer.rb
+++ b/app/services/concerns/stream_entry_renderer.rb
@@ -2,7 +2,6 @@
 
 module StreamEntryRenderer
   def stream_entry_to_xml(stream_entry)
-    renderer = StreamEntriesController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https)
-    renderer.render(:show, assigns: { stream_entry: stream_entry }, formats: [:atom])
+    AtomSerializer.render(AtomSerializer.new.entry(stream_entry, true))
   end
 end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 106d257ba..19eedc0a7 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -50,22 +50,23 @@ class FanOutOnWriteService < BaseService
   end
 
   def render_anonymous_payload(status)
-    @payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status)
+    @payload = InlineRenderer.render(status, nil, 'api/v1/statuses/show')
+    @payload = Oj.dump(event: :update, payload: @payload)
   end
 
   def deliver_to_hashtags(status)
     Rails.logger.debug "Delivering status #{status.id} to hashtags"
 
     status.tags.pluck(:name).each do |hashtag|
-      FeedManager.instance.broadcast("hashtag:#{hashtag}", event: 'update', payload: @payload)
-      FeedManager.instance.broadcast("hashtag:#{hashtag}:local", event: 'update', payload: @payload) if status.account.local?
+      Redis.current.publish("timeline:hashtag:#{hashtag}", @payload)
+      Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if status.local?
     end
   end
 
   def deliver_to_public(status)
     Rails.logger.debug "Delivering status #{status.id} to public timeline"
 
-    FeedManager.instance.broadcast(:public, event: 'update', payload: @payload)
-    FeedManager.instance.broadcast('public:local', event: 'update', payload: @payload) if status.account.local?
+    Redis.current.publish('timeline:public', @payload)
+    Redis.current.publish('timeline:public:local', @payload) if status.local?
   end
 end
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 5cc96403c..e92aada64 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -22,26 +22,6 @@ class FavouriteService < BaseService
   private
 
   def build_xml(favourite)
-    description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}"
-
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        unique_id xml, favourite.created_at, favourite.id, 'Favourite'
-        title xml, description
-        content xml, description
-
-        author(xml) do
-          include_author xml, favourite.account
-        end
-
-        object_type xml, :activity
-        verb xml, :favorite
-        in_reply_to xml, TagManager.instance.uri_for(favourite.status), TagManager.instance.url_for(favourite.status)
-
-        target(xml) do
-          include_target xml, favourite.status
-        end
-      end
-    end.to_xml
+    AtomSerializer.render(AtomSerializer.new.favourite_salmon(favourite))
   end
 end
diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb
index b39eafc70..936953429 100644
--- a/app/services/follow_remote_account_service.rb
+++ b/app/services/follow_remote_account_service.rb
@@ -45,13 +45,13 @@ class FollowRemoteAccountService < BaseService
     account.suspended   = true if domain_block && domain_block.suspend?
     account.silenced    = true if domain_block && domain_block.silence?
 
-    xml  = get_feed(account.remote_url)
-    hubs = get_hubs(xml)
+    body, xml = get_feed(account.remote_url)
+    hubs      = get_hubs(xml)
 
     account.uri     = get_account_uri(xml)
     account.hub_url = hubs.first.attribute('href').value
 
-    get_profile(xml, account)
+    get_profile(body, account)
     account.save!
 
     account
@@ -61,7 +61,7 @@ class FollowRemoteAccountService < BaseService
 
   def get_feed(url)
     response = http_client.get(Addressable::URI.parse(url))
-    Nokogiri::XML(response)
+    [response.to_s, Nokogiri::XML(response)]
   end
 
   def get_hubs(xml)
@@ -82,12 +82,8 @@ class FollowRemoteAccountService < BaseService
     author_uri.content
   end
 
-  def get_profile(xml, account)
-    update_remote_profile_service.call(xml.at_xpath('/xmlns:feed'), account)
-  end
-
-  def update_remote_profile_service
-    @update_remote_profile_service ||= UpdateRemoteProfileService.new
+  def get_profile(body, account)
+    RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), false)
   end
 
   def http_client
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 17b3b2542..844f5282d 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -10,7 +10,7 @@ class FollowService < BaseService
     target_account = FollowRemoteAccountService.new.call(uri)
 
     raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
-    raise Mastodon::NotPermittedError       if target_account.blocking?(source_account) || source_account.blocking?(target_account)
+    raise Mastodon::NotPermittedError  if target_account.blocking?(source_account) || source_account.blocking?(target_account)
 
     if target_account.locked?
       request_follow(source_account, target_account)
@@ -55,48 +55,10 @@ class FollowService < BaseService
   end
 
   def build_follow_request_xml(follow_request)
-    description = "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}"
-
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        unique_id xml, follow_request.created_at, follow_request.id, 'FollowRequest'
-        title xml, description
-        content xml, description
-
-        author(xml) do
-          include_author xml, follow_request.account
-        end
-
-        object_type xml, :activity
-        verb xml, :request_friend
-
-        target(xml) do
-          include_author xml, follow_request.target_account
-        end
-      end
-    end.to_xml
+    AtomSerializer.render(AtomSerializer.new.follow_request_salmon(follow_request))
   end
 
   def build_follow_xml(follow)
-    description = "#{follow.account.acct} started following #{follow.target_account.acct}"
-
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        unique_id xml, follow.created_at, follow.id, 'Follow'
-        title xml, description
-        content xml, description
-
-        author(xml) do
-          include_author xml, follow.account
-        end
-
-        object_type xml, :activity
-        verb xml, :follow
-
-        target(xml) do
-          include_author xml, follow.target_account
-        end
-      end
-    end.to_xml
+    AtomSerializer.render(AtomSerializer.new.follow_salmon(follow))
   end
 end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 24486f220..ffeee5fcf 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -50,7 +50,7 @@ class NotifyService < BaseService
   def create_notification
     @notification.save!
     return unless @notification.browserable?
-    FeedManager.instance.broadcast(@recipient.id, event: 'notification', payload: FeedManager.instance.inline_render(@recipient, 'api/v1/notifications/show', @notification))
+    Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, 'api/v1/notifications/show')))
   end
 
   def send_email
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index b8179f7dc..221aa42a3 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -37,11 +37,11 @@ class PostStatusService < BaseService
   def validate_media!(media_ids)
     return if media_ids.nil? || !media_ids.is_a?(Enumerable)
 
-    raise Mastodon::ValidationError, 'Cannot attach more than 4 files' if media_ids.size > 4
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if media_ids.size > 4
 
     media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map(&:to_i))
 
-    raise Mastodon::ValidationError, 'Cannot attach a video to a toot that already contains images' if media.size > 1 && media.find(&:video?)
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if media.size > 1 && media.find(&:video?)
 
     media
   end
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 69911abc5..cf2f7a826 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -5,15 +5,15 @@ class ProcessFeedService < BaseService
     xml = Nokogiri::XML(body)
     xml.encoding = 'utf-8'
 
-    update_author(xml, account)
+    update_author(body, xml, account)
     process_entries(xml, account)
   end
 
   private
 
-  def update_author(xml, account)
+  def update_author(body, xml, account)
     return if xml.at_xpath('/xmlns:feed', xmlns: TagManager::XMLNS).nil?
-    UpdateRemoteProfileService.new.call(xml.at_xpath('/xmlns:feed', xmlns: TagManager::XMLNS), account, true)
+    RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), true)
   end
 
   def process_entries(xml, account)
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index d5f7b4b3c..805ca5a27 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -24,7 +24,7 @@ class ProcessInteractionService < BaseService
     return if account.suspended?
 
     if salmon.verify(envelope, account.keypair)
-      update_remote_profile_service.call(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS), account, true)
+      RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), true)
 
       case verb(xml)
       when :follow
@@ -114,7 +114,7 @@ class ProcessInteractionService < BaseService
 
     return if status.nil?
 
-    remove_status_service.call(status) if account.id == status.account_id
+    RemovalWorker.perform_async(status.id) if account.id == status.account_id
   end
 
   def favourite!(xml, from_account)
@@ -130,7 +130,7 @@ class ProcessInteractionService < BaseService
   end
 
   def add_post!(body, account)
-    process_feed_service.call(body, account)
+    ProcessingWorker.perform_async(account.id, body.force_encoding('UTF-8'))
   end
 
   def status(xml)
@@ -153,10 +153,6 @@ class ProcessInteractionService < BaseService
     @process_feed_service ||= ProcessFeedService.new
   end
 
-  def update_remote_profile_service
-    @update_remote_profile_service ||= UpdateRemoteProfileService.new
-  end
-
   def remove_status_service
     @remove_status_service ||= RemoveStatusService.new
   end
diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb
index 1b03d62e6..675007938 100644
--- a/app/services/reject_follow_service.rb
+++ b/app/services/reject_follow_service.rb
@@ -10,31 +10,6 @@ class RejectFollowService < BaseService
   private
 
   def build_xml(follow_request)
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        unique_id xml, Time.now.utc, follow_request.id, 'FollowRequest'
-        title xml, "#{follow_request.target_account.acct} rejects follow request by #{follow_request.account.acct}"
-
-        author(xml) do
-          include_author xml, follow_request.target_account
-        end
-
-        object_type xml, :activity
-        verb xml, :reject
-
-        target(xml) do
-          author(xml) do
-            include_author xml, follow_request.account
-          end
-
-          object_type xml, :activity
-          verb xml, :request_friend
-
-          target(xml) do
-            include_author xml, follow_request.target_account
-          end
-        end
-      end
-    end.to_xml
+    AtomSerializer.render(AtomSerializer.new.reject_follow_request_salmon(follow_request))
   end
 end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index cf1f432e4..50bb7fc97 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -4,6 +4,8 @@ class RemoveStatusService < BaseService
   include StreamEntryRenderer
 
   def call(status)
+    @payload = Oj.dump(event: :delete, payload: status.id)
+
     remove_from_self(status) if status.account.local?
     remove_from_followers(status)
     remove_from_mentioned(status)
@@ -25,25 +27,23 @@ class RemoveStatusService < BaseService
   end
 
   def remove_from_followers(status)
-    status.account.followers.each do |follower|
-      next unless follower.local?
+    status.account.followers.where(domain: nil).each do |follower|
       unpush(:home, follower, status)
     end
   end
 
   def remove_from_mentioned(status)
+    return unless status.local?
     notified_domains = []
 
     status.mentions.each do |mention|
       mentioned_account = mention.account
 
-      if mentioned_account.local?
-        unpush(:mentions, mentioned_account, status)
-      else
-        next if notified_domains.include?(mentioned_account.domain)
-        notified_domains << mentioned_account.domain
-        send_delete_salmon(mentioned_account, status)
-      end
+      next if mentioned_account.local?
+      next if notified_domains.include?(mentioned_account.domain)
+
+      notified_domains << mentioned_account.domain
+      send_delete_salmon(mentioned_account, status)
     end
   end
 
@@ -65,17 +65,19 @@ class RemoveStatusService < BaseService
       redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id)
     end
 
-    FeedManager.instance.broadcast(receiver.id, event: 'delete', payload: status.id)
+    Redis.current.publish("timeline:#{receiver.id}", @payload)
   end
 
   def remove_from_hashtags(status)
-    status.tags.each do |tag|
-      FeedManager.instance.broadcast("hashtag:#{tag.name}", event: 'delete', payload: status.id)
+    status.tags.pluck(:name) do |hashtag|
+      Redis.current.publish("timeline:hashtag:#{hashtag}", @payload)
+      Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if status.local?
     end
   end
 
   def remove_from_public(status)
-    FeedManager.instance.broadcast(:public, event: 'delete', payload: status.id)
+    Redis.current.publish('timeline:public', @payload)
+    Redis.current.publish('timeline:public:local', @payload) if status.local?
   end
 
   def redis
diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb
index c4f789f74..3a3fd2d8c 100644
--- a/app/services/unblock_service.rb
+++ b/app/services/unblock_service.rb
@@ -11,22 +11,6 @@ class UnblockService < BaseService
   private
 
   def build_xml(block)
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        unique_id xml, Time.now.utc, block.id, 'Block'
-        title xml, "#{block.account.acct} no longer blocks #{block.target_account.acct}"
-
-        author(xml) do
-          include_author xml, block.account
-        end
-
-        object_type xml, :activity
-        verb xml, :unblock
-
-        target(xml) do
-          include_author xml, block.target_account
-        end
-      end
-    end.to_xml
+    AtomSerializer.render(AtomSerializer.new.unblock_salmon(block))
   end
 end
diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb
index 5f0ba4254..a32e87bff 100644
--- a/app/services/unfavourite_service.rb
+++ b/app/services/unfavourite_service.rb
@@ -13,26 +13,6 @@ class UnfavouriteService < BaseService
   private
 
   def build_xml(favourite)
-    description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}"
-
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        unique_id xml, Time.now.utc, favourite.id, 'Favourite'
-        title xml, description
-        content xml, description
-
-        author(xml) do
-          include_author xml, favourite.account
-        end
-
-        object_type xml, :activity
-        verb xml, :unfavorite
-        in_reply_to xml, TagManager.instance.uri_for(favourite.status), TagManager.instance.url_for(favourite.status)
-
-        target(xml) do
-          include_target xml, favourite.status
-        end
-      end
-    end.to_xml
+    AtomSerializer.render(AtomSerializer.new.unfavourite_salmon(favourite))
   end
 end
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 3440da364..244c9b529 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -13,25 +13,6 @@ class UnfollowService < BaseService
   private
 
   def build_xml(follow)
-    description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
-
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        unique_id xml, Time.now.utc, follow.id, 'Follow'
-        title xml, description
-        content xml, description
-
-        author(xml) do
-          include_author xml, follow.account
-        end
-
-        object_type xml, :activity
-        verb xml, :unfollow
-
-        target(xml) do
-          include_author xml, follow.target_account
-        end
-      end
-    end.to_xml
+    AtomSerializer.render(AtomSerializer.new.unfollow_salmon(follow))
   end
 end
diff --git a/app/views/accounts/show.atom.ruby b/app/views/accounts/show.atom.ruby
deleted file mode 100644
index e15021178..000000000
--- a/app/views/accounts/show.atom.ruby
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-Nokogiri::XML::Builder.new do |xml|
-  feed(xml) do
-    simple_id  xml, account_url(@account, format: 'atom')
-    title      xml, @account.display_name
-    subtitle   xml, @account.note
-    updated_at xml, stream_updated_at
-    logo       xml, full_asset_url(@account.avatar.url(:original))
-
-    author(xml) do
-      include_author xml, @account
-    end
-
-    link_alternate xml, TagManager.instance.url_for(@account)
-    link_self      xml, account_url(@account, format: 'atom')
-    link_next      xml, account_url(@account, format: 'atom', max_id: @entries.last.id) if @entries.size == 20
-    link_hub       xml, api_push_url
-    link_salmon    xml, api_salmon_url(@account.id)
-
-    @entries.each do |stream_entry|
-      entry(xml, false) do
-        include_entry xml, stream_entry
-      end
-    end
-  end
-end.to_xml
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 7eae6982b..abab14a28 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -11,8 +11,10 @@
     %meta{:name => "theme-color", :content => "#282c37"}/
     %meta{:name => "apple-mobile-web-app-capable", :content => "yes"}/
 
-    %title
-      = "#{yield(:page_title)} - " if content_for?(:page_title)
+    %title<
+      - if content_for?(:page_title)
+        = yield(:page_title)
+        = ' - '
       = Setting.site_title
 
     = stylesheet_link_tag 'application', media: 'all'
diff --git a/app/views/doorkeeper/authorized_applications/index.html.haml b/app/views/oauth/authorized_applications/index.html.haml
index d4719881c..d4719881c 100644
--- a/app/views/doorkeeper/authorized_applications/index.html.haml
+++ b/app/views/oauth/authorized_applications/index.html.haml
diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml
index cdd0dde3b..434c5c8da 100644
--- a/app/views/stream_entries/_status.html.haml
+++ b/app/views/stream_entries/_status.html.haml
@@ -16,7 +16,7 @@
           %strong= display_name(status.account)
         = t('stream_entries.reblogged')
 
-  = render partial: centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status', locals: { status: proper_status(status) }
+  = render partial: centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status', locals: { status: status.proper }
 
 - if include_threads
   = render partial: 'stream_entries/status', collection: @descendants, as: :status, locals: { is_successor: true }
diff --git a/app/views/stream_entries/show.atom.ruby b/app/views/stream_entries/show.atom.ruby
deleted file mode 100644
index a298f3269..000000000
--- a/app/views/stream_entries/show.atom.ruby
+++ /dev/null
@@ -1,9 +0,0 @@
-Nokogiri::XML::Builder.new do |xml|
-  entry(xml, true) do
-    author(xml) do
-      include_author xml, @stream_entry.account
-    end
-
-    include_entry xml, @stream_entry
-  end
-end.to_xml
diff --git a/app/views/user_mailer/confirmation_instructions.fi.html.erb b/app/views/user_mailer/confirmation_instructions.fi.html.erb
new file mode 100644
index 000000000..8b72722da
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.fi.html.erb
@@ -0,0 +1,5 @@
+<p>Tervetuloa <%= @resource.email %>!</p>
+
+<p>Voit vahvistaa Mastodon tilisi klikkaamalla alla olevaa linkkiä:</p>
+
+<p><%= link_to 'Varmista tilini', confirmation_url(@resource, confirmation_token: @token) %></p>
diff --git a/app/views/user_mailer/confirmation_instructions.fi.text.erb b/app/views/user_mailer/confirmation_instructions.fi.text.erb
new file mode 100644
index 000000000..796913abb
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.fi.text.erb
@@ -0,0 +1,5 @@
+Tervetuloa <%= @resource.email %>!
+
+Voit vahvistaa Mastodon tilisi klikkaamalla alla olevaa linkkiä:
+
+<%= confirmation_url(@resource, confirmation_token: @token) %>
diff --git a/app/views/user_mailer/password_change.fi.html.erb b/app/views/user_mailer/password_change.fi.html.erb
new file mode 100644
index 000000000..c56b96593
--- /dev/null
+++ b/app/views/user_mailer/password_change.fi.html.erb
@@ -0,0 +1,3 @@
+<p>Hei <%= @resource.email %>!</p>
+
+<p>Lähetämme tämän viestin ilmoittaaksemme että salasanasi on vaihdettu.</p>
diff --git a/app/views/user_mailer/password_change.fi.text.erb b/app/views/user_mailer/password_change.fi.text.erb
new file mode 100644
index 000000000..d90c3fdeb
--- /dev/null
+++ b/app/views/user_mailer/password_change.fi.text.erb
@@ -0,0 +1,3 @@
+Hei <%= @resource.email %>!
+
+Lähetämme tämän viestin ilmoittaaksemme että salasanasi on vaihdettu.
diff --git a/app/views/user_mailer/reset_password_instructions.fi.html.erb b/app/views/user_mailer/reset_password_instructions.fi.html.erb
new file mode 100644
index 000000000..53be0b62b
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.fi.html.erb
@@ -0,0 +1,8 @@
+<p>Hei <%= @resource.email %>!</p>
+
+<p>Joku on pyytänyt salasanvaihto Mastodonissa. Voit tehdä sen allaolevassa linkissä.</p>
+
+<p><%= link_to 'Vaihda salasanani', edit_password_url(@resource, reset_password_token: @token) %></p>
+
+<p>Jos et pyytänyt vaihtoa, poista tämä viesti.</p>
+<p>Salasanaasi ei vaihdeta ennen kuin menet ylläolevaan linkkiin ja luot uuden.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.fi.text.erb b/app/views/user_mailer/reset_password_instructions.fi.text.erb
new file mode 100644
index 000000000..c826d5fc8
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.fi.text.erb
@@ -0,0 +1,8 @@
+Hei <%= @resource.email %>!
+
+Joku on pyytänyt salasanvaihto Mastodonissa. Voit tehdä sen allaolevassa linkissä.
+
+<%= edit_password_url(@resource, reset_password_token: @token) %>
+
+Jos et pyytänyt vaihtoa, poista tämä viesti.
+Salasanaasi ei vaihdeta ennen kuin menet ylläolevaan linkkiin ja luot uuden.
diff --git a/app/workers/admin/suspension_worker.rb b/app/workers/admin/suspension_worker.rb
index 38761f3b9..7ef2b35ec 100644
--- a/app/workers/admin/suspension_worker.rb
+++ b/app/workers/admin/suspension_worker.rb
@@ -3,6 +3,8 @@
 class Admin::SuspensionWorker
   include Sidekiq::Worker
 
+  sidekiq_options queue: 'pull'
+
   def perform(account_id)
     SuspendAccountService.new.call(Account.find(account_id))
   end
diff --git a/app/workers/application_worker.rb b/app/workers/application_worker.rb
index f2d7c1062..436f24763 100644
--- a/app/workers/application_worker.rb
+++ b/app/workers/application_worker.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
 class ApplicationWorker
   def info(message)
     Rails.logger.info("#{self.class.name} - #{message}")
diff --git a/app/workers/distribution_worker.rb b/app/workers/distribution_worker.rb
index 9a2867ea6..f7953689b 100644
--- a/app/workers/distribution_worker.rb
+++ b/app/workers/distribution_worker.rb
@@ -4,10 +4,7 @@ class DistributionWorker < ApplicationWorker
   include Sidekiq::Worker
 
   def perform(status_id)
-    status = Status.find(status_id)
-
-    FanOutOnWriteService.new.call(status)
-    WarmCacheService.new.call(status)
+    FanOutOnWriteService.new.call(Status.find(status_id))
   rescue ActiveRecord::RecordNotFound
     info("Couldn't find the status")
   end
diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb
index 7cf29fb53..d5a33cada 100644
--- a/app/workers/import_worker.rb
+++ b/app/workers/import_worker.rb
@@ -46,7 +46,7 @@ class ImportWorker
 
       begin
         FollowService.new.call(from_account, row[0])
-      rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
+      rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound, Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
         next
       end
     end
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index 466def3a8..8412be4b7 100644
--- a/app/workers/pubsubhubbub/delivery_worker.rb
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -13,6 +13,9 @@ class Pubsubhubbub::DeliveryWorker
   def perform(subscription_id, payload)
     subscription = Subscription.find(subscription_id)
     headers      = {}
+    host         = Addressable::URI.parse(subscription.callback_url).host
+
+    return if DomainBlock.blocked?(host)
 
     headers['User-Agent']      = 'Mastodon/PubSubHubbub'
     headers['Link']            = LinkHeader.new([[api_push_url, [%w(rel hub)]], [account_url(subscription.account, format: :atom), [%w(rel self)]]]).to_s
diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb
index 82ff257af..68ca0f870 100644
--- a/app/workers/pubsubhubbub/distribution_worker.rb
+++ b/app/workers/pubsubhubbub/distribution_worker.rb
@@ -10,14 +10,10 @@ class Pubsubhubbub::DistributionWorker
 
     return if stream_entry.hidden?
 
-    account  = stream_entry.account
-    renderer = AccountsController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https)
-    payload  = renderer.render(:show, assigns: { account: account, entries: [stream_entry] }, formats: [:atom])
-    # domains  = account.followers_domains
+    account = stream_entry.account
+    payload = AtomSerializer.render(AtomSerializer.new.feed(account, [stream_entry]))
 
     Subscription.where(account: account).active.select('id, callback_url').find_each do |subscription|
-      host = Addressable::URI.parse(subscription.callback_url).host
-      next if DomainBlock.blocked?(host) # || !domains.include?(host)
       Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload)
     end
   rescue ActiveRecord::RecordNotFound
diff --git a/app/workers/push_update_worker.rb b/app/workers/push_update_worker.rb
new file mode 100644
index 000000000..fbcdcf634
--- /dev/null
+++ b/app/workers/push_update_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class PushUpdateWorker
+  include Sidekiq::Worker
+
+  def perform(account_id, status_id)
+    account = Account.find(account_id)
+    status  = Status.find(status_id)
+    message = InlineRenderer.render(status, account, 'api/v1/statuses/show')
+
+    Redis.current.publish("timeline:#{account.id}", Oj.dump(event: :update, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/remote_profile_update_worker.rb b/app/workers/remote_profile_update_worker.rb
new file mode 100644
index 000000000..b91dc3466
--- /dev/null
+++ b/app/workers/remote_profile_update_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class RemoteProfileUpdateWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(account_id, body, resubscribe)
+    account = Account.find(account_id)
+
+    xml = Nokogiri::XML(body)
+    xml.encoding = 'utf-8'
+
+    author_container = xml.at_xpath('/xmlns:feed', xmlns: TagManager::XMLNS) || xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS)
+
+    UpdateRemoteProfileService.new.call(author_container, account, resubscribe)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/salmon_worker.rb b/app/workers/salmon_worker.rb
index fc95ce47f..d37d40432 100644
--- a/app/workers/salmon_worker.rb
+++ b/app/workers/salmon_worker.rb
@@ -7,7 +7,7 @@ class SalmonWorker
 
   def perform(account_id, body)
     ProcessInteractionService.new.call(body, Account.find(account_id))
-  rescue ActiveRecord::RecordNotFound
+  rescue Nokogiri::XML::XPath::SyntaxError, ActiveRecord::RecordNotFound
     true
   end
 end
diff --git a/config/initializers/rabl_init.rb b/config/initializers/rabl_init.rb
index f7be0c607..132a42144 100644
--- a/config/initializers/rabl_init.rb
+++ b/config/initializers/rabl_init.rb
@@ -1,4 +1,5 @@
 Rabl.configure do |config|
+  config.json_engine       = Oj
   config.cache_all_output  = false
   config.cache_sources     = Rails.env.production?
   config.include_json_root = false
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 742219df9..aa3a732f9 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -163,3 +163,7 @@ en:
     invalid_otp_token: Invalid two-factor code
   will_paginate:
     page_gap: "&hellip;"
+  media_attachments:
+    validations:
+      too_many: Cannot attach more than 4 files
+      images_and_video: Cannot attach a video to a status that already contains images
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index cdb2b9886..947d3f646 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -16,18 +16,18 @@ fi:
       chronology: Aikajana on kronologisessa järjestyksessä
       ethics: 'Eettinen suunnittelu: ei mainoksia, no seurantaa'
       gifv: GIFV settejä ja lyhyitä videoita
-      privacy: Julkaisu kohtainen yksityisyys aseuts
+      privacy: Julkaisu kohtainen yksityisyys asetus
       public: Julkiset aikajanat
     features_headline: Mikä erottaa Mastodonin muista
     get_started: Aloita käyttö
     links: Linkit
-    other_instances: muuhun palvelimeen
+    other_instances: Muut palvelimet
     source_code: Lähdekoodi
     status_count_after: statusta
     status_count_before: Ovat luoneet
     terms: Ehdot
-    user_count_after: käyttäjää
-    user_count_before: Koti käyttäjälle
+    user_count_after: käyttäjälle
+    user_count_before: Koti
   accounts:
     follow: Seuraa
     followers: Seuraajat
@@ -130,8 +130,8 @@ fi:
     authorized_apps: Valtuutetut ohjelmat
     back: Takaisin Mastodoniin
     edit_profile: Muokkaa profiilia
-    export: Datan vienti
-    import: Datan tuonti
+    export: Vie dataa
+    import: Tuo dataa
     preferences: Ominaisuudet
     settings: Asetukset
     two_factor_auth: Kaksivaiheinen tunnistus
diff --git a/config/puma.rb b/config/puma.rb
index 550129bdc..191f00cca 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -9,7 +9,7 @@ preload_app!
 
 on_worker_boot do
   if ENV['HEROKU'] # Spawn the workers from Puma, to only use one dyno
-    @sidekiq_pid ||= spawn('bundle exec sidekiq -q default -q mailers -q push')
+    @sidekiq_pid ||= spawn('bundle exec sidekiq -q default -q push -q pull -q mailers ')
   end
 
   ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
diff --git a/config/routes.rb b/config/routes.rb
index ca77191f7..315ad5da5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -11,7 +11,7 @@ Rails.application.routes.draw do
   end
 
   use_doorkeeper do
-    controllers authorizations: 'oauth/authorizations'
+    controllers authorizations: 'oauth/authorizations', authorized_applications: 'oauth/authorized_applications'
   end
 
   get '.well-known/host-meta', to: 'xrd#host_meta', as: :host_meta
diff --git a/db/migrate/20170406215816_add_notifications_and_favourites_indices.rb b/db/migrate/20170406215816_add_notifications_and_favourites_indices.rb
new file mode 100644
index 000000000..00e41bf3a
--- /dev/null
+++ b/db/migrate/20170406215816_add_notifications_and_favourites_indices.rb
@@ -0,0 +1,7 @@
+class AddNotificationsAndFavouritesIndices < ActiveRecord::Migration[5.0]
+  def change
+    add_index :notifications, [:activity_id, :activity_type]
+    add_index :accounts, :url
+    add_index :favourites, :status_id
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index b5d55fa16..fe9b8dd4f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20170405112956) do
+ActiveRecord::Schema.define(version: 20170406215816) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -49,6 +49,7 @@ ActiveRecord::Schema.define(version: 20170405112956) do
     t.integer  "following_count",         default: 0,     null: false
     t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
     t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower", using: :btree
+    t.index ["url"], name: "index_accounts_on_url", using: :btree
     t.index ["username", "domain"], name: "index_accounts_on_username_and_domain", unique: true, using: :btree
   end
 
@@ -75,6 +76,7 @@ ActiveRecord::Schema.define(version: 20170405112956) do
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
     t.index ["account_id", "status_id"], name: "index_favourites_on_account_id_and_status_id", unique: true, using: :btree
+    t.index ["status_id"], name: "index_favourites_on_status_id", using: :btree
   end
 
   create_table "follow_requests", force: :cascade do |t|
@@ -128,6 +130,7 @@ ActiveRecord::Schema.define(version: 20170405112956) do
     t.datetime "updated_at", null: false
     t.index ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree
     t.index ["status_id"], name: "index_mentions_on_status_id", using: :btree
+    t.index ["status_id"], name: "mentions_status_id_index", using: :btree
   end
 
   create_table "mutes", force: :cascade do |t|
@@ -146,6 +149,7 @@ ActiveRecord::Schema.define(version: 20170405112956) do
     t.datetime "updated_at",      null: false
     t.integer  "from_account_id"
     t.index ["account_id", "activity_id", "activity_type"], name: "account_activity", unique: true, using: :btree
+    t.index ["activity_id", "activity_type"], name: "index_notifications_on_activity_id_and_activity_type", using: :btree
   end
 
   create_table "oauth_access_grants", force: :cascade do |t|
diff --git a/docs/Running-Mastodon/Administration-guide.md b/docs/Running-Mastodon/Administration-guide.md
index dd69eb303..09b0f1df1 100644
--- a/docs/Running-Mastodon/Administration-guide.md
+++ b/docs/Running-Mastodon/Administration-guide.md
@@ -7,7 +7,7 @@ So, you have a working Mastodon instance... now what?
 
 The following rake task:
 
-    rake mastodon:make_admin USERNAME=alice
+    RAILS_ENV=production bundle exec rails mastodon:make_admin USERNAME=alice
 
 Would turn the local user "alice" into an admin.
 
diff --git a/docs/Running-Mastodon/Heroku-guide.md b/docs/Running-Mastodon/Heroku-guide.md
index 0de26230c..269bc6331 100644
--- a/docs/Running-Mastodon/Heroku-guide.md
+++ b/docs/Running-Mastodon/Heroku-guide.md
@@ -3,13 +3,50 @@ Heroku guide
 
 [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?button-url=https://github.com/tootsuite/mastodon&template=https://github.com/tootsuite/mastodon)
 
-Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. It should be noted this has limited testing and could have unpredictable results.
+Mastodon can be run on a free [Heroku](https://heroku.com) app. It should be
+noted this has limited testing and could have unpredictable results.
 
-1. Click the above button.
-2. Fill in the options requested.
-  * You can use a .herokuapp.com domain, which will be simple to set up, or you can use a custom domain. If you want a custom domain and HTTPS, you will need to upgrade to a paid plan (to use Heroku's SSL features), or set up [CloudFlare](https://cloudflare.com) who offer free "Flexible SSL" (note: CloudFlare have some undefined limits on WebSockets. So far, no one has reported hitting concurrent connection limits).
-  * You will want Amazon S3 for file storage. The only exception is for development purposes, where you may not care if files are not saved. Follow a guide online for creating a free Amazon S3 bucket and Access Key, then enter the details.
-  * If you want your Mastodon to be able to send emails, configure SMTP settings here (or later). Consider using [Mailgun](https://mailgun.com) or similar, who offer free plans that should suit your interests.
-3. Deploy! The app should be set up, with a working web interface and database. You can change settings and manage versions from the Heroku dashboard.
+## Basic setup
 
-You may need to use the `heroku` CLI application to run `USERNAME=yourUsername rails mastodon:make_admin` to make yourself an admin.
+Click the button above to start creating a Heroku app with the Mastodon repo as
+the source. This tells Heroku to use the `app.json` file which does things like
+prompt for config variables, set up the right buildpacks, run a postdeploy task,
+and add the appropriate addons.
+
+If you don't use the deploy button and app.json approach, you will need to do
+some of that manually.
+
+## Domain names and SSL
+
+You can add your domain name to the Heroku app's setting, and then also use
+Heroku's (free) auto renewal program for Lets Encrypt certificates, by
+requesting a cert from the settings screen. You'll have to point your hostname
+DNS at Heroku using the values heroku gives you on this screen, using whatever
+method is appropriate for your DNS setup.
+
+You should set the Heroku config vars of `LOCAL_DOMAIN` to your hostname, and
+`LOCAL_HTTPS` to "true" as well.
+
+## Email
+
+Consider using [Mailgun](https://mailgun.com) or similar, who offer free plans
+that should suit your interests. Look in `production.rb` to see which config
+variables need to be set on Heroku for outgoing email to work.
+
+## File storage
+
+You will want Amazon S3 for file storage. The only exception is for development
+purposes, where you may not care if files are not saved. Follow a guide online
+for creating a free Amazon S3 bucket and Access Key, then enter the details.
+
+## Deployment
+
+You can deploy from the Heroku web interface or from the command line. Run:
+
+  `heroku run rails db:migrate`
+
+after you first deploy to set up the first database.
+
+To make yourself an admin, you may need to use the `heroku` CLI application after creating an account online:
+
+  `heroku rake mastodon:make_admin USERNAME=yourUsername`
diff --git a/docs/Running-Mastodon/Production-guide.md b/docs/Running-Mastodon/Production-guide.md
index 90e9c0dea..af21af546 100644
--- a/docs/Running-Mastodon/Production-guide.md
+++ b/docs/Running-Mastodon/Production-guide.md
@@ -24,7 +24,7 @@ server {
 
   ssl_protocols TLSv1.2;
   ssl_ciphers EECDH+AESGCM:EECDH+AES;
-  ssl_ecdh_curve secp384r1;
+  ssl_ecdh_curve prime256v1;
   ssl_prefer_server_ciphers on;
   ssl_session_cache shared:SSL:10m;
 
@@ -90,7 +90,7 @@ It is recommended to create a special user for mastodon on the server (you could
 
     sudo apt-get install imagemagick ffmpeg libpq-dev libxml2-dev libxslt1-dev nodejs file git curl
     curl -sL https://deb.nodesource.com/setup_4.x | sudo bash -
-    apt-get intall nodejs
+    apt-get install nodejs
     sudo npm install -g yarn
 
 ## Redis
diff --git a/docs/Running-Mastodon/Scalingo-guide.md b/docs/Running-Mastodon/Scalingo-guide.md
index 6552056a8..9329f753e 100644
--- a/docs/Running-Mastodon/Scalingo-guide.md
+++ b/docs/Running-Mastodon/Scalingo-guide.md
@@ -8,6 +8,6 @@ Scalingo guide
   * You can use a .scalingo.io domain, which will be simple to set up, or you can use a custom domain.
   * You will want Amazon S3 for file storage. The only exception is for development purposes, where you may not care if files are not saved. Follow a guide online for creating a free Amazon S3 bucket and Access Key, then enter the details.
   * If you want your Mastodon to be able to send emails, configure SMTP settings here (or later). Consider using [Mailgun](https://mailgun.com) or similar, who offer free plans that should suit your interests.
-3. Deploy! The app should be set up, with a working web interface and database. You can change settings and manage versions from the Heroku dashboard.
+3. Deploy! The app should be set up, with a working web interface and database. You can change settings and manage versions from the Scalingo dashboard.
 
-You may need to use the `scalingo` CLI application to run `USERNAME=yourUsername rails mastodon:make_admin` to make yourself an admin.
+To make yourself an admin, you can use the `scalingo` CLI: `scalingo run -e USERNAME=yourusername rails mastodon:make_admin`.
diff --git a/docs/Running-Mastodon/Vagrant-guide.md b/docs/Running-Mastodon/Vagrant-guide.md
index b24f14e83..83a892408 100644
--- a/docs/Running-Mastodon/Vagrant-guide.md
+++ b/docs/Running-Mastodon/Vagrant-guide.md
@@ -17,6 +17,8 @@ To create and provision a new virtual machine for Mastodon development:
     cd mastodon
     vagrant up
 
+**Note:** On Linux hosts, you will need to [enable NFS support](https://www.vagrantup.com/docs/synced-folders/nfs.html).
+
 Running `vagrant up` for the first time will run provisioning, which will:
 
 - Download the Ubuntu 14.04 base image, if there isn't already a copy on your machine
@@ -61,4 +63,4 @@ To run the `rspec` tests and `rubocop` style checker, you may either:
 
 ## Support/help
 
-If you are confused, or having any issues with the above, the Mastodon IRC channel ( irc.freenode.net #mastodon ) is a good place to find assistance.
\ No newline at end of file
+If you are confused, or having any issues with the above, the Mastodon IRC channel ( irc.freenode.net #mastodon ) is a good place to find assistance.
diff --git a/docs/Using-Mastodon/Apps.md b/docs/Using-Mastodon/Apps.md
index 67b14dc26..b5e1fa36b 100644
--- a/docs/Using-Mastodon/Apps.md
+++ b/docs/Using-Mastodon/Apps.md
@@ -13,5 +13,6 @@ Some people have started working on apps for the Mastodon API. Here is a list of
 |Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)|
 |Tooter|Chrome|<https://github.com/ineffyble/tooter>|[@effy@mastodon.social](https://mastodon.social/users/effy)|
 |tootstream|CLI|<https://github.com/magicalraccoon/tootstream>|[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
+|HackerNewsBot|CLI|<https://github.com/raymestalez/mastodon-hnbot>|[@rayalez@hackertribe.io](https://hackertribe.io/users/rayalez)|
 
 If you have a project like this, let me know so I can add it to the list!
diff --git a/docs/Using-Mastodon/FAQ.md b/docs/Using-Mastodon/FAQ.md
index daedcbdd8..3b03a8ee4 100644
--- a/docs/Using-Mastodon/FAQ.md
+++ b/docs/Using-Mastodon/FAQ.md
@@ -36,8 +36,9 @@ While Mastodon is compatible with GNU social in terms of server to server commun
 
 Because Mastodon has been created from a blank slate, it is much simpler to have the API mirror internal structures as closely as possible, rather than build an emulation layer. Secondly, the GNU social client API is actually a half-way implementation of the legacy Twitter API - that's the reason why it works with some older Twitter client apps. However, many of those apps are not maintained anymore, the GNU social API does not actually keep up with the real Twitter API and never fully implemented all its features; at the same time, the Twitter API was never meant for a federated service and so obscures some of the functionality.
 
+
 #### How is Mastodon funded?
 
 Development of Mastodon and hosting of mastodon.social is funded through my [Patreon (also BTC/PayPal donations)](https://www.patreon.com/user?u=619786). Beyond that, I am not interested in VC funding, monetizing, advertising, or anything of that sort. I could offer setup/maintenance services on demand.
 
-The software is free and open source and communities should host their own servers if they can, that way the costs are more or less distributed. Obviously it'd be hard for me to pay the bills if literally everyone decided to use the mastodon.social instance only.
\ No newline at end of file
+The software is free and open source and communities should host their own servers if they can, that way the costs are more or less distributed. Obviously it'd be hard for me to pay the bills if literally everyone decided to use the mastodon.social instance only.
diff --git a/docs/Using-Mastodon/List-of-Mastodon-instances.md b/docs/Using-Mastodon/List-of-Mastodon-instances.md
index a295d282b..f7a046949 100644
--- a/docs/Using-Mastodon/List-of-Mastodon-instances.md
+++ b/docs/Using-Mastodon/List-of-Mastodon-instances.md
@@ -47,7 +47,11 @@ There is also a list at [instances.mastodon.xyz](https://instances.mastodon.xyz)
 | [status.dissidence.ovh](https://status.dissidence.ovh)|N/A|Yes|Yes|
 | [mastodon.cc](https://mastodon.cc)|Art|Yes|No|
 | [mastodon.technology](https://mastodon.technology)|Open registrations, federates everywhere, for tech folks|Yes|No|
-| [mastodon.systemlab.fr](https://mastodon.systemlab.fr/)|Le mastodon Français, informatique, jeux-vidéos, gaming et hébergement.|Yes|No|
-| [digitalhumanities.club](http://www.digitalhumanities.club)|[Digital humanities](http://whatisdigitalhumanities.com) community; invitations will open once code of conduct drafted.|No|No|
+| [mastodon.systemlab.fr](https://mastodon.systemlab.fr/)|Le mastodon Français, informatique, jeux-vidéos, gaming et hébergement.|Yes|
+| [mastodon.top](https://mastodon.top) |N/A|Yes|Yes|
+| [niu.moe](https://niu.moe/)|:dolls: The most cutest node ever, FR/EN, anime and computer :balloon:|Yes|Yes|
+| [im-in.space](https://im-in.space/)|SPAAAAACE! Probably with a lot of French people. (Invite-only, might randomly open registrations)|No|Yes|
+| [social.bytestemplar.com](https://social.bytestemplar.com)|N/A|Yes|No|
+| [digitalhumanities.club](http://www.digitalhumanities.club)|[Digital humanities](http://whatisdigitalhumanities.com) community; invitations will open once code of conduct drafted.|No|No
 
 Let me know if you start running one so I can add it to the list! (Alternatively, add it yourself as a pull request).
diff --git a/docs/Using-Mastodon/User-guide.md b/docs/Using-Mastodon/User-guide.md
index acd02f24e..e456de29d 100644
--- a/docs/Using-Mastodon/User-guide.md
+++ b/docs/Using-Mastodon/User-guide.md
@@ -26,17 +26,17 @@ Mastodon User's Guide
 
 ## Intro
 
-Mastodon is a social network application based on the GNU Social protocol. It behaves a lot like other social networks, especially Twitter, with one key difference - it is open-source and anyone can start their own server (also called an "*instance*"), and users of any instance can interact freely with those of other instances (called "*federation*"). Thus, it is possible for small communities to set up their own servers to use amongst themselves while also allowing interaction with other communities.
+Mastodon is a social network application based on the OStatus protocol. It behaves a lot like other social networks, especially Twitter, with one key difference - it is open-source and anyone can start their own server (also called an "*instance*"), and users of any instance can interact freely with those of other instances (called "*federation*"). Thus, it is possible for small communities to set up their own servers to use amongst themselves while also allowing interaction with other communities.
 
 #### Decentralization and Federation
 
 Mastodon is a system decentralized through a concept called "*federation*" - rather than depending on a single person or organization to run its infrastructure, anyone can download and run the software and run their own server. Federation means different Mastodon servers can interact with each other seamlessly, similar to e.g. e-mail.
 
-As such, anyone can download Mastodon and e.g. run it for a small community of people, but any user registered on that instance can follow and send and read posts from other Mastodon instances (as well as servers running other GNU Social-compatible services). This means that not only is users' data not inherently owned by a company with an interest in selling it to advertisers, but also that if any given server shuts down its users can set up a new one or migrate to another instance, rather than the entire service being lost.
+As such, anyone can download Mastodon and e.g. run it for a small community of people, but any user registered on that instance can follow, send, and read posts from other Mastodon instances (as well as servers running other OStatus-compatible services, such as GNU Social and postActiv). This means that not only is users' data not inherently owned by a company with an interest in selling it to advertisers, but also that if any given server shuts down its users can set up a new one or migrate to another instance, rather than the entire service being lost.
 
 Within each Mastodon instance, usernames just appear as `@username`, similar to other services such as Twitter. Users from other instances appear, and can be searched for and followed, as `@user@servername.ext` - so e.g. `@gargron` on the `mastodon.social` instance can be followed from other instances as `@gargron@mastodon.social`).
 
-Posts from users on external instances are "*federated*" into the local one, i.e. if `user1@mastodon1` follows `user2@gnusocial2`, any posts `user2@gnusocial2` makes appear in both `user1@mastodon`'s Home feed and the public timeline on the `mastodon1` server. Mastodon server administrators have some control over this and can exclude users' posts from appearing on the public timeline; post privacy settings from users on Mastodon instances also affect this, see below in the [Toot Privacy](User-guide.md#toot-privacy) section.
+Posts from users on external instances are "*federated*" into the local one, i.e. if `user1@mastodon1` follows `user2@gnusocial2`, any posts `user2@gnusocial2` makes appear in both `user1@mastodon1`'s Home feed and the public timeline on the `mastodon1` server. Mastodon server administrators have some control over this and can exclude users' posts from appearing on the public timeline; post privacy settings from users on Mastodon instances also affect this, see below in the [Toot Privacy](User-guide.md#toot-privacy) section.
 
 ## Getting Started
 
@@ -44,7 +44,7 @@ Posts from users on external instances are "*federated*" into the local one, i.e
 
 You can customise your Mastodon profile in a number of ways - you can set a custom "display" name, a profile "avatar" picture, a background image for your profile page header, and a short "bio" that summarises you or your account.
 
-![Preferences icon](screenshots/preferences.png) To edit your profile, click the Preferences icon in the Compose column and select "Edit Profile" on the left-hand menu on the Preferences page. Your display name is limited to 30 characters, your bio to 160. Avatars and header pictures can be uploaded as png, gif or jpg images and cannot be larger than 2MB. They will be resized to standard sizes - 120x120 pixels for avatars, 700x335 pixels for header pictures.
+![Preferences icon](screenshots/preferences.png) To edit your profile, click the Preferences icon in the Compose column and select "Edit Profile" on the left-hand menu on the Preferences page. Your display name is limited to 30 characters, your bio to 160. Avatars and header pictures can be uploaded as png, gif or jpg images and cannot be larger than 2MB. They will be resized to standard sizes - 120x120 pixels for avatars, 700x335 pixels for header pictures. 
 
 #### E-Mail Notifications
 
@@ -56,17 +56,17 @@ The most basic way to interact with Mastodon is to make a text post, also called
 
 If you want to reply to another user's toot, you can click the "Reply" icon on it. This will add their username to your input box along with a preview of the message you're replying to, and the user will receive a notification of your response.
 
-Similarly, in order to start a conversation with another user, just mention their user name in your toot. When you type the @ symbol followed directly (without a space) by any character in a message, Mastodon will automatically start suggesting users that match the username you're typing. Like with replies, mentioning a user like this will send them a notification.
+Similarly, in order to start a conversation with another user, just mention their user name in your toot. When you type the @ symbol followed directly (without a space) by any character in a message, Mastodon will automatically start suggesting users that match the username you're typing. Like with replies, mentioning a user like this will send them a notification. If the post starts with a mention, it will be treated as a reply and will only appear in the Home timelines of users who follow both you and the user you are mentioning. It will still be visible on your profile depending on privacy settings.
 
 ##### Content Warnings
 
-When you want to post something that you don't want to be immediately visible - for example, spoilers for that film that's just out, or some personal thoughts that contain [triggers](http://www.bbc.co.uk/news/blogs-ouch-26295437), you can "hide" it behind a Content Warning.
+When you want to post something that you don't want to be immediately visible - for example, spoilers for that film that's just come out, or some personal thoughts that mention potentially upsetting topics, you can "hide" it behind a Content Warning.
 
 To do this, click the ![CW icon](screenshots/compose-cw.png) "CW" switch under the Compose box. This will add another text box labeled "Content warning"; you should enter a short summary of what the "body" of your post contains here while your actual post goes into the "What is on your mind?" box as normal.
 
 ![animation showing how to enable content warnings](screenshots/content-warning.gif)
 
-This will cause the body of your post to be hidden behind a "Show More" button in the timeline, with only the content warning visible by default:
+This will cause the body of your post to be hidden behind a "Show More" button in the timeline, with only the content warning and any mentioned users visible by default:
 
 ![animation showing content warnings in the timeline](screenshots/cw-toot.gif)
 
@@ -74,13 +74,13 @@ This will cause the body of your post to be hidden behind a "Show More" button i
 
 ##### Hashtags
 
-If you're making a post belonging to a wider subject, it might be worth adding a "hashtag" to it. This can be done simply by adding any alphanumeric term with a # sign in front of it to the toot, e.g. #introductions (which is popular on mastodon.social for new users to introduce themselves to the community), or #politics for political discussions, etc. Clicking on a hashtag in a toot will show a timeline consisting only of toots that include this hashtag (i.e. it's a shortcut to searching for it). This allows users to group messages of similar subjects together, forming a separate "timeline" for people interested in that subject.
+If you're making a post belonging to a wider subject, it might be worth adding a "hashtag" to it. This can be done simply by writing in the post a # sign followed by a phrase, e.g. #introductions (which is popular on mastodon.social for new users to introduce themselves to the community), or #politics for political discussions, etc. Clicking on a hashtag in a toot will show a timeline consisting only of public posts that include this hashtag (i.e. it's a shortcut to searching for it). This allows users to group messages of similar subjects together, forming a separate "timeline" for people interested in that subject. Hashtags can also be searched for from the search bar above the compose box.
 
 ##### Boosts and Favourites
 
 You can *favourite* another user's toot by clicking the star icon underneath. This will send the user a notification that you have marked their post as a favourite; the meaning of this varies widely by context from a general "I'm listening" to signalling agreement or offering support for the ideas expressed.
 
-Additionally you can *boost* toots by clicking the "circular arrows" icon. Boosting a toot will show it on your profile timeline and make it appear to all your followers, even if they aren't following the user who made the original post. This is helpful if someone posts a message you think others should see, as it increases the message's reach while keeping the author information intact.
+Additionally you can *boost* toots by clicking the "circular arrows" icon. Boosting a toot will show it on your profile timeline and make it appear to all your followers, even if they aren't following the user who made the original post. This is helpful if someone posts a message you think others should see, as it increases the message's reach while keeping the author information intact. 
 
 #### Posting Images
 
@@ -92,21 +92,21 @@ You can also attach video files or GIF animations to Toots. However, there is a
 
 #### Following Other Users
 
-Following another user will make all of their toots as well as other users' toots which they [boost](User-guide.md#boosts-and-favourites) in your Home column. This gives you a separate timeline from the [federated timeline](User-guide.md#the-federated-timeline) in which you can read what particular people are up to without the noise of general conversation.
+Following another user will make all of their toots as well as other users' toots which they [boost](User-guide.md#boosts-and-favourites) appear in your Home column. This gives you a separate timeline from the [public timelines](User-guide.md#the-public-timelines) in which you can read what particular people are up to without the noise of general conversation.
 
 ![Follow icon](screenshots/follow.png) In order to follow a user, click their name or avatar to open their profile, then click the Follow icon in the top left of their profile view.
 
-If their account is locked (which is shown with a padlock icon ![Padlock icon](screenshots/locked-icon.png) next to their user name), they will receive a notification of your request to follow them and need to approve this before you are added to their follower list (and thus see their toots). To show you that you're waiting for someone to approve your follow request, the Follow icon ![Follow icon](screenshots/follow-icon.png) on their profile will be replaced with an hourglass icon ![Pending icon](screenshots/pending-icon.png).
+If their account has a padlock icon ![Padlock icon](screenshots/locked-icon.png) next to their user name, they will receive a notification of your request to follow them and they will need to approve this before you are added to their follower list (and thus see their toots). To show you that you are waiting for someone to approve your follow request, the Follow icon ![Follow icon](screenshots/follow-icon.png) on their profile will be replaced with an hourglass icon ![Pending icon](screenshots/pending-icon.png). The requirement for new followers to be approved is something you can enable for your own profile under preferences.
 
 Once you follow a user, the Follow icon will be highlighted in blue on their profile ![Following icon](screenshots/following-icon.png); you can unfollow them again by clicking this.
 
 If you know someone's user name you can also open their profile for following by entering it in the [Search box](User-guide.md#searching) in the Compose column. This also works for remote users, though depending on whether they are known to your home instance you might have to enter their full name including the domain (e.g. `gargron@mastodon.social`) into the search box before their profile will appear in the suggestions.
 
-Alternately, if you already have a user's profile open in a separate browser tab, most GNU Social-related networks should have a "Follow" or "Subscribe" button on their profile page. This will ask you to enter the full user name to follow **from** (ie. if your account is on mastodon.social you would want to enter this as `myaccount@mastodon.social`)
+Alternately, if you already have a user's profile open in a separate browser tab, most OStatus-related networks should have a "Follow" or "Subscribe" button on their profile page. This will ask you to enter the full user name to follow **from** (ie. if your account is on mastodon.social you would want to enter this as `myaccount@mastodon.social`)
 
 #### Notifications
 
-When someone follows your account or requests to follow you, mentions your user name (either as an initial message or in response to one of your toots) or boosts or favourites one of your toots, you will receive a notification for this. These will appear as desktop notifications on your computer (if your web browser supports this and you've enabled them) as well as in your "Notifications" column.
+When someone follows your account or requests to follow you, mentions your user name, or boosts or favourites one of your toots, you will receive a notification for this. These will appear as desktop notifications on your computer (if your web browser supports this and you've enabled them) as well as in your "Notifications" column.
 
 ![Notification Settings icon](screenshots/notifications-settings.png) You can filter what kind of notifications you see in the Notifications column by clicking the Notification Settings icon at the top of the column and ticking or un-ticking what you do or don't want to see notifications for.
 
@@ -116,21 +116,25 @@ When someone follows your account or requests to follow you, mentions your user
 
 #### Mobile Apps
 
-There are no official mobile Mastodon apps for iOS or Android at this point. However, there are several third-party apps in development; you can find a list of these [here](Apps.md).
+Mastodon has an open API, so anyone can develop a client or app to use Mastodon from anything. Many people have already developed mobile apps for iOS and Android. You can find a list of these [here](Apps.md). Many of these projects are also open source and welcome collaborators.
 
-#### The Federated Timeline
+#### The Public Timelines
 
-Mastodon has a "Federated" timeline, which is a collection of all public toots made by all local users as well as posts from remote users that are federated (because someone on your instance follows the remote user making the post). This is a good way to meet new people to follow or interact with, but can be overwhelming especially if there's a lot of activity.
+In addition to your Home timeline, there are two public timelines available. The Federated Timeline and the Local Timeline. These are both a good way to meet new people to follow or interact with.
+
+##### The Federated Timeline
+
+The Federated Timeline shows all public posts from all users "known" to your instance. This means the user is either on the same instance as you, or somebody on your instance follows that user. The Federated Timeline is a great way to engage in the broad chatter of the world. Following users on remote instances who you meet on the Federated Timeline can lead to meeting more users on more instances and further connecting your instance to more and more of the entire Mastodon and OStatus network.
 
 ![Federated Timeline icon](screenshots/federated-timeline.png) To view the federated timeline, click the "Federated Timeline" icon in your Compose column or the respective button on the Getting Started panel. To hide the federated timeline again, simply click the "Back" link at the top of the column while you're viewing it.
 
 #### The Local Timeline
 
-In addition to the Federated Timeline, there's also a "Local" timeline, which only shows public toots made by users on your home instance. This is quieter than the Federated timeline, and useful if you want to stick close to your instance's community without having too much noise from outside. To view the Local Timeline, click the ![Menu icon](screenshots/compose-menu.png) Menu icon on the Compose pane and then select "Local Timeline" on the rightmost column.
+The Local Timeline only shows public posts made by users on your home instance. This can be useful if your instance has particular community norms that users on other instances may not have, such as particular topics that get put under content warnings; or particular in-jokes and shared interests. To view the Local Timeline, click the ![Menu icon](screenshots/compose-menu.png) Menu icon on the Compose pane and then select "Local Timeline" on the rightmost column.
 
 #### Searching
 
-Mastodon has a search function - however, this is limited to users and [hashtags](User-guide.md#hashtags) only and cannot be used to search through the full text of toots. In order to start a search, just type into the search box in the Compose column; Mastodon will automatically start showing suggestions of both user names and hashtags in a pop-up after a moment. Selecting any of these will open the user's profile or a view of all toots on the hashtag.
+Mastodon has a search function - you can use it to search for users and [hashtags](User-guide.md#hashtags). The search does not look through the entire text of posts, only hashtags. In order to start a search, just type into the search box in the Compose column and hit *enter*; This will open the search pane. The search pane will show suggestions as you type. Selecting any of these will open the user's profile or a view of all toots on the hashtag.
 
 ## Privacy, Safety and Security
 
@@ -140,7 +144,7 @@ Mastodon has a number of advanced security, privacy and safety features over mor
 
 Two-Factor Authentication (2FA) is a mechanism that improves the security of your Mastodon account by requiring a numeric code from another device (most commonly mobile phones) linked to your Mastodon account when you log in - this means that even if someone gets hold of both your e-mail address and your password, they cannot take over your Mastodon account as they would need a physical device you own to log in.
 
-Mastodon's 2FA uses Google Authenticator (or compatible apps). You can install this for free to your [Android](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) or [iOS](https://itunes.apple.com/gb/app/google-authenticator/id388497605) device; [this Wikipedia page](https://en.wikipedia.org/wiki/Google_Authenticator#Implementations) lists further versions of the app for other systems.
+Mastodon's 2FA uses Google Authenticator (or compatible apps, such as Authy). You can install this for free to your [Android](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) or [iOS](https://itunes.apple.com/gb/app/google-authenticator/id388497605) device; [this Wikipedia page](https://en.wikipedia.org/wiki/Google_Authenticator#Implementations) lists further versions of the app for other systems.
 
 ![Preferences icon](screenshots/preferences.png) In order to enable 2FA for your Mastodon account, click the Preferences icon in the Compose column, click "Two-factor Authentication" in the left menu on the settings page and follow the instructions. Once activated, every time you log in you will need a one-time code generated by the Authenticator app on the device you've linked to your account.
 
@@ -154,18 +158,20 @@ To allow you more control over who can see your toots, Mastodon supports "privat
 
 #### Toot Privacy
 
-Toot privacy is handled independently of account privacy, and individually for each toot. The three tiers of visibility for toots are Public (default), Unlisted or Private. In order to select your privacy level, click the ![Globe icon](screenshots/compose-privacy.png) globe icon. Changes to this setting are remembered between posts, i.e. if you make one private toot you will need to disable the switch again to make public toots.
+Toot privacy is handled independently of account privacy, and individually for each toot. The four tiers of visibility for toots are Public (default), Unlisted, Private, and Direct. In order to select your privacy level, click the ![Globe icon](screenshots/compose-privacy.png) globe icon. Changes to this setting are remembered between posts, i.e. if you make one private toot, each toot you make will be private until you change it back to public. You can change your default post privacy under preferences.
 
-**Public** is the default status of toots on accounts not set to private; a toot is public if neither of the two flags are set. Public toots are visible to any other user on the public timeline, federate to other GNU Social instances without restriction and appear on your user profile page to anyone including search engine bots and visitors who aren't logged into a Mastodon account.
+**Public** is the default status of toots on most accounts. Public toots are visible to any other user on the public timelines, federate to other Mastodon and OStatus instances without restriction, and appear on your user profile page to anyone including search engine bots and visitors who aren't logged into a Mastodon account.
 
-**Unlisted** toots are toggled with the "Do not display in public timeline" option in the Compose pane. They are visible to anyone following you and appear on your profile page to the public even without a Mastodon login, but do *not* appear to anyone viewing the Public Timeline while logged into Mastodon.
+**Unlisted** toots are public, except that they do not appear in the public timelines or search results. They are visible to anyone following you and appear on your profile page to the public even without a Mastodon login. Other than not appearing in the public timelines or search results, they function identically to public posts.
 
-**Private** toots, finally, are toggled with the "Mark as private" switch. Private toots do not appear in the public timeline nor on your profile page to anyone viewing it unless they are on your Followers list. This means the option is of very limited use if your account is not also set to be private (as anyone can follow you without confirmation and thus see your private toots). However the separation of this means that if you *do* set your entire account to private, you can switch this option off on a toot to make unlisted or even public toots from your otherwise private account. Private posts are not encrypted. Make sure you trust your instance admin not to just read your private posts on the back-end.
-
-Private toots do not federate to other instances, unless you @mention a remote user. In this case, they will federate to their instance *and may appear there PUBLICLY*. A warning will be displayed if you're composing a private toot that will federate to another instance.
+**Private** toots do not appear in the public timeline nor on your profile page to anyone viewing it unless they are on your Followers list. The option is of limited use if your account is not also set to require approval of new followers (as anyone can follow you without confirmation and thus see your private toots). However the separation of this means that if you *do* set your entire account to private, you can switch this option off on a toot to make unlisted or even public toots from your otherwise private account.
 
 Private toots cannot be boosted. If someone you follow makes a private toot, it will appear in your timeline with a padlock icon in place of the Boost icon. **NOTE** that remote instances may not respect this.
 
+Private toots do not federate to other instances, unless you @mention a remote user. In this case, they will federate to their instance, and users on that instance who follow both you and the @mentioned user will see it in their Home timelines. There is no reliable way to check if an instance will actually respect post privacy. Non-Mastodon servers, such as a GNU Social server, do not support Mastodon privacy settings. A user on GNU Social who you @mention in a private post would not even be aware that the post is intended to be private and would be able to boost it, which would undo the privacy setting. There is also no way to guarantee that someone could not just modify the code on their particular Mastodon instance to not respect private post restrictions. A warning will be displayed if you're composing a private toot that will federate to another instance. You should thus think through how much you trust the user you are @mentioning and the instance they are on.
+
+Private posts are not encrypted. Make sure you trust your instance admin not to just read your private posts on the back-end. Do not say anything you would not want potentially intercepted. 
+
 **Direct** posts are only visible to users you have @mentioned in them and cannot be boosted. Like with private posts, you should be mindful that the remote instance may not respect this protocol. If you are discussing a sensitive matter you should move the conversation off of Mastodon. 
 
 To summarise:
@@ -183,9 +189,13 @@ You can block a user to stop them contacting you. To do this, you can click or t
 
 **NOTE** that this will stop them from seeing your public toots while they are logged in, but they *will* be able to see your public toots by simply opening your profile in another browser that isn't logged into Mastodon (or logged into a different account that you have not blocked).
 
-Mentions, favourites, boosts or any other interaction with you from a blocked user will be hidden from your view. You will not see replies to a blocked person, even if the reply mentions you, nor will you see their toots if someone boosts them. You will not see toots mentioning a blocked person except in the public timeline.
+Mentions, favourites, boosts or any other interaction with you from a blocked user will be hidden from your view. You will not see replies to a blocked person, even if the reply mentions you, nor will you see their toots if someone boosts them.
+
+The blocked user will not be notified of your blocking them. They will be removed from your followers.
+
+#### Muting
 
-The blocked user will not be notified of your blocking them. They will be removed from your followers, *but* will still be able to see any public toots you make. Blocks do not federate across instances.
+If you do not wish to see posts from a particular user, but do not care about if they see your posts, you may choose to *mute* them. You can mute a user from the same menu on their profile page that you would block them from. You will not see posts from a muted user unless they @mention you. A muted user will have no way to know that you have them muted. 
 
 #### Reporting Toots or Users
 
diff --git a/scalingo.json b/scalingo.json
index 84b690e24..d60f1529c 100644
--- a/scalingo.json
+++ b/scalingo.json
@@ -1,7 +1,7 @@
 {
   "name": "Mastodon",
   "description": "A GNU Social-compatible microblogging server",
-  "repository": "https://github.com/johnsudaar/mastodon",
+  "repository": "https://github.com/tootsuite/mastodon",
   "logo": "https://github.com/tootsuite/mastodon/raw/master/app/assets/images/logo.png",
   "env": {
     "LOCAL_DOMAIN": {
diff --git a/spec/fabricators/media_attachment_fabricator.rb b/spec/fabricators/media_attachment_fabricator.rb
index 59db2440d..dc91d708f 100644
--- a/spec/fabricators/media_attachment_fabricator.rb
+++ b/spec/fabricators/media_attachment_fabricator.rb
@@ -1,3 +1,3 @@
 Fabricator(:media_attachment) do
-
+  account
 end
diff --git a/spec/fabricators/status_fabricator.rb b/spec/fabricators/status_fabricator.rb
index df222fc9d..8ec5f4ba7 100644
--- a/spec/fabricators/status_fabricator.rb
+++ b/spec/fabricators/status_fabricator.rb
@@ -1,3 +1,4 @@
 Fabricator(:status) do
+  account
   text "Lorem ipsum dolor sit amet"
 end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index d7f59adb8..93a45459d 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -99,11 +99,75 @@ RSpec.describe Account, type: :model do
   end
 
   describe '#favourited?' do
-    pending
+    let(:original_status) do
+      author = Fabricate(:account, username: 'original')
+      Fabricate(:status, account: author)
+    end
+
+    context 'when the status is a reblog of another status' do
+      let(:original_reblog) do
+        author = Fabricate(:account, username: 'original_reblogger')
+        Fabricate(:status, reblog: original_status, account: author)
+      end
+
+      it 'is is true when this account has favourited it' do
+        Fabricate(:favourite, status: original_reblog, account: subject)
+
+        expect(subject.favourited?(original_status)).to eq true
+      end
+
+      it 'is false when this account has not favourited it' do
+        expect(subject.favourited?(original_status)).to eq false
+      end
+    end
+
+    context 'when the status is an original status' do
+      it 'is is true when this account has favourited it' do
+        Fabricate(:favourite, status: original_status, account: subject)
+
+        expect(subject.favourited?(original_status)).to eq true
+      end
+
+      it 'is false when this account has not favourited it' do
+        expect(subject.favourited?(original_status)).to eq false
+      end
+    end
   end
 
   describe '#reblogged?' do
-    pending
+    let(:original_status) do
+      author = Fabricate(:account, username: 'original')
+      Fabricate(:status, account: author)
+    end
+
+    context 'when the status is a reblog of another status'do
+      let(:original_reblog) do
+        author = Fabricate(:account, username: 'original_reblogger')
+        Fabricate(:status, reblog: original_status, account: author)
+      end
+
+      it 'is true when this account has reblogged it' do
+        Fabricate(:status, reblog: original_reblog, account: subject)
+
+        expect(subject.reblogged?(original_reblog)).to eq true
+      end
+
+      it 'is false when this account has not reblogged it' do
+        expect(subject.reblogged?(original_reblog)).to eq false
+      end
+    end
+
+    context 'when the status is an original status' do
+      it 'is true when this account has reblogged it' do
+        Fabricate(:status, reblog: original_status, account: subject)
+
+        expect(subject.reblogged?(original_status)).to eq true
+      end
+
+      it 'is false when this account has not reblogged it' do
+        expect(subject.reblogged?(original_status)).to eq false
+      end
+    end
   end
 
   describe '.find_local' do
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index b9d079521..000bee0f5 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -91,10 +91,31 @@ RSpec.describe Status, type: :model do
   end
 
   describe '#reblogs_count' do
-    pending
+    it 'is the number of reblogs' do
+      Fabricate(:status, account: bob, reblog: subject)
+      Fabricate(:status, account: alice, reblog: subject)
+
+      expect(subject.reblogs_count).to eq 2
+    end
   end
 
   describe '#favourites_count' do
-    pending
+    it 'is the number of favorites' do
+      Fabricate(:favourite, account: bob, status: subject)
+      Fabricate(:favourite, account: alice, status: subject)
+
+      expect(subject.favourites_count).to eq 2
+    end
+  end
+
+  describe '#proper' do
+    it 'is itself for original statuses' do
+      expect(subject.proper).to eq subject
+    end
+
+    it 'is the source status for reblogs' do
+      subject.reblog = other
+      expect(subject.proper).to eq other
+    end
   end
 end
diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb
index 9ee4daf6f..0e39cd969 100644
--- a/spec/services/post_status_service_spec.rb
+++ b/spec/services/post_status_service_spec.rb
@@ -3,8 +3,168 @@ require 'rails_helper'
 RSpec.describe PostStatusService do
   subject { PostStatusService.new }
 
-  it 'creates a new status'
-  it 'creates a new response status'
-  it 'processes mentions'
-  it 'pings PuSH hubs'
+  it 'creates a new status' do
+    account = Fabricate(:account)
+    text = "test status update"
+
+    status = subject.call(account, text)
+
+    expect(status).to be_persisted
+    expect(status.text).to eq text
+  end
+
+  it 'creates a new response status' do
+    in_reply_to_status = Fabricate(:status)
+    account = Fabricate(:account)
+    text = "test status update"
+
+    status = subject.call(account, text, in_reply_to_status)
+
+    expect(status).to be_persisted
+    expect(status.text).to eq text
+    expect(status.thread).to eq in_reply_to_status
+  end
+
+  it 'creates a sensitive status' do
+    status = create_status_with_options(sensitive: true)
+
+    expect(status).to be_persisted
+    expect(status).to be_sensitive
+  end
+
+  it 'creates a status with spoiler text' do
+    spoiler_text = "spoiler text"
+
+    status = create_status_with_options(spoiler_text: spoiler_text)
+
+    expect(status).to be_persisted
+    expect(status.spoiler_text).to eq spoiler_text
+  end
+
+  it 'creates a status with empty default spoiler text' do
+    status = create_status_with_options(spoiler_text: nil)
+
+    expect(status).to be_persisted
+    expect(status.spoiler_text).to eq ''
+  end
+
+  it 'creates a status with the given visibility' do
+    status = create_status_with_options(visibility: :private)
+
+    expect(status).to be_persisted
+    expect(status.visibility).to eq "private"
+  end
+
+  it 'creates a status for the given application' do
+    application = Fabricate(:application)
+
+    status = create_status_with_options(application: application)
+
+    expect(status).to be_persisted
+    expect(status.application).to eq application
+  end
+
+  it 'processes mentions' do
+    mention_service = double(:process_mentions_service)
+    allow(mention_service).to receive(:call)
+    allow(ProcessMentionsService).to receive(:new).and_return(mention_service)
+    account = Fabricate(:account)
+
+    status = subject.call(account, "test status update")
+
+    expect(ProcessMentionsService).to have_received(:new)
+    expect(mention_service).to have_received(:call).with(status)
+  end
+
+  it 'processes hashtags' do
+    hashtags_service = double(:process_hashtags_service)
+    allow(hashtags_service).to receive(:call)
+    allow(ProcessHashtagsService).to receive(:new).and_return(hashtags_service)
+    account = Fabricate(:account)
+
+    status = subject.call(account, "test status update")
+
+    expect(ProcessHashtagsService).to have_received(:new)
+    expect(hashtags_service).to have_received(:call).with(status)
+  end
+
+  it 'pings PuSH hubs' do
+    allow(DistributionWorker).to receive(:perform_async)
+    allow(Pubsubhubbub::DistributionWorker).to receive(:perform_async)
+    account = Fabricate(:account)
+
+    status = subject.call(account, "test status update")
+
+    expect(DistributionWorker).to have_received(:perform_async).with(status.id)
+    expect(Pubsubhubbub::DistributionWorker).
+      to have_received(:perform_async).with(status.stream_entry.id)
+  end
+
+  it 'crawls links' do
+    allow(LinkCrawlWorker).to receive(:perform_async)
+    account = Fabricate(:account)
+
+    status = subject.call(account, "test status update")
+
+    expect(LinkCrawlWorker).to have_received(:perform_async).with(status.id)
+  end
+
+  it 'attaches the given media to the created status' do
+    account = Fabricate(:account)
+    media = Fabricate(:media_attachment)
+
+    status = subject.call(
+      account,
+      "test status update",
+      nil,
+      media_ids: [media.id],
+    )
+
+    expect(media.reload.status).to eq status
+  end
+
+  it 'does not allow attaching more than 4 files' do
+    account = Fabricate(:account)
+
+    expect do
+      subject.call(
+        account,
+        "test status update",
+        nil,
+        media_ids: [
+          Fabricate(:media_attachment, account: account),
+          Fabricate(:media_attachment, account: account),
+          Fabricate(:media_attachment, account: account),
+          Fabricate(:media_attachment, account: account),
+          Fabricate(:media_attachment, account: account),
+        ].map(&:id),
+      )
+    end.to raise_error(
+      Mastodon::ValidationError,
+      I18n.t('media_attachments.validations.too_many'),
+    )
+  end
+
+  it 'does not allow attaching both videos and images' do
+    account = Fabricate(:account)
+
+    expect do
+      subject.call(
+        account,
+        "test status update",
+        nil,
+        media_ids: [
+          Fabricate(:media_attachment, type: :video, account: account),
+          Fabricate(:media_attachment, type: :image, account: account),
+        ].map(&:id),
+      )
+    end.to raise_error(
+      Mastodon::ValidationError,
+      I18n.t('media_attachments.validations.images_and_video'),
+    )
+  end
+
+  def create_status_with_options(options = {})
+    subject.call(Fabricate(:account), "test", nil, options)
+  end
 end
diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb
index 5e57d823b..b15284fee 100644
--- a/spec/services/process_feed_service_spec.rb
+++ b/spec/services/process_feed_service_spec.rb
@@ -16,6 +16,7 @@ RSpec.describe ProcessFeedService do
   end
 
   it 'updates remote user\'s account information' do
+    account.reload
     expect(account.display_name).to eq '::1'
     expect(account).to have_attached_file(:avatar)
   end