diff --git a/.env.production.sample b/.env.production.sample
index a7f9eb4bf85c8629c3d027b2d8d9abf387488ec4..d7c04e23548082b7e7778c7459695772a899931e 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/Dockerfile b/Dockerfile
index c28287cca8708e3468b3fcd459935a6a8da4a7a5..57a8f34e97e3f04fff8b89289bac31040d782b3b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -29,7 +29,8 @@ 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/*
 
diff --git a/Gemfile b/Gemfile
index b5705e9d19fd6b167c163a7a139e10a395afd83b..65bd5eb495b740190cac2303d79bedd0218446d4 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 408d85ade1293f6a18432e7627a0c31f6f53bc3a..f2a199931f394763b12ccc28035c8a71d8fe4adf 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 6cdd89518f2dc1a3ccccf676a036b01ff949dc24..646e26059e0918d5d1bf99da23ddd8282cc1c35c 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/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index cbb7b85bcb9a8e1625fc9c11d9be313684cf60be..00f20074d9146f761ac1b235455a60de3483cca5 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -47,6 +47,7 @@ import pt from 'react-intl/locale-data/pt';
 import hu from 'react-intl/locale-data/hu';
 import uk from 'react-intl/locale-data/uk';
 import fi from 'react-intl/locale-data/fi';
+import eo from 'react-intl/locale-data/eo';
 import getMessagesForLocale from '../locales';
 import { hydrateStore } from '../actions/store';
 import createStream from '../stream';
@@ -59,7 +60,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
   basename: '/web'
 });
 
-addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi]);
+addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi, ...eo]);
 
 const Mastodon = React.createClass({
 
diff --git a/app/assets/javascripts/components/locales/eo.jsx b/app/assets/javascripts/components/locales/eo.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8c118b31fa50060927149e3114e23bfe29f665e2
--- /dev/null
+++ b/app/assets/javascripts/components/locales/eo.jsx
@@ -0,0 +1,68 @@
+const eo = {
+  "column_back_button.label": "Reveni",
+  "lightbox.close": "Fermi",
+  "loading_indicator.label": "Ŝarĝanta...",
+  "status.mention": "Mencii @{name}",
+  "status.delete": "Forigi",
+  "status.reply": "Respondi",
+  "status.reblog": "Diskonigi",
+  "status.favourite": "Favori",
+  "status.reblogged_by": "{name} diskonigita",
+  "status.sensitive_warning": "Tikla enhavo",
+  "status.sensitive_toggle": "Alklaki por vidi",
+  "video_player.toggle_sound": "Aktivigi sonojn",
+  "account.mention": "Mencii @{name}",
+  "account.edit_profile": "Redakti la profilon",
+  "account.unblock": "Malbloki @{name}",
+  "account.unfollow": "Malsekvi",
+  "account.block": "Bloki @{name}",
+  "account.follow": "Sekvi",
+  "account.posts": "Mesaĝoj",
+  "account.follows": "Sekvatoj",
+  "account.followers": "Sekvantoj",
+  "account.follows_you": "Sekvas vin",
+  "account.requested": "Atendas aprobon",
+  "getting_started.heading": "Por komenci",
+  "getting_started.about_addressing": "Vi povas sekvi homojn se vi konas la uzantnomon kaj domajnon tajpinte retpoŝtecan adreson en la serĉilon.",
+  "getting_started.about_shortcuts": "Se la celita uzanto troviĝas en la sama domajno de vi, uzi nur la uzantnomon sufiĉos. La sama regulo validas por mencii aliajn uzantojn en mesaĝo.",
+  "getting_started.open_source_notice": "Mastodon estas malfermitkoda programo. Vi povas kontribui aÅ­ raporti problemojn en github je {github}. {apps}.",
+  "column.home": "Hejmo",
+  "column.community": "Loka tempolinio",
+  "column.public": "Fratara tempolinio",
+  "column.notifications": "Sciigoj",
+  "tabs_bar.compose": "Ekskribi",
+  "tabs_bar.home": "Hejmo",
+  "tabs_bar.mentions": "Sciigoj",
+  "tabs_bar.public": "Fratara tempolinio",
+  "tabs_bar.notifications": "Sciigoj",
+  "compose_form.placeholder": "Pri kio vi pensas?",
+  "compose_form.publish": "Hup",
+  "compose_form.sensitive": "Marki ke la enhavo estas tikla",
+  "compose_form.spoiler": "Kaŝi la tekston malantaŭ averto",
+  "compose_form.private": "Marki ke la enhavo estas privata",
+  "compose_form.privacy_disclaimer": "Via privata mesaĝo estos sendita nur al menciitaj uzantoj en {domains}. Ĉu vi fidas {domainsCount, plural, one {tiun servilon} other {tiujn servilojn}}? Mesaĝa privateco funkcias nur en aperaĵoj de Mastodon. Se {domains} {domainsCount, plural, one {ne estas aperaĵo de Mastodon} other {ne estas aperaĵoj de Mastodon}}, estos neniu indiko ke via mesaĝo estas privata, kaj ĝi povus esti diskonigita aŭ videbligita al necelitaj ricevantoj.",
+  "compose_form.unlisted": "Ne afiŝi en publikaj tempolinioj",
+  "navigation_bar.edit_profile": "Redakti la profilon",
+  "navigation_bar.preferences": "Preferoj",
+  "navigation_bar.community_timeline": "Loka tempolinio",
+  "navigation_bar.public_timeline": "Fratara tempolinio",
+  "navigation_bar.logout": "Elsaluti",
+  "reply_indicator.cancel": "Rezigni",
+  "search.placeholder": "Serĉi",
+  "search.account": "Konto",
+  "search.hashtag": "Kradvorto",
+  "upload_button.label": "Aldoni enhavaĵon",
+  "upload_form.undo": "Malfari",
+  "notification.follow": "{name} sekvis vin",
+  "notification.favourite": "{name} favoris vian mesaĝon",
+  "notification.reblog": "{name} diskonigis vian mesaĝon",
+  "notification.mention": "{name} menciis vin",
+  "notifications.column_settings.alert": "Retumilaj atentigoj",
+  "notifications.column_settings.show": "Montri en kolono",
+  "notifications.column_settings.follow": "Novaj sekvantoj:",
+  "notifications.column_settings.favourite": "Favoroj:",
+  "notifications.column_settings.mention": "Mencioj:",
+  "notifications.column_settings.reblog": "Diskonigoj:",
+};
+
+export default eo;
diff --git a/app/assets/javascripts/components/locales/index.jsx b/app/assets/javascripts/components/locales/index.jsx
index 72b8a5df5d3e49cc402efced40f6fdd8541dc24c..1e7b8b548e2d3f36c128d1c93870df96666d398c 100644
--- a/app/assets/javascripts/components/locales/index.jsx
+++ b/app/assets/javascripts/components/locales/index.jsx
@@ -6,6 +6,7 @@ import fr from './fr';
 import pt from './pt';
 import uk from './uk';
 import fi from './fi';
+import eo from './eo';
 
 const locales = {
   en,
@@ -15,7 +16,8 @@ const locales = {
   fr,
   pt,
   uk,
-  fi
+  fi,
+  eo
 };
 
 export default function getMessagesForLocale (locale) {
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index d233b3471a2ac711fa20c41cfe17f2da0840e1ee..696e89418a5eea86d8d4bf11e461e1dcc9fa5cc9 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -1,5 +1,9 @@
 @import 'variables';
 
+.app-body{
+ -ms-overflow-style: -ms-autohiding-scrollbar; 
+}
+
 .button {
   background-color: darken($color4, 3%);
   font-family: inherit;
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index dc1aeb5ea5eeab46165fb0637a92199bd2f52f77..619c04be26e4f8e5376e5b6e7fb6746213fe1514 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 c06142fd43ac211fc597f15038186524d5e18b88..f00f9c1e35dfd961d8e211d0c6cfd07f01616722 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 0000000000000000000000000000000000000000..b6f86809010f7138aa9114879bc9e51bddb9b82c
--- /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 7c25266d81356fe66881c6bbf52e4284682a1bae..cdbfde0fbd0e2c510eb11d46683731275e5ff5d0 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 0000000000000000000000000000000000000000..09dd5d3c4b5bc9a98e3445b1aa6532bf9f9ae6db
--- /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 de38b360232813d2308523859e7e5d3a767214ac..469a8c33e2b874b7b8c1f02184b83afc8df84c2d 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/settings_helper.rb b/app/helpers/settings_helper.rb
index e01f7d0cc4837e884c48dab8c83d2151e6677001..74dc0e11d760b90bb1a87e6551123016cb8dd9fc 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -11,6 +11,7 @@ module SettingsHelper
     uk: 'Українська',
     'zh-CN': '简体中文',
     fi: 'Suomi',
+    eo: 'Esperanto',
   }.freeze
 
   def human_locale(locale)
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index a26e912a3b2bbc0c1cf74c960d73011312cdb540..38e63ed8da1a18130c47fcfce5500f15d64b8d94 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 0000000000000000000000000000000000000000..b9dcee6b32d8c04094620b365338fa7d4abbf46c
--- /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/tag_manager.rb b/app/lib/tag_manager.rb
index 2a5e7a409534c8d500f406c86293ee6f6c329999..07b2fb91e01645a45d635b8d60fe3efb53dff0f7 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 6968607a2af6fbcb0bc07f3a1e0e253917c5625b..cbba8b5b6d116b676682e12b19da2de57835d9c5 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 6948ad77c20f4b2b6b845db8c97bf1bd93ce52ce..7e3dd3e28829e8372cf6668b8ee023f55f5dc3c6 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 ae7ae446e9be0a41ac0e66f9454901f8342b3d97..8aff5ae0698f12949eb9c72a1904384664d38c42 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 8c6197f2c3407ef6aa7e98f7d4da60803b3095a6..0f478bcb7dd8c3c867e50e21fa86f15cedfcbfc5 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 ac465bdb23c321ab4e196d4e14866b1ff4a19440..97c76bee107600aaf21cd120ef0dedc3f480ad3b 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 bd914d8be7e2cedea76d75253218fb0023dca1c1..d59b47afbff34b35afed0d42bd609a0eb6f82809 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 a4255daead0fa80ff6f72d30d6db6d39f823ef1c..ef176d8a6623d74721c8cb78b096857b42e9e405 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/favourite_service.rb b/app/services/favourite_service.rb
index 5cc96403cde3e3a7a9c7c65b42801c49e93071bb..e92aada64f1c697b7af271b488008fc9d3629fb8 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_service.rb b/app/services/follow_service.rb
index 17b3b25423001c4233011ea0c688c11501511103..844f5282d4616fe018aa4c918e5d863760b48c82 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/post_status_service.rb b/app/services/post_status_service.rb
index b8179f7dccfc9ca63389d001f75a57f836582e0d..221aa42a3896ff82beb3bf6126b590bdbc252a8f 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/reject_follow_service.rb b/app/services/reject_follow_service.rb
index 1b03d62e645287b282add1ad7274ebfe2314b509..675007938b991b78749ece27b476a6c1264a7524 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/unblock_service.rb b/app/services/unblock_service.rb
index c4f789f742375e03b4bf9bfaee39ff992ecd8814..3a3fd2d8c10beb318d8a47702183725afcca47d1 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 5f0ba425408c2f5166c6df7e74f256f685769a81..a32e87bff3432945580b3a2c97ec9e246ddbc0bb 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 3440da364a7aa5fdf979aa880ed5bc4981c44db7..244c9b52904f24dcda8a967ec534f45af750c898 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 e15021178ef8feb49d348e538c1cf49f8573a93e..0000000000000000000000000000000000000000
--- 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 7eae6982bd0dc90290a261f3eed85b8c31c2e65c..abab14a28778201cedda529575fcc207fc4d9054 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
similarity index 100%
rename from app/views/doorkeeper/authorized_applications/index.html.haml
rename to 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 cdd0dde3be91c6d03995d1e3c21f1644d7aa13ed..434c5c8da2d513ea6ee852085de3bcd7903bd514 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 a298f3269c5c7e7bcd59b6489b2811ec7662c3f2..0000000000000000000000000000000000000000
--- 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 0000000000000000000000000000000000000000..8b72722da8f011abadfa7d9add7fbd679bb5c3e1
--- /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 0000000000000000000000000000000000000000..796913abb996da75603710aafb2c5aedd4675b1f
--- /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 0000000000000000000000000000000000000000..c56b965934f96cb6103ed447fb3c53f59e816cdb
--- /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 0000000000000000000000000000000000000000..d90c3fdebdc37985fb19f5bea9be101377302725
--- /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 0000000000000000000000000000000000000000..53be0b62bca74a50c259a53b226fd7adbc2683de
--- /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 0000000000000000000000000000000000000000..c826d5fc887c4133c391f59b016e67f5546af160
--- /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/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index 466def3a85f2f7ba65544b97922cde79bb2fe2d3..8412be4b75c286bb703f12201fdc8afca2a92e76 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 82ff257afabdbbfa9b0a316db37fa22d8185a949..68ca0f870c898c3463218c3737b1f542260c2670 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/config/application.rb b/config/application.rb
index 17b7a19cc96dd23c0f48812e5972776102c403c3..9a5c0d0d3fe1c2d0afba76dd043165530fb196b5 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -24,7 +24,7 @@ module Mastodon
 
     # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
     # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
-    config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN', :fi]
+    config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN', :fi, :eo]
     config.i18n.default_locale    = :en
 
     # config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')
diff --git a/config/locales/devise.eo.yml b/config/locales/devise.eo.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b786647dd7ba20c58950a821bf5553881a104324
--- /dev/null
+++ b/config/locales/devise.eo.yml
@@ -0,0 +1,61 @@
+---
+eo:
+  devise:
+    confirmations:
+      confirmed: Via konto estas konfirmita.
+      send_instructions: Vi ricevos instrukciojn por konfirmi vian konton post kelkaj minutoj.
+      send_paranoid_instructions: Se via retpoŝt-adreso ekzistas en nia datumbazo, vi baldaŭ ricevos retpoŝt-mesaĝon, kiu enhavas la instrukciojn por konfirmi vian konton.
+    failure:
+      already_authenticated: Vi jam estas ensalutita.
+      inactive: Via konto ankoraÅ­ ne estas konfirmita.
+      invalid: Malĝusta retpoŝt-adreso aŭ pasvorto.
+      last_attempt: Vi ankoraŭ povas provi unufoje antaŭ ol via konto estos ŝlosita.
+      locked: Via konto estas ŝlosita.
+      not_found_in_database: Malĝusta retpoŝt-adreso aŭ pasvorto.
+      timeout: Via sesio eksiĝis. Bonvolu reensaluti por daŭrigi.
+      unauthenticated: Vi devas ensaluti aŭ membriĝi por daŭrigi.
+      unconfirmed: Vi devas konfirmi vian konton por daÅ­rigi.
+    mailer:
+      confirmation_instructions:
+        subject: Instrukcioj por konfirmi
+      password_change:
+        subject: Via pasvorto estis ŝanĝita senprobleme.
+      reset_password_instructions:
+        subject: Instrukcioj por ŝanĝi la pasvorton
+      unlock_instructions:
+        subject: Instrukcioj por malŝlosi la konton
+    omniauth_callbacks:
+      failure: 'Ni ne povis aÅ­tentigi vin per %{kind}: ''%{reason}''.'
+      success: AÅ­tentigita senprobleme per %{kind}.
+    passwords:
+      no_token: Vi ne povas iri al tiu paĝo per alia vojo ol retpoŝt-mesaĝo por ŝanĝi pasvorton. Se vi venas de tia retpoŝt-mesaĝo, kontrolu ke vi uzis la tutan URL.
+      send_instructions: Vi ricevos retpoŝt-mesaĝon kun instrukcioj por ŝanĝi vian pasvorton post kelkaj minutoj.
+      send_paranoid_instructions: Se via retpoŝt-adreso ekzistas en nia datumbazo, vi ricevos ligilon por ŝanĝi vian pasvorton per retpoŝt-mesaĝo.
+      updated: Via pasvorto estis redaktita senprobleme, vi nun estas ensalutita.
+      updated_not_active: Via pasvorto estis redaktita senprobleme.
+    registrations:
+      destroyed: Äœis! Via konto estis forigita senprobleme. Ni esperas revidi vin baldaÅ­.
+      signed_up: Bonvenon! Vi membriĝis senprobleme.
+      signed_up_but_inactive: Vi bone membriĝis, sed vi ankoraŭ ne povas ensaluti ĉar via konto ne estis konfirmita.
+      signed_up_but_locked: Vi bone membriĝis, sed vi ne povas ensaluti ĉar via konto estas ŝlosita.
+      signed_up_but_unconfirmed: Retpoŝt-mesaĝo kun via ligilo por konfirmi vian konton estis sendita al via retpoŝt-adreso. Bonvolu uzi tiun ligilon por konfirmi vian konton.
+      update_needs_confirmation: Vi bone aktualigis vian konton, sed ni bezonas kontroli vian novan retpoŝt-adreson. Bonvolu kontroli viajn retpoŝt-mesaĝojn kaj uzi la ligilon por konfirmi vian novan retpoŝt-adreson.
+      updated: Via konto estis aktualigita senprobleme.
+    sessions:
+      already_signed_out: Elsalutita.
+      signed_in: Ensalutita.
+      signed_out: Elsalutita.
+    unlocks:
+      send_instructions: Vi ricevos retpoŝt-mesaĝon kun instrukcioj por malŝlosi vian konton post kelkaj minutoj.
+      send_paranoid_instructions: Se via retpoŝt-adreso ekzistas en nia datumbazo, vi ricevos ligilon por malŝlosi vian konton per retpoŝt-mesaĝo.
+      unlocked: Via konto estis malŝlosita senprobleme, vi nun estas ensalutita.
+  errors:
+    messages:
+      already_confirmed: jam estis konfirmita, bonvolu provi ensaluti
+      confirmation_period_expired: devas esti konfirmita en %{period}, bonvolu repeti
+      expired: eksiĝis, bonvolu repeti
+      not_found: ne estis trovita
+      not_locked: ne estis ŝlosita
+      not_saved:
+        one: '1 eraro malpermesis al tiu %{resource} esti konservita:'
+        other: '%{count} eraroj malpermesis al tiu %{resource} esti konservita:'
diff --git a/config/locales/doorkeeper.eo.yml b/config/locales/doorkeeper.eo.yml
new file mode 100644
index 0000000000000000000000000000000000000000..33cc7cc199aab6c975b5249b85e016b07c2fb0c4
--- /dev/null
+++ b/config/locales/doorkeeper.eo.yml
@@ -0,0 +1,113 @@
+---
+eo:
+  activerecord:
+    attributes:
+      doorkeeper/application:
+        name: Nomo
+        redirect_uri: URI de plusendo
+    errors:
+      models:
+        doorkeeper/application:
+          attributes:
+            redirect_uri:
+              fragment_present: ne povas enhavi eron.
+              invalid_uri: devas esti valida URI.
+              relative_uri: devas esti absoluta URI.
+              secured_uri: devas esti HTTPS/SSL-a URI.
+  doorkeeper:
+    applications:
+      buttons:
+        authorize: Rajtigi
+        cancel: Rezigni
+        destroy: Detrui
+        edit: Redakti
+        submit: Sendi
+      confirmations:
+        destroy: Ĉu vi certas?
+      edit:
+        title: Redakti aplikaĵon
+      form:
+        error: Ups! Kontrolu vian formularon ĉu estas eraroj
+      help:
+        native_redirect_uri: Uzu %{native_redirect_uri} por lokaj provoj
+        redirect_uri: Uzu unu linion por ĉiu URI
+        scopes: Apartigu ampleksojn per spacetoj. Lasu malplena por uzi la senŝanĝajn ampleksojn.
+      index:
+        callback_url: URL vokita per referenco
+        name: Nomo
+        new: Nova Aplikaĵo
+        title: Viaj aplikaĵoj
+      new:
+        title: Nova aplikaĵo
+      show:
+        actions: Agoj
+        application_id: Identigo de la aplikaĵo
+        callback_urls: URL-j vokitaj per referenco
+        scopes: Ampleksoj
+        secret: Sekreto
+        title: 'Aplikaĵo: %{name}'
+    authorizations:
+      buttons:
+        authorize: Rajtigi
+        deny: Rifuzi
+      error:
+        title: Eraro okazis
+      new:
+        able_to: Povos
+        prompt: La aplikaĵo %{client_name} petas aliron al via konto
+        title: Rajtigo bezonata
+      show:
+        title: Rajtiga kodo
+    authorized_applications:
+      buttons:
+        revoke: Malrajtigi
+      confirmations:
+        revoke: Ĉu vi certas?
+      index:
+        application: Aplikaĵo
+        created_at: Rajtigita
+        date_format: "%Y-%m-%d %H:%M:%S"
+        scopes: Ampleksoj
+        title: Viaj rajtigitaj aplikaĵoj
+    errors:
+      messages:
+        access_denied: La posedanto de la rimedo aÅ­ la rajtiga servilo rifuzis vian peton.
+        credential_flow_not_configured: La sendado de la identigiloj de la posedanto de la rimedo malsukcesis ĉar Doorkeeper.configure.resource_owner_from_credentials ne estis agordita.
+        invalid_client: La aŭtentigo de la kliento malsukcesis ĉar la kliento estas nekonata, aŭ mankis peto aŭtentigi, aŭ la aŭtentig-metodo ne estas subtenata.
+        invalid_grant: La rajtiga konsento ne estas valida, ne plu estas valida, estis forigita, ne kongruas kun la plusenda URI uzita en la aÅ­tentiga peto, aÅ­ estis sendita al alia kliento.
+        invalid_redirect_uri: La plusenda URI uzita en estas valida.
+        invalid_request: Mankis al la peto nepra parametro, enhavas nesubtenatan parametran valoron, aÅ­ la peto simple estas misformita.
+        invalid_resource_owner: La donitaj identigiloj pri la posedanto de la rimedo ne estas validaj, aÅ­ tiu ne povas esti trovita.
+        invalid_scope: La petita amplekso ne estas valida, estas nekonata, aÅ­ estas misformita.
+        invalid_token:
+          expired: La atingoĵetono eskiĝis.
+          revoked: La atingoĵetono estis rifuzita.
+          unknown: La atingoĵetono ne estas valida.
+        resource_owner_authenticator_not_configured: La posedanto de la rimedo ne povis esti trovita ĉar Doorkeeper.configure.resource_owner_authenticator ne estas agordita.
+        server_error: La rajtiga servilo rimarkis neatenditan kondiĉon, kiu malpermesis al ĝi plenumi la peton.
+        temporarily_unavailable: La rajtiga servilo ne povas nun plenumi la peton pro dumtempa superŝarĝo aŭ prizorgado de la servilo.
+        unauthorized_client: La kliento ne rajtas fari tian peton uzante tiun metodon.
+        unsupported_grant_type: La tipo de la rajtiga konsento ne estas subtenata de la rajtiga servilo.
+        unsupported_response_type: La rajtiga servilo ne subtenas tian respondon.
+    flash:
+      applications:
+        create:
+          notice: Aplikaĵo kreita.
+        destroy:
+          notice: Aplikaĵo forigita.
+        update:
+          notice: Aplikaĵo aktualigita.
+      authorized_applications:
+        destroy:
+          notice: Aplikaĵo malrajtigita.
+    layouts:
+      admin:
+        nav:
+          applications: Aplikaĵoj
+          oauth2_provider: OAuth2-provizanto
+      application:
+        title: OAuth-a rajtigo bezonata
+    scopes:
+      follow: sekvi, bloki, malbloki kaj malsekvi kontojn
+      read: legi la datumojn de via konto
+      write: mesaĝi kiel vi
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 742219df99f7d84056f935621a33b2db49b9f5c6..aa3a732f96b967fda38df3ad9bbb5217c73430ed 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/eo.yml b/config/locales/eo.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3644b37bb72b1eeb0440a65d9c96751bf44152e7
--- /dev/null
+++ b/config/locales/eo.yml
@@ -0,0 +1,164 @@
+---
+eo:
+  about:
+    about_mastodon: Mastodon estas <em>senpaga, malfermitkoda</em> socia reto. Ĝi estas <em>sencentra</em> alia eblo al komercaj servoj. Ĝi evitigas, ke unusola firmao regu vian tutan komunikadon. Elektu servilon, kiun vi fidas. Kiu ajn estas via elekto, vi povas interagi kun ĉiuj aliaj uzantoj. Iu ajn povas krei sian propran aperaĵon de Mastodon en sia servilo, kaj partopreni en la <em>socia reto</em> tute glate.
+    about_this: Pri tiu aperaĵo
+    apps: Aplikaĵoj
+    business_email: 'Profesia retpoŝt-adreso:'
+    contact: Kontakti
+    description_headline: Kio estas %{domain}?
+    domain_count_after: aliaj aperaĵoj
+    domain_count_before: Konektita al
+    features:
+      api: Malfermita API por aplikaĵoj kaj servoj
+      blocks: Kompletaj iloj por bloki kaj kaŝi
+      characters: Po 500 signoj por ĉiu mesaĝo
+      chronology: Tempolinioj laÅ­tempaj
+      ethics: 'Etike kreita: neniu reklamo, neniu ŝpurado'
+      gifv: Eblo diskonigi etajn videojn kaj GIFV
+      privacy: Videbleco agordita laŭ la mesaĝo
+      public: Publikaj tempolinioj
+    features_headline: Kiel Mastodon estas malsimila
+    get_started: Komenci
+    links: Ligiloj
+    other_instances: Aliaj aperaĵoj
+    source_code: Fontkodo
+    status_count_after: mesaĝoj
+    status_count_before: Kiu publikigis
+    terms: Terms
+    user_count_after: uzantoj
+    user_count_before: Hejmo de
+  accounts:
+    follow: Sekvi
+    followers: Sekvantoj
+    following: Sekvatoj
+    nothing_here: Estas nenio ĉi tie!
+    people_followed_by: Sekvatoj de %{name}
+    people_who_follow: Sekvantoj de %{name}
+    posts: Mesaĝoj
+    remote_follow: Fore sekvi
+    unfollow: Malsekvi
+  application_mailer:
+    settings: 'Ŝanĝi la retpoŝt-mesaĝajn preferojn: %{link}'
+    signature: Sciigoj de Mastodon el %{instance}
+    view: 'Vidi:'
+  applications:
+    invalid_url: La URL donita ne estas valida
+  auth:
+    change_password: Ŝanĝi pasvorton
+    didnt_get_confirmation: Ĉu vi ne ricevis la instrukciojn por konfirmi?
+    forgot_password: Pasvorto forgesita?
+    login: Ensaluti
+    logout: Elsaluti
+    register: Membriĝi
+    resend_confirmation: Resendi la instrukciojn por konfirmi
+    reset_password: Ŝanĝi la pasvorton
+    set_new_password: Elekti novan pasvorton
+  authorize_follow:
+    error: BedaÅ­rinde, okazis eraro provante konsulti la foran konton
+    follow: Sekvi
+    prompt_html: 'Vi (<strong>%{self}</strong>) petis sekvi:'
+    title: Sekvi %{acct}
+  datetime:
+    distance_in_words:
+      about_x_hours: "%{count}h"
+      about_x_months: "%{count}mo"
+      about_x_years: "%{count}j"
+      almost_x_years: "%{count}j"
+      half_a_minute: Ä´us
+      less_than_x_minutes: "%{count}m"
+      less_than_x_seconds: Ä´us
+      over_x_years: "%{count}j"
+      x_days: "%{count}t"
+      x_minutes: "%{count}m"
+      x_months: "%{count}mo"
+      x_seconds: "%{count}s"
+  exports:
+    blocks: Vi blokas
+    csv: CSV
+    follows: Vi sekvas
+    storage: Mediaĵa konservado
+  generic:
+    changes_saved_msg: Ŝanĝoj senprobleme konservitaj!
+    powered_by: povigita de %{link}
+    save_changes: Konservi la ŝanĝojn
+    validation_errors:
+      one: Io ne okazis senprobleme! Bonvolu konsulti la suban erar-raporton.
+      other: Io ne okazis senprobleme! Bonvolu konsulti la subajn %{count} erar-raportojn.
+  imports:
+    preface: Vi povas alporti kelkajn datumojn, kiel listojn de ĉiuj homoj kiujn vi sekvas aŭ blokas, al via konto de ĉi tiu aperaĵo, per dosiero elportita de alia aperaĵo.
+    success: Viaj datumoj estis senprobleme alportitaj kaj estos traktitaj kiel planite.
+    types:
+      blocking: Listo de blokitoj
+      following: Listo de sekvatoj
+    upload: Alporti
+  landing_strip_html: <strong>%{name}</strong> estas uzanto en <strong>%{domain}</strong>. Vi povas sekvi tiun aŭ interagi kun tiu, se vi havas konton ie ajn en la Fediverse. Se vi ne havas, vi povas <a href="%{sign_up_path}">membriĝi ĉi tie.</a>.
+  notification_mailer:
+    digest:
+      body: 'Jen eta resumo de tio, kio okazis en %{instance}, ekde kiam vi laste vizitis en %{since}:'
+      mention: "%{name} menciis vin en:"
+      new_followers_summary:
+        one: Vi ekhavis novan sekvanton! Jej!
+        other: Vi ekhavis %{count} novajn sekvantojn! Mirinde!
+      subject:
+        one: "1 nova sciigo ekde via lasta vizito \U0001F418"
+        other: "%{count} novaj sciigoj ekde via lasta vizito \U0001F418"
+    favourite:
+      body: '%{name} favoris vian mesaĝon:'
+      subject: "%{name} favoris vian mesaĝon"
+    follow:
+      body: "%{name} eksekvis vin:"
+      subject: "%{name} eksekvis vin"
+    follow_request:
+      body: "%{name} petis sekvi vin:"
+      subject: '%{name} petis sekvi vin'
+    mention:
+      body: '%{name} menciis vin en:'
+      subject: '%{name} menciis vin'
+    reblog:
+      body: '%{name} diskonigis vian mesaĝon:'
+      subject: "%{name} diskonigis vian mesaĝon"
+  pagination:
+    next: Sekva
+    prev: Malsekva
+  remote_follow:
+    acct: Enmetu vian uzantnomo@aperaĵo de kie vi volas sekvi tiun uzanton
+    missing_resource: La URL de plusendado ne povis esti trovita
+    proceed: DaÅ­rigi por plusendi
+    prompt: 'Vi eksekvos:'
+  settings:
+    authorized_apps: Rajtigitaj aplikaĵoj
+    back: Reveni al Mastodon
+    edit_profile: Redakti la profilon
+    export: Elporti datumojn
+    import: Alporti
+    preferences: Preferoj
+    settings: Agordoj
+    two_factor_auth: Dufaktora aÅ­tentigo
+  statuses:
+    open_in_web: Malfermi retumile
+    over_character_limit: limo de %{max} signoj trapasita
+    show_more: Montri pli
+    visibilities:
+      private: Montri nur al sekvantoj
+      public: Publika
+      unlisted: Publika, sed ne aperos en publikaj tempolinioj
+  stream_entries:
+    click_to_show: Alklaki por montri
+    reblogged: diskonigita
+    sensitive_content: Tikla enhavo
+  time:
+    formats:
+      default: "%b %d, %Y, %H:%M"
+  two_factor_auth:
+    description_html: Se vi ebligas <strong>dufaktoran aŭtentigon</strong>, vi bezonos vian poŝtelefonon por ensaluti, ĉar ĝi kreos nombrojn, kiujn vi devos entajpi.
+    disable: Malebligi
+    enable: Ebligi
+    instructions_html: "<strong>Skanu tiun QR-kodon per Google Authenticator aŭ per simila aplikaĵo de via poŝtelefono</strong>. De tiam, la aplikaĵo kreos nombrojn, kiujn vi devos entajpi."
+    plaintext_secret_html: 'Rekte legebla sekreta kodo: <samp>%{secret}</samp>'
+    warning: Se vi ne povas agordi aŭtentigan aplikaĵon nun, elektu "malebligi" aŭ vi ne plu povos ensaluti.
+  users:
+    invalid_email: La retpoŝt-adreso ne estas valida
+    invalid_otp_token: La dufaktora aÅ­tentigila kodo ne estas valida
+  will_paginate:
+    page_gap: "&hellip;"
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index cdb2b9886ad05d72810f323dd8d2f0ceaa42061c..947d3f6461d4f49f1f1a52a4588b92417313c1b7 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/locales/simple_form.eo.yml b/config/locales/simple_form.eo.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8c89a56e7ea848f29f98c800d227d81fc93cb0eb
--- /dev/null
+++ b/config/locales/simple_form.eo.yml
@@ -0,0 +1,46 @@
+---
+eo:
+  simple_form:
+    hints:
+      defaults:
+        avatar: En la formato PNG, GIF aÅ­ JPG. Äœis 2Mo. Estos malgrandigita al 120x120px
+        display_name: 30 signoj pleje
+        header: En la formato PNG, GIF aÅ­ JPG. Äœis 2Mo. Estos malgrandigita al 700x335px
+        locked: Vi devos aprobi ĉiun peton de sekvado, kaj viaj mesaĝoj estos senŝanĝe nur por viaj sekvantoj.
+        note: 160 signoj pleje
+      imports:
+        data: Dosiero CSV el alia aperaĵo de Mastodon
+    labels:
+      defaults:
+        avatar: Profilbildo
+        confirm_new_password: Konfirmi novan pasvorton
+        confirm_password: Konfirmi la pasvorton
+        current_password: Nuna pasvorto
+        data: Datumoj
+        display_name: Publika nomo
+        email: Retpoŝt-adreso
+        header: Kapbildo
+        locale: Lingvo
+        locked: Privatigi la konton
+        new_password: Nova pasvorto
+        note: Sinprezento
+        otp_attempt: Dufaktora identigilo
+        password: Pasvorto
+        setting_default_privacy: Videbleco de la mesaĝoj
+        type: Tipo de alportado
+        username: Uzantnomo
+      interactions:
+        must_be_follower: Kaŝi la sciigojn de homoj, kiuj ne sekvas vin
+        must_be_following: Kaŝi la sciigojn de homoj, kiujn vi ne sekas
+      notification_emails:
+        digest: Sendi resumajn retpoŝt-mesaĝojn
+        favourite: Sendi retpoŝt-mesaĝon, kiam iu favoras mesaĝon de vi
+        follow: Sendi retpoŝt-mesaĝon, kiam iu eksekvas vin
+        follow_request: Sendi retpoŝt-mesaĝon, kiam iu petas sekvi vin
+        mention: Sendi retpoŝt-mesaĝon, kiam iu mencias vin
+        reblog: Sendi retpoŝt-mesaĝon, kiam iu diskonigas mesaĝon de vi
+    'no': 'Ne'
+    required:
+      mark: "*"
+      text: bezonata
+    'yes': 'Jes'
diff --git a/config/puma.rb b/config/puma.rb
index 550129bdc604595cd5e9ac43682c53df1bcaf936..191f00ccaae9971bc5ba43670ad6d479999e845d 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 ca77191f7c8fa7655d227935d0f15d6b8a41f8ba..315ad5da57fec334368b61823c62ff2fffe90f93 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 0000000000000000000000000000000000000000..00e41bf3aba064a6818baa267361dec130cc4245
--- /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 b5d55fa16e9e04e8beb2d54d1f3fe59f8966e3d5..fe9b8dd4ffb002aec4ff09770ba6a2aeee094fae 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 dd69eb303041fcd39eca4479b6b1beef33034c0d..09b0f1df12029509c77fc3ad1069e53e01e31597 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 0de26230c74beb8204dcc3834bc9b9f31e48b082..269bc6331490fd930317a129f1fe327e92ae34e5 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 90e9c0dea14158866ef5355c6b6f3b0ca3f1b02f..af21af546057b54db4dc99a9f921d9cbd5da8fb0 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/Using-Mastodon/Apps.md b/docs/Using-Mastodon/Apps.md
index 67b14dc263a21cd4e2e0fe955a50d284bcf118a3..b5e1fa36ba220a9348195a4f1d5960a6bf878f5c 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 daedcbdd8a7514a1670ae7d23f718a2bf5265e7f..3b03a8ee416e57f9e65576048f593ae867f4e02e 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 aa89e90dde94bbe4f7d9589b0d943be40234a5a6..2386f574c4c19ea90b25beb422d152a9ce87d651 100644
--- a/docs/Using-Mastodon/List-of-Mastodon-instances.md
+++ b/docs/Using-Mastodon/List-of-Mastodon-instances.md
@@ -7,6 +7,7 @@ There is also a list at [instances.mastodon.xyz](https://instances.mastodon.xyz)
 | -------------|-------------|---|---|
 | [mastodon.social](https://mastodon.social) |Flagship, quick updates|No|No|
 | [securitymastod.one](https://securitymastod.one/) |Information security enthusiasts and pros|Yes|Yes|
+| [mastodon.nuzgo.net](https://mastodon.nuzgo.net/) |Mastodon instance hosted in Paris |Yes|No|
 | [mastodon.cx](https://mastodon.cx/) |Alternative Mastodon instance hosted in France|Yes|Yes|
 | [mastodon.network](https://mastodon.network) |N/A|Yes|Yes|
 | [awoo.space](https://awoo.space) |Intentionally moderated, only federates with mastodon.social|Yes|No|
@@ -21,6 +22,7 @@ There is also a list at [instances.mastodon.xyz](https://instances.mastodon.xyz)
 | [social.diskseven.com](https://social.diskseven.com) |Single user|No|Yes|
 | [social.gestaltzerfall.net](https://social.gestaltzerfall.net) |Single user|No|No|
 | [mastodon.xyz](https://mastodon.xyz) |N/A|Yes|Yes|
+| [mastodon.land](https://mastodon.land) |N/A|Yes|Yes|
 | [mastodon.partipirate.org](https://mastodon.partipirate.org) |French Pirate Party Instance - Politics and stuff|Yes|No|
 | [social.targaryen.house](https://social.targaryen.house) |Federates everywhere, quick updates.|Yes|Yes|
 | [masto.themimitoof.fr](https://masto.themimitoof.fr) |N/A|Yes|Yes|
@@ -35,7 +37,7 @@ There is also a list at [instances.mastodon.xyz](https://instances.mastodon.xyz)
 | [oc.todon.fr](https://oc.todon.fr) |Modérée et principalement francophone, pas de tolérances pour misogynie/LGBTphobies/validisme/etc.|Yes|Yes|
 | [maly.io](https://maly.io) |N/A|Yes|No|
 | [social.lou.lt](https://social.lou.lt) |N/A|Yes|No|
-| [mastodon.ninetailed.uk](https://mastodon.ninetailed.uk) |N/A|Yes|No|
+| [mastodon.ninetailed.uk](https://mastodon.ninetailed.uk) |Open registrations, furry-friendly, UK-based|Yes|No|
 | [soc.louiz.org](https://soc.louiz.org) |"Coucou"|Yes|No|
 | [7nw.eu](https://7nw.eu) |N/A|Yes|No|
 | [mastodon.gougere.fr](https://mastodon.gougere.fr)|N/A|Yes|No|
@@ -48,6 +50,29 @@ 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|
+| [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
+| [design.vu](https://design.vu)|— what's your design view‽|Yes|No|
+| [masto.raildecake.fr](https://masto.raildecake.fr)|Hebergé chez un FAI associatif dans le sud de la france, grillons & pins en options|Yes|No|
+| [good-dragon.com](https://good-dragon.com/)|Quick updates, Relaxed Moderation, Federates Everywhere, Furries|Yes|No|
+| [rich.gop](https://rich.gop/)|Federates everywhere, Open registration, Privacy respected|Yes|Yes|
+| [social.nowa.re](https://social.nowa.re)|Open Registration|Yes|No|
+| [mastodon.ml](http://mastodon.ml) |A chill place to hangout and chat about anime, programming and movies.|Yes|Yes|
+| [off-the-clock.us](https://off-the-clock.us/)|The work day is over.|Yes|No|
+| [infinimatix.net](https://infinimatix.net)|Informatics|Yes|Yes|
+| [social.0day.agency](https://social.0day.agency)|Infosec, Hacking, Fun (only protonmail)|Yes|Yes|
+| [kagrumez.lerk.io](https://kagrumez.lerk.io)|Open registration. German end english.|Yes|No|
+| [meow.social](https://meow.social)|A furry fandom focused instance|Yes|No|
+| [neumastodon.com](https://neumastodon.com/)|Northeastern University Mastodon |Yes|No|
+| [dancingbanana.party](https://dancingbanana.party)|La banane qui danse.|Yes|No|
+| [mastodon.brussels.fr](https://mastodon.brussels/)|Le mastodon pour les belges, si vous aimez la bonne ambiance venez nous rejoindre !|Yes|Yes|
+| [mastodon.llamasweet.tech](https://mastodon.llamasweet.tech/)|Mastodon about Android developement|Yes|No|
+| [manx.social](https://manx.social/)|Instance for the Isle of Man|Yes|Yes|
+| [mastodon.host](https://mastodon.host/)|Lightly moderated, federates everywhere and has a follow bot ( Huge federated timeline )|Yes|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/spec/fabricators/media_attachment_fabricator.rb b/spec/fabricators/media_attachment_fabricator.rb
index 59db2440d6ae5584912450b6b4126b9bc92113f2..dc91d708f3347d946688947506520ad6844415ed 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 df222fc9d2cf6cafd41a47b422ef3b49f3388c27..8ec5f4ba7989c060eb995e47084f0cba6302cc45 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 d7f59adb821d18ce5a66bde352fffb03b0639806..93a45459d947dbdca5ff7a0f341a36ced2391951 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 b9d0795213e3f65904bc93e5b023643786c0fa28..000bee0f50b3f8eb1b11e56ffa7f41113650e1e5 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 9ee4daf6f5f2e2fde4e60ef5b9cf79550b8ccfca..0e39cd969a8d0c0e9b6619f36e80c3a9d885c321 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