about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.env.production.sample13
-rw-r--r--.travis.yml2
-rw-r--r--Dockerfile (renamed from Dockerfile.app)0
-rw-r--r--Dockerfile.neo4j17
-rw-r--r--Gemfile8
-rw-r--r--Gemfile.lock97
-rw-r--r--README.md8
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx8
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx12
-rw-r--r--app/assets/javascripts/components/actions/suggestions.jsx37
-rw-r--r--app/assets/javascripts/components/components/button.jsx19
-rw-r--r--app/assets/javascripts/components/components/lightbox.jsx4
-rw-r--r--app/assets/javascripts/components/components/media_gallery.jsx162
-rw-r--r--app/assets/javascripts/components/components/status.jsx7
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx9
-rw-r--r--app/assets/javascripts/components/components/video_player.jsx42
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx9
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx17
-rw-r--r--app/assets/javascripts/components/features/account/components/action_bar.jsx2
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx27
-rw-r--r--app/assets/javascripts/components/features/account/index.jsx5
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx19
-rw-r--r--app/assets/javascripts/components/features/compose/components/suggestions_box.jsx86
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx15
-rw-r--r--app/assets/javascripts/components/features/status/components/detailed_status.jsx4
-rw-r--r--app/assets/javascripts/components/locales/de.jsx2
-rw-r--r--app/assets/javascripts/components/locales/en.jsx13
-rw-r--r--app/assets/javascripts/components/locales/es.jsx2
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx31
-rw-r--r--app/assets/javascripts/components/locales/hu.jsx55
-rw-r--r--app/assets/javascripts/components/locales/index.jsx8
-rw-r--r--app/assets/javascripts/components/locales/pt.jsx2
-rw-r--r--app/assets/javascripts/components/middleware/errors.jsx2
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx2
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx6
-rw-r--r--app/assets/javascripts/components/reducers/notifications.jsx7
-rw-r--r--app/assets/javascripts/components/reducers/statuses.jsx29
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx20
-rw-r--r--app/assets/javascripts/components/reducers/user_lists.jsx4
-rw-r--r--app/assets/stylesheets/application.scss1
-rw-r--r--app/assets/stylesheets/components.scss106
-rw-r--r--app/assets/stylesheets/forms.scss13
-rw-r--r--app/assets/stylesheets/tables.scss25
-rw-r--r--app/controllers/admin/pubsubhubbub_controller.rb11
-rw-r--r--app/controllers/api/push_controller.rb37
-rw-r--r--app/controllers/api/v1/accounts_controller.rb25
-rw-r--r--app/controllers/api/v1/media_controller.rb3
-rw-r--r--app/controllers/api/v1/notifications_controller.rb3
-rw-r--r--app/controllers/api/v1/statuses_controller.rb27
-rw-r--r--app/controllers/api/v1/timelines_controller.rb18
-rw-r--r--app/controllers/api_controller.rb4
-rw-r--r--app/controllers/application_controller.rb25
-rw-r--r--app/controllers/settings/preferences_controller.rb7
-rw-r--r--app/controllers/settings/profiles_controller.rb4
-rw-r--r--app/helpers/admin/pubsubhubbub_helper.rb2
-rw-r--r--app/helpers/atom_builder_helper.rb6
-rw-r--r--app/helpers/settings_helper.rb2
-rw-r--r--app/lib/feed_manager.rb26
-rw-r--r--app/models/account.rb27
-rw-r--r--app/models/concerns/obfuscate_filename.rb16
-rw-r--r--app/models/feed.rb4
-rw-r--r--app/models/follow.rb28
-rw-r--r--app/models/follow_suggestion.rb50
-rw-r--r--app/models/media_attachment.rb2
-rw-r--r--app/models/status.rb34
-rw-r--r--app/models/subscription.rb29
-rw-r--r--app/models/user.rb3
-rw-r--r--app/services/block_service.rb18
-rw-r--r--app/services/fan_out_on_write_service.rb7
-rw-r--r--app/services/favourite_service.rb2
-rw-r--r--app/services/follow_remote_account_service.rb3
-rw-r--r--app/services/follow_service.rb4
-rw-r--r--app/services/notify_service.rb2
-rw-r--r--app/services/post_status_service.rb13
-rw-r--r--app/services/process_feed_service.rb2
-rw-r--r--app/services/process_hashtags_service.rb2
-rw-r--r--app/services/process_interaction_service.rb4
-rw-r--r--app/services/pubsubhubbub/subscribe_service.rb13
-rw-r--r--app/services/pubsubhubbub/unsubscribe_service.rb15
-rw-r--r--app/services/reblog_service.rb2
-rw-r--r--app/services/remove_status_service.rb5
-rw-r--r--app/services/search_service.rb6
-rw-r--r--app/services/unfollow_service.rb3
-rw-r--r--app/services/update_remote_profile_service.rb26
-rw-r--r--app/views/accounts/show.atom.ruby3
-rw-r--r--app/views/admin/pubsubhubbub/index.html.haml20
-rw-r--r--app/views/api/v1/statuses/_show.rabl2
-rw-r--r--app/views/settings/preferences/show.html.haml4
-rw-r--r--app/views/stream_entries/show.html.haml3
-rw-r--r--app/workers/processing_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/confirmation_worker.rb36
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb30
-rw-r--r--app/workers/pubsubhubbub/distribution_worker.rb18
-rw-r--r--app/workers/removal_worker.rb9
-rw-r--r--app/workers/salmon_worker.rb2
-rw-r--r--app/workers/thread_resolve_worker.rb8
-rw-r--r--config/application.rb2
-rw-r--r--config/environments/production.rb7
-rw-r--r--config/i18n-tasks.yml3
-rw-r--r--config/initializers/neography.rb5
-rw-r--r--config/initializers/ostatus.rb16
-rw-r--r--config/initializers/paperclip.rb4
-rw-r--r--config/initializers/rack-attack.rb4
-rw-r--r--config/initializers/timeout.rb1
-rw-r--r--config/locales/devise.hu.yml61
-rw-r--r--config/locales/doorkeeper.fr.yml12
-rw-r--r--config/locales/doorkeeper.hu.yml112
-rw-r--r--config/locales/fr.yml32
-rw-r--r--config/locales/hu.yml59
-rw-r--r--config/locales/simple_form.de.yml3
-rw-r--r--config/locales/simple_form.en.yml3
-rw-r--r--config/locales/simple_form.fr.yml11
-rw-r--r--config/locales/simple_form.hu.yml28
-rw-r--r--config/routes.rb17
-rw-r--r--db/migrate/20161123093447_add_sensitive_to_statuses.rb5
-rw-r--r--db/migrate/20161128103007_create_subscriptions.rb15
-rw-r--r--db/schema.rb35
-rw-r--r--docker-compose.yml17
-rw-r--r--lib/tasks/mastodon.rake15
-rw-r--r--package.json25
-rw-r--r--spec/controllers/admin/pubsubhubbub_controller_spec.rb15
-rw-r--r--spec/controllers/api/push_controller_spec.rb13
-rw-r--r--spec/controllers/api/salmon_controller_spec.rb1
-rw-r--r--spec/controllers/api/subscriptions_controller_spec.rb3
-rw-r--r--spec/controllers/api/v1/accounts_controller_spec.rb14
-rw-r--r--spec/fabricators/subscription_fabricator.rb6
-rw-r--r--spec/helpers/admin/pubsubhubbub_helper_spec.rb15
-rw-r--r--spec/models/subscription_spec.rb5
-rw-r--r--spec/services/process_feed_service_spec.rb1
-rw-r--r--spec/services/update_remote_profile_service_spec.rb8
-rw-r--r--yarn.lock11
133 files changed, 1550 insertions, 664 deletions
diff --git a/.env.production.sample b/.env.production.sample
index b68ba523c..52d519570 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -6,14 +6,13 @@ DB_USER=postgres
 DB_NAME=postgres
 DB_PASS=
 DB_PORT=5432
-NEO4J_HOST=neo4j
-NEO4J_PORT=7474
 
 # Federation
 LOCAL_DOMAIN=example.com
 LOCAL_HTTPS=true
 
 # Application secrets
+# Generate each with the `rake secret` task
 PAPERCLIP_SECRET=
 SECRET_KEY_BASE=
 
@@ -23,3 +22,13 @@ SMTP_PORT=587
 SMTP_LOGIN=
 SMTP_PASSWORD=
 SMTP_FROM_ADDRESS=notifications@example.com
+
+# Optional asset host for multi-server setups
+# CDN_HOST=assets.example.com
+
+# S3 (optional)
+S3_ENABLED=false
+S3_BUCKET=
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+S3_REGION=
diff --git a/.travis.yml b/.travis.yml
index f6841779d..fe4549edd 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -11,8 +11,6 @@ env:
     - LOCAL_DOMAIN=cb6e6126.ngrok.io
     - LOCAL_HTTPS=true
     - RAILS_ENV=test
-    - NEO4J_HOST=localhost
-    - NEO4J_PORT=7575
 
 addons:
   postgresql: 9.4
diff --git a/Dockerfile.app b/Dockerfile
index dfc0ad5b7..dfc0ad5b7 100644
--- a/Dockerfile.app
+++ b/Dockerfile
diff --git a/Dockerfile.neo4j b/Dockerfile.neo4j
deleted file mode 100644
index 373c2abb0..000000000
--- a/Dockerfile.neo4j
+++ /dev/null
@@ -1,17 +0,0 @@
-FROM neo4j:latest
-
-ENV NEO4J_AUTH=none
-
-RUN cd /var/lib/neo4j/plugins \
-  && wget http://products.graphaware.com/download/framework-server-community/graphaware-server-community-all-3.0.6.43.jar \
-  && wget http://products.graphaware.com/download/noderank/graphaware-noderank-3.0.6.43.3.jar
-RUN echo "dbms.unmanaged_extension_classes=com.graphaware.server=/graphaware" >> /var/lib/neo4j/conf/neo4j.conf
-RUN echo 'com.graphaware.runtime.enabled=true\n\
-com.graphaware.module.NR.1=com.graphaware.module.noderank.NodeRankModuleBootstrapper\n\
-com.graphaware.module.NR.maxTopRankNodes=10\n\
-com.graphaware.module.NR.dampingFactor=0.85\n\
-com.graphaware.module.NR.propertyKey=nodeRank\n'\
-  >> /var/lib/neo4j/conf/neo4j.conf
-RUN echo 'com.graphaware.runtime.stats.disabled=true\n\
-com.graphaware.server.stats.disabled=true\n'\
-  >> /var/lib/neo4j/conf/neo4j.conf
diff --git a/Gemfile b/Gemfile
index 9654c8828..95fd04629 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,7 +2,7 @@
 
 source 'https://rubygems.org'
 
-gem 'rails', '5.0.0.1'
+gem 'rails', git: 'https://github.com/rails/rails.git', branch: '5-0-stable'
 gem 'sass-rails', '~> 5.0'
 gem 'uglifier', '>= 1.3.0'
 gem 'coffee-rails', '~> 4.1.0'
@@ -17,9 +17,9 @@ gem 'pghero'
 gem 'dotenv-rails'
 gem 'font-awesome-rails'
 
-gem 'paperclip', '~> 4.3'
+gem 'paperclip', '~> 5.0'
 gem 'paperclip-av-transcoder'
-gem 'aws-sdk', '< 2.0'
+gem 'aws-sdk', '>= 2.0'
 
 gem 'http'
 gem 'httplog'
@@ -41,9 +41,9 @@ gem 'simple_form'
 gem 'will_paginate'
 gem 'rack-attack'
 gem 'rack-cors', require: 'rack/cors'
+gem 'rack-timeout-puma'
 gem 'sidekiq'
 gem 'ledermann-rails-settings'
-gem 'neography'
 gem 'pg_search'
 
 gem 'react-rails'
diff --git a/Gemfile.lock b/Gemfile.lock
index a31573af6..aa9f59da8 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,5 +1,7 @@
-GEM
-  remote: https://rubygems.org/
+GIT
+  remote: https://github.com/rails/rails.git
+  revision: ecb394a31420f6fd1d0ab692c79f2dd44176e2c9
+  branch: 5-0-stable
   specs:
     actioncable (5.0.0.1)
       actionpack (= 5.0.0.1)
@@ -24,7 +26,6 @@ GEM
       erubis (~> 2.7.0)
       rails-dom-testing (~> 2.0)
       rails-html-sanitizer (~> 1.0, >= 1.0.2)
-    active_record_query_trace (1.5.3)
     activejob (5.0.0.1)
       activesupport (= 5.0.0.1)
       globalid (>= 0.3.6)
@@ -39,18 +40,44 @@ GEM
       i18n (~> 0.7)
       minitest (~> 5.1)
       tzinfo (~> 1.1)
+    rails (5.0.0.1)
+      actioncable (= 5.0.0.1)
+      actionmailer (= 5.0.0.1)
+      actionpack (= 5.0.0.1)
+      actionview (= 5.0.0.1)
+      activejob (= 5.0.0.1)
+      activemodel (= 5.0.0.1)
+      activerecord (= 5.0.0.1)
+      activesupport (= 5.0.0.1)
+      bundler (>= 1.3.0, < 2.0)
+      railties (= 5.0.0.1)
+      sprockets-rails (>= 2.0.0)
+    railties (5.0.0.1)
+      actionpack (= 5.0.0.1)
+      activesupport (= 5.0.0.1)
+      method_source
+      rake (>= 0.8.7)
+      thor (>= 0.18.1, < 2.0)
+
+GEM
+  remote: https://rubygems.org/
+  specs:
+    active_record_query_trace (1.5.3)
     addressable (2.4.0)
-    arel (7.1.1)
+    arel (7.1.4)
     ast (2.3.0)
     autoprefixer-rails (6.5.0.2)
       execjs
     av (0.9.0)
       cocaine (~> 0.5.3)
-    aws-sdk (1.66.0)
-      aws-sdk-v1 (= 1.66.0)
-    aws-sdk-v1 (1.66.0)
-      json (~> 1.4)
-      nokogiri (>= 1.4.4)
+    aws-sdk (2.6.28)
+      aws-sdk-resources (= 2.6.28)
+    aws-sdk-core (2.6.28)
+      aws-sigv4 (~> 1.0)
+      jmespath (~> 1.0)
+    aws-sdk-resources (2.6.28)
+      aws-sdk-core (= 2.6.28)
+    aws-sigv4 (1.0.0)
     babel-source (5.8.35)
     babel-transpiler (0.7.0)
       babel-source (>= 4.0, < 6)
@@ -108,7 +135,6 @@ GEM
       thread
       thread_safe
     erubis (2.7.0)
-    excon (0.53.0)
     execjs (2.7.0)
     fabrication (2.15.2)
     fast_blank (1.0.0)
@@ -161,6 +187,7 @@ GEM
     jbuilder (2.6.0)
       activesupport (>= 3.0.0, < 5.1)
       multi_json (~> 1.2)
+    jmespath (1.3.1)
     jquery-rails (4.1.1)
       rails-dom-testing (>= 1, < 3)
       railties (>= 4.2.0)
@@ -185,33 +212,25 @@ GEM
     mime-types (3.1)
       mime-types-data (~> 3.2015)
     mime-types-data (3.2016.0521)
-    mimemagic (0.3.0)
+    mimemagic (0.3.2)
     mini_portile2 (2.1.0)
     minitest (5.9.1)
     multi_json (1.12.1)
-    neography (1.8.0)
-      excon (>= 0.33.0)
-      json (>= 1.7.7)
-      multi_json (>= 1.3.2)
-      os (>= 0.9.6)
-      rake (>= 0.8.7)
-      rubyzip (>= 1.0.0)
     nio4r (1.2.1)
     nokogiri (1.6.8.1)
       mini_portile2 (~> 2.1.0)
     oj (2.17.3)
     orm_adapter (0.5.0)
-    os (0.9.6)
     ostatus2 (1.0.2)
       addressable (~> 2.4)
       http (~> 2.0)
       nokogiri (~> 1.6)
-    paperclip (4.3.7)
-      activemodel (>= 3.2.0)
-      activesupport (>= 3.2.0)
+    paperclip (5.1.0)
+      activemodel (>= 4.2.0)
+      activesupport (>= 4.2.0)
       cocaine (~> 0.5.5)
       mime-types
-      mimemagic (= 0.3.0)
+      mimemagic (~> 0.3.0)
     paperclip-av-transcoder (0.6.4)
       av (~> 0.9.0)
       paperclip (>= 2.5.2)
@@ -242,18 +261,9 @@ GEM
       rack
     rack-test (0.6.3)
       rack (>= 1.0)
-    rails (5.0.0.1)
-      actioncable (= 5.0.0.1)
-      actionmailer (= 5.0.0.1)
-      actionpack (= 5.0.0.1)
-      actionview (= 5.0.0.1)
-      activejob (= 5.0.0.1)
-      activemodel (= 5.0.0.1)
-      activerecord (= 5.0.0.1)
-      activesupport (= 5.0.0.1)
-      bundler (>= 1.3.0, < 2.0)
-      railties (= 5.0.0.1)
-      sprockets-rails (>= 2.0.0)
+    rack-timeout (0.4.2)
+    rack-timeout-puma (0.0.1)
+      rack-timeout (~> 0.2, >= 0.2.0)
     rails-dom-testing (2.0.1)
       activesupport (>= 4.2.0, < 6.0)
       nokogiri (~> 1.6.0)
@@ -266,12 +276,6 @@ GEM
       rails (> 3.1)
     rails_serve_static_assets (0.0.5)
     rails_stdout_logging (0.0.5)
-    railties (5.0.0.1)
-      actionpack (= 5.0.0.1)
-      activesupport (= 5.0.0.1)
-      method_source
-      rake (>= 0.8.7)
-      thor (>= 0.18.1, < 2.0)
     rainbow (2.1.0)
     rake (11.3.0)
     rdoc (4.2.2)
@@ -333,7 +337,6 @@ GEM
       ruby-progressbar (~> 1.7)
       unicode-display_width (~> 1.0, >= 1.0.1)
     ruby-progressbar (1.8.1)
-    rubyzip (1.2.0)
     safe_yaml (1.0.4)
     sass (3.4.22)
     sass-rails (5.0.6)
@@ -362,7 +365,7 @@ GEM
     sprockets (3.7.0)
       concurrent-ruby (~> 1.0)
       rack (> 1, < 3)
-    sprockets-rails (3.1.1)
+    sprockets-rails (3.2.0)
       actionpack (>= 4.0)
       activesupport (>= 4.0)
       sprockets (>= 3.0.0)
@@ -403,7 +406,7 @@ DEPENDENCIES
   active_record_query_trace
   addressable
   autoprefixer-rails
-  aws-sdk (< 2.0)
+  aws-sdk (>= 2.0)
   better_errors
   binding_of_caller
   browserify-rails
@@ -429,11 +432,10 @@ DEPENDENCIES
   letter_opener
   link_header
   lograge
-  neography
   nokogiri
   oj
   ostatus2
-  paperclip (~> 4.3)
+  paperclip (~> 5.0)
   paperclip-av-transcoder
   pg
   pg_search
@@ -443,7 +445,8 @@ DEPENDENCIES
   rabl
   rack-attack
   rack-cors
-  rails (= 5.0.0.1)
+  rack-timeout-puma
+  rails!
   rails_12factor
   rails_autolink
   react-rails
diff --git a/README.md b/README.md
index 25d179d86..00472a616 100644
--- a/README.md
+++ b/README.md
@@ -60,8 +60,6 @@ Consult the example configuration file, `.env.production.sample` for the full li
 
 - PostgreSQL
 - Redis
-- Neo4J (optional)
-  - GraphAware NodeRank
 
 ## Running with Docker and Docker-Compose
 
@@ -90,8 +88,8 @@ The container has two volumes, for the assets and for user uploads. The default
 - `rake mastodon:media:clear` removes uploads that have not been attached to any status after a while, you would want to run this from a periodic cronjob
 - `rake mastodon:push:clear` unsubscribes from PuSH notifications for remote users that have no local followers. You may not want to actually do that, to keep a fuller footprint of the fediverse or in case your users will soon re-follow
 - `rake mastodon:push:refresh` re-subscribes PuSH for expiring remote users, this should be run periodically from a cronjob and quite often as the expiration time depends on the particular hub of the remote user
-- `rake mastodon:feeds:clear` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
-- `rake mastodon:graphs:sync` re-imports all follow relationships into Neo4J. Only for troubleshooting
+- `rake mastodon:feeds:clear_all` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
+- `rake mastodon:feeds:clear` removes timelines of users who haven't signed in lately, which allows to save RAM and improve message distribution. This is required to be run periodically so that when they login again the regeneration process will trigger
 
 Running any of these tasks via docker-compose would look like this:
 
@@ -117,6 +115,8 @@ Which will re-create the updated containers, leaving databases and data as is. D
 
 You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. This section may be updated with more details in the future.
 
+**IRC channel**: #mastodon on irc.freenode.net
+
 ### Extra credits
 
 - The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis
diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 4a0777a64..759435afe 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -246,7 +246,8 @@ export function blockAccount(id) {
     dispatch(blockAccountRequest(id));
 
     api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
-      dispatch(blockAccountSuccess(response.data));
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
     }).catch(error => {
       dispatch(blockAccountFail(id, error));
     });
@@ -272,10 +273,11 @@ export function blockAccountRequest(id) {
   };
 };
 
-export function blockAccountSuccess(relationship) {
+export function blockAccountSuccess(relationship, statuses) {
   return {
     type: ACCOUNT_BLOCK_SUCCESS,
-    relationship
+    relationship,
+    statuses
   };
 };
 
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index af3cdbf30..b97cb7b12 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -22,6 +22,8 @@ export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
 export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
 export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
 
+export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+
 export function changeCompose(text) {
   return {
     type: COMPOSE_CHANGE,
@@ -62,7 +64,8 @@ export function submitCompose() {
     api(getState).post('/api/v1/statuses', {
       status: getState().getIn(['compose', 'text'], ''),
       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
-      media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
+      media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+      sensitive: getState().getIn(['compose', 'sensitive'])
     }).then(function (response) {
       dispatch(submitComposeSuccess(response.data));
       dispatch(updateTimeline('home', response.data));
@@ -197,3 +200,10 @@ export function unmountCompose() {
     type: COMPOSE_UNMOUNT
   };
 };
+
+export function changeComposeSensitivity(checked) {
+  return {
+    type: COMPOSE_SENSITIVITY_CHANGE,
+    checked
+  };
+};
diff --git a/app/assets/javascripts/components/actions/suggestions.jsx b/app/assets/javascripts/components/actions/suggestions.jsx
deleted file mode 100644
index 6b3aa69dd..000000000
--- a/app/assets/javascripts/components/actions/suggestions.jsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import api from '../api';
-
-export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
-export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
-export const SUGGESTIONS_FETCH_FAIL    = 'SUGGESTIONS_FETCH_FAIL';
-
-export function fetchSuggestions() {
-  return (dispatch, getState) => {
-    dispatch(fetchSuggestionsRequest());
-
-    api(getState).get('/api/v1/accounts/suggestions').then(response => {
-      dispatch(fetchSuggestionsSuccess(response.data));
-    }).catch(error => {
-      dispatch(fetchSuggestionsFail(error));
-    });
-  };
-};
-
-export function fetchSuggestionsRequest() {
-  return {
-    type: SUGGESTIONS_FETCH_REQUEST
-  };
-};
-
-export function fetchSuggestionsSuccess(accounts) {
-  return {
-    type: SUGGESTIONS_FETCH_SUCCESS,
-    accounts: accounts
-  };
-};
-
-export function fetchSuggestionsFail(error) {
-  return {
-    type: SUGGESTIONS_FETCH_FAIL,
-    error: error
-  };
-};
diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx
index fe36d40c5..d63129013 100644
--- a/app/assets/javascripts/components/components/button.jsx
+++ b/app/assets/javascripts/components/components/button.jsx
@@ -7,7 +7,14 @@ const Button = React.createClass({
     onClick: React.PropTypes.func,
     disabled: React.PropTypes.bool,
     block: React.PropTypes.bool,
-    secondary: React.PropTypes.bool
+    secondary: React.PropTypes.bool,
+    size: React.PropTypes.number,
+  },
+
+  getDefaultProps () {
+    return {
+      size: 36
+    };
   },
 
   mixins: [PureRenderMixin],
@@ -32,16 +39,16 @@ const Button = React.createClass({
       fontWeight: '500',
       letterSpacing: '0',
       textTransform: 'uppercase',
-      padding: '0 16px',
-      height: '36px',
+      padding: `0 ${this.props.size / 2.25}px`,
+      height: `${this.props.size}px`,
       cursor: 'pointer',
-      lineHeight: '36px',
+      lineHeight: `${this.props.size}px`,
       borderRadius: '4px',
       textDecoration: 'none'
     };
-    
+
     return (
-      <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={style}>
+      <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}>
         {this.props.text || this.props.children}
       </button>
     );
diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx
index 537bab954..36f078a3a 100644
--- a/app/assets/javascripts/components/components/lightbox.jsx
+++ b/app/assets/javascripts/components/components/lightbox.jsx
@@ -43,13 +43,15 @@ const Lightbox = React.createClass({
   render () {
     const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
 
+    const content = isVisible ? children : <div />;
+
     return (
       <div className='lightbox' style={{...overlayStyle, display: isVisible ? 'flex' : 'none'}} onClick={onOverlayClicked}>
         <Motion defaultStyle={{ y: -200 }} style={{ y: spring(isVisible ? 0 : -200) }}>
           {({ y }) =>
             <div style={{...dialogStyle, transform: `translateY(${y}px)`}}>
               <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
-              {children}
+              {content}
             </div>
           }
         </Motion>
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index bdb456a08..d04c7c869 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -1,9 +1,47 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import PureRenderMixin    from 'react-addons-pure-render-mixin';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { FormattedMessage } from 'react-intl';
+
+const outerStyle = {
+  marginTop: '8px',
+  overflow: 'hidden',
+  width: '100%',
+  boxSizing: 'border-box'
+};
+
+const spoilerStyle = {
+  background: '#000',
+  color: '#fff',
+  textAlign: 'center',
+  height: '100%',
+  cursor: 'pointer',
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+  flexDirection: 'column'
+};
+
+const spoilerSpanStyle = {
+  display: 'block',
+  fontSize: '14px',
+};
+
+const spoilerSubSpanStyle = {
+  display: 'block',
+  fontSize: '11px',
+  fontWeight: '500'
+};
 
 const MediaGallery = React.createClass({
 
+  getInitialState () {
+    return {
+      visible: false
+    };
+  },
+
   propTypes: {
+    sensitive: React.PropTypes.bool,
     media: ImmutablePropTypes.list.isRequired,
     height: React.PropTypes.number.isRequired,
     onOpenMedia: React.PropTypes.func.isRequired
@@ -20,69 +58,85 @@ const MediaGallery = React.createClass({
     e.stopPropagation();
   },
 
+  handleOpen () {
+    this.setState({ visible: true });
+  },
+
   render () {
-    var children = this.props.media.take(4);
-    var size     = children.size;
-
-    children = children.map((attachment, i) => {
-      let width  = 50;
-      let height = 100;
-      let top    = 'auto';
-      let left   = 'auto';
-      let bottom = 'auto';
-      let right  = 'auto';
-
-      if (size === 1) {
-        width = 100;
-      }
-
-      if (size === 4 || (size === 3 && i > 0)) {
-        height = 50;
-      }
-
-      if (size === 2) {
-        if (i === 0) {
-          right = '2px';
-        } else {
-          left = '2px';
-        }
-      } else if (size === 3) {
-        if (i === 0) {
-          right = '2px';
-        } else if (i > 0) {
-          left = '2px';
-        }
+    const { media, sensitive } = this.props;
 
-        if (i === 1) {
-          bottom = '2px';
-        } else if (i > 1) {
-          top = '2px';
-        }
-      } else if (size === 4) {
-        if (i === 0 || i === 2) {
-          right = '2px';
+    let children;
+
+    if (sensitive && !this.state.visible) {
+      children = (
+        <div style={spoilerStyle} onClick={this.handleOpen}>
+          <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+          <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </div>
+      );
+    } else {
+      const size = media.take(4).size;
+
+      children = media.take(4).map((attachment, i) => {
+        let width  = 50;
+        let height = 100;
+        let top    = 'auto';
+        let left   = 'auto';
+        let bottom = 'auto';
+        let right  = 'auto';
+
+        if (size === 1) {
+          width = 100;
         }
 
-        if (i === 1 || i === 3) {
-          left = '2px';
+        if (size === 4 || (size === 3 && i > 0)) {
+          height = 50;
         }
 
-        if (i < 2) {
-          bottom = '2px';
-        } else {
-          top = '2px';
+        if (size === 2) {
+          if (i === 0) {
+            right = '2px';
+          } else {
+            left = '2px';
+          }
+        } else if (size === 3) {
+          if (i === 0) {
+            right = '2px';
+          } else if (i > 0) {
+            left = '2px';
+          }
+
+          if (i === 1) {
+            bottom = '2px';
+          } else if (i > 1) {
+            top = '2px';
+          }
+        } else if (size === 4) {
+          if (i === 0 || i === 2) {
+            right = '2px';
+          }
+
+          if (i === 1 || i === 3) {
+            left = '2px';
+          }
+
+          if (i < 2) {
+            bottom = '2px';
+          } else {
+            top = '2px';
+          }
         }
-      }
 
-      return (
-        <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
-          <a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
-        </div>
-      );
-    });
+        return (
+          <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
+            <a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
+          </div>
+        );
+      });
+    }
 
     return (
-      <div style={{ marginTop: '8px', overflow: 'hidden', width: '100%', height: `${this.props.height}px`, boxSizing: 'border-box' }}>
+      <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
         {children}
       </div>
     );
diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx
index 84cd07527..df5f0f2c2 100644
--- a/app/assets/javascripts/components/components/status.jsx
+++ b/app/assets/javascripts/components/components/status.jsx
@@ -34,6 +34,7 @@ const Status = React.createClass({
     onReblog: React.PropTypes.func,
     onDelete: React.PropTypes.func,
     onOpenMedia: React.PropTypes.func,
+    onBlock: React.PropTypes.func,
     me: React.PropTypes.number,
     muted: React.PropTypes.bool
   },
@@ -81,11 +82,11 @@ const Status = React.createClass({
       );
     }
 
-    if (status.get('media_attachments').size > 0) {
+    if (status.get('media_attachments').size > 0 && !this.props.muted) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
-        media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />;
+        media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} />;
       } else {
-        media = <MediaGallery media={status.get('media_attachments')} height={110} onOpenMedia={this.props.onOpenMedia} />;
+        media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />;
       }
     }
 
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index dec1decff..35feda88b 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -7,6 +7,7 @@ import { defineMessages, injectIntl } from 'react-intl';
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
   mention: { id: 'status.mention', defaultMessage: 'Mention' },
+  block: { id: 'account.block', defaultMessage: 'Block' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }
@@ -24,7 +25,8 @@ const StatusActionBar = React.createClass({
     onFavourite: React.PropTypes.func,
     onReblog: React.PropTypes.func,
     onDelete: React.PropTypes.func,
-    onMention: React.PropTypes.func
+    onMention: React.PropTypes.func,
+    onBlock: React.PropTypes.func
   },
 
   mixins: [PureRenderMixin],
@@ -49,6 +51,10 @@ const StatusActionBar = React.createClass({
     this.props.onMention(this.props.status.get('account'));
   },
 
+  handleBlockClick () {
+    this.props.onBlock(this.props.status.get('account'));
+  },
+
   render () {
     const { status, me, intl } = this.props;
     let menu = [];
@@ -57,6 +63,7 @@ const StatusActionBar = React.createClass({
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick });
     }
 
     return (
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index 9b9b0a2e4..61c1995a7 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -1,7 +1,7 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import IconButton from './icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 
 const messages = defineMessages({
   toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }
@@ -25,6 +25,30 @@ const muteStyle = {
   zIndex: '5'
 };
 
+const spoilerStyle = {
+  marginTop: '8px',
+  background: '#000',
+  color: '#fff',
+  textAlign: 'center',
+  height: '100%',
+  cursor: 'pointer',
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+  flexDirection: 'column'
+};
+
+const spoilerSpanStyle = {
+  display: 'block',
+  fontSize: '14px'
+};
+
+const spoilerSubSpanStyle = {
+  display: 'block',
+  fontSize: '11px',
+  fontWeight: '500'
+};
+
 const VideoPlayer = React.createClass({
   propTypes: {
     media: ImmutablePropTypes.map.isRequired,
@@ -41,6 +65,7 @@ const VideoPlayer = React.createClass({
 
   getInitialState () {
     return {
+      visible: false,
       muted: true
     };
   },
@@ -63,8 +88,21 @@ const VideoPlayer = React.createClass({
     }
   },
 
+  handleOpen () {
+    this.setState({ visible: true });
+  },
+
   render () {
-    const { media, intl, width, height } = this.props;
+    const { media, intl, width, height, sensitive } = this.props;
+
+    if (sensitive && !this.state.visible) {
+      return (
+        <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
+          <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+          <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </div>
+      );
+    }
 
     return (
       <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index 87c7c65f3..c42582bfd 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -39,6 +39,8 @@ import en from 'react-intl/locale-data/en';
 import de from 'react-intl/locale-data/de';
 import es from 'react-intl/locale-data/es';
 import fr from 'react-intl/locale-data/fr';
+import pt from 'react-intl/locale-data/pt';
+import hu from 'react-intl/locale-data/hu';
 import getMessagesForLocale from '../locales';
 
 const store = configureStore();
@@ -47,7 +49,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
   basename: '/web'
 });
 
-addLocaleData([...en, ...de, ...es, ...fr]);
+addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu]);
 
 const Mastodon = React.createClass({
 
@@ -75,11 +77,6 @@ const Mastodon = React.createClass({
               return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
             case 'delete':
               return store.dispatch(deleteFromTimelines(data.id));
-            case 'merge':
-            case 'unmerge':
-              return store.dispatch(refreshTimeline('home', true));
-            case 'block':
-              return store.dispatch(refreshTimeline('mentions', true));
             case 'notification':
               return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
           }
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index 28756b5ef..6a882eab4 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -1,18 +1,19 @@
-import { connect }       from 'react-redux';
-import Status            from '../components/status';
+import { connect } from 'react-redux';
+import Status from '../components/status';
 import { makeGetStatus } from '../selectors';
 import {
   replyCompose,
   mentionCompose
-}                        from '../actions/compose';
+} from '../actions/compose';
 import {
   reblog,
   favourite,
   unreblog,
   unfavourite
-}                        from '../actions/interactions';
-import { deleteStatus }  from '../actions/statuses';
-import { openMedia }     from '../actions/modal';
+} from '../actions/interactions';
+import { blockAccount } from '../actions/accounts';
+import { deleteStatus } from '../actions/statuses';
+import { openMedia } from '../actions/modal';
 import { createSelector } from 'reselect'
 
 const mapStateToProps = (state, props) => ({
@@ -91,6 +92,10 @@ const mapDispatchToProps = (dispatch) => ({
 
   onOpenMedia (url) {
     dispatch(openMedia(url));
+  },
+
+  onBlock (account) {
+    dispatch(blockAccount(account.get('id')));
   }
 
 });
diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx
index cd01de2e2..f09dea6ab 100644
--- a/app/assets/javascripts/components/features/account/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx
@@ -58,10 +58,8 @@ const ActionBar = React.createClass({
     } else if (account.getIn(['relationship', 'blocking'])) {
       menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock });
     } else if (account.getIn(['relationship', 'following'])) {
-      menu.push({ text: intl.formatMessage(messages.unfollow), action: this.props.onFollow });
       menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
     } else {
-      menu.push({ text: intl.formatMessage(messages.follow), action: this.props.onFollow });
       menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
     }
 
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index b3e9e2a9f..b890e15c1 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -2,22 +2,30 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import emojify from '../../../emoji';
 import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
-import { FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+});
 
 const Header = React.createClass({
 
   propTypes: {
     account: ImmutablePropTypes.map.isRequired,
-    me: React.PropTypes.number.isRequired
+    me: React.PropTypes.number.isRequired,
+    onFollow: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
 
   render () {
-    const { account, me } = this.props;
+    const { account, me, intl } = this.props;
 
     let displayName = account.get('display_name');
     let info        = '';
+    let actionBtn   = '';
 
     if (displayName.length === 0) {
       displayName = account.get('username');
@@ -27,11 +35,19 @@ const Header = React.createClass({
       info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>
     }
 
+    if (me !== account.get('id')) {
+      actionBtn = (
+        <div style={{ position: 'absolute', top: '10px', left: '20px' }}>
+          <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
+        </div>
+      );
+    }
+
     const content         = { __html: emojify(account.get('note')) };
     const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
 
     return (
-      <div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
+      <div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', backgroundPosition: 'center', position: 'relative' }}>
         <div style={{ background: 'rgba(47, 52, 65, 0.9)', padding: '20px 10px' }}>
           <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
             <div style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
@@ -45,6 +61,7 @@ const Header = React.createClass({
           <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
 
           {info}
+          {actionBtn}
         </div>
       </div>
     );
@@ -52,4 +69,4 @@ const Header = React.createClass({
 
 });
 
-export default Header;
+export default injectIntl(Header);
diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx
index 818979f8f..c2cc58bb2 100644
--- a/app/assets/javascripts/components/features/account/index.jsx
+++ b/app/assets/javascripts/components/features/account/index.jsx
@@ -87,9 +87,8 @@ const Account = React.createClass({
     return (
       <Column>
         <ColumnBackButton />
-        <Header account={account} me={me} />
-
-        <ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} onMention={this.handleMention} />
+        <Header account={account} me={me} onFollow={this.handleFollow} />
+        <ActionBar account={account} me={me} onBlock={this.handleBlock} onMention={this.handleMention} />
 
         {this.props.children}
       </Column>
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
index 32bdeaeca..b16731c05 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -8,7 +8,8 @@ import Autosuggest from 'react-autosuggest';
 import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
 import { debounce } from 'react-decoration';
 import UploadButtonContainer from '../containers/upload_button_container';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -54,7 +55,8 @@ const textareaStyle = {
   padding: '10px',
   fontFamily: 'Roboto',
   fontSize: '14px',
-  margin: '0'
+  margin: '0',
+  resize: 'vertical'
 };
 
 const renderInputComponent = inputProps => (
@@ -67,6 +69,7 @@ const ComposeForm = React.createClass({
     text: React.PropTypes.string.isRequired,
     suggestion_token: React.PropTypes.string,
     suggestions: React.PropTypes.array,
+    sensitive: React.PropTypes.bool,
     is_submitting: React.PropTypes.bool,
     is_uploading: React.PropTypes.bool,
     in_reply_to: ImmutablePropTypes.map,
@@ -75,7 +78,8 @@ const ComposeForm = React.createClass({
     onCancelReply: React.PropTypes.func.isRequired,
     onClearSuggestions: React.PropTypes.func.isRequired,
     onFetchSuggestions: React.PropTypes.func.isRequired,
-    onSuggestionSelected: React.PropTypes.func.isRequired
+    onSuggestionSelected: React.PropTypes.func.isRequired,
+    onChangeSensitivity: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -139,6 +143,10 @@ const ComposeForm = React.createClass({
     this.autosuggest = c;
   },
 
+  handleChangeSensitivity (e) {
+    this.props.onChangeSensitivity(e.target.checked);
+  },
+
   render () {
     const { intl } = this.props;
     let replyArea  = '';
@@ -178,6 +186,11 @@ const ComposeForm = React.createClass({
           <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
           <UploadButtonContainer style={{ paddingTop: '4px' }} />
         </div>
+
+        <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', marginTop: '10px', borderTop: '1px solid #616b86', paddingTop: '10px' }}>
+          <Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} />
+          <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark content as sensitive' /></span>
+        </label>
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx b/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx
deleted file mode 100644
index 6850629ba..000000000
--- a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import AccountContainer from '../../../containers/account_container';
-import { FormattedMessage } from 'react-intl';
-
-const outerStyle = {
-  position: 'relative'
-};
-
-const headerStyle = {
-  fontSize: '14px',
-  fontWeight: '500',
-  display: 'block',
-  padding: '10px',
-  color: '#9baec8',
-  background: '#454b5e',
-  overflow: 'hidden'
-};
-
-const nextStyle = {
-  display: 'inline-block',
-  float: 'right',
-  fontWeight: '400',
-  color: '#2b90d9'
-};
-
-const SuggestionsBox = React.createClass({
-
-  propTypes: {
-    accountIds: ImmutablePropTypes.list,
-    perWindow: React.PropTypes.number
-  },
-
-  getInitialState () {
-    return {
-      index: 0
-    };
-  },
-
-  getDefaultProps () {
-    return {
-      perWindow: 2
-    };
-  },
-
-  mixins: [PureRenderMixin],
-
-  handleNextClick (e) {
-    e.preventDefault();
-
-    let newIndex = this.state.index + 1;
-
-    if (this.props.accountIds.skip(this.props.perWindow * newIndex).size === 0) {
-      newIndex = 0;
-    }
-
-    this.setState({ index: newIndex });
-  },
-
-  render () {
-    const { accountIds, perWindow } = this.props;
-
-    if (!accountIds || accountIds.size === 0) {
-      return <div />;
-    }
-
-    let nextLink = '';
-
-    if (accountIds.size > perWindow) {
-      nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}><FormattedMessage id='suggestions_box.refresh' defaultMessage='Refresh' /></a>;
-    }
-
-    return (
-      <div style={outerStyle}>
-        <strong style={headerStyle}>
-          <FormattedMessage id='suggestions_box.who_to_follow' defaultMessage='Who to follow' /> {nextLink}
-        </strong>
-
-        {accountIds.skip(perWindow * this.state.index).take(perWindow).map(accountId => <AccountContainer key={accountId} id={accountId} withNote={false} />)}
-      </div>
-    );
-  }
-
-});
-
-export default SuggestionsBox;
diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
index 87bcd6b99..9897f6505 100644
--- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
@@ -6,7 +6,8 @@ import {
   cancelReplyCompose,
   clearComposeSuggestions,
   fetchComposeSuggestions,
-  selectComposeSuggestion
+  selectComposeSuggestion,
+  changeComposeSensitivity
 } from '../../../actions/compose';
 import { makeGetStatus } from '../../../selectors';
 
@@ -18,6 +19,7 @@ const makeMapStateToProps = () => {
       text: state.getIn(['compose', 'text']),
       suggestion_token: state.getIn(['compose', 'suggestion_token']),
       suggestions: state.getIn(['compose', 'suggestions']).toJS(),
+      sensitive: state.getIn(['compose', 'sensitive']),
       is_submitting: state.getIn(['compose', 'is_submitting']),
       is_uploading: state.getIn(['compose', 'is_uploading']),
       in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
@@ -51,6 +53,10 @@ const mapDispatchToProps = function (dispatch) {
 
     onSuggestionSelected (position, accountId) {
       dispatch(selectComposeSuggestion(position, accountId));
+    },
+
+    onChangeSensitivity (checked) {
+      dispatch(changeComposeSensitivity(checked));
     }
   }
 };
diff --git a/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx b/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx
deleted file mode 100644
index 944ceed85..000000000
--- a/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { connect }           from 'react-redux';
-import SuggestionsBox        from '../components/suggestions_box';
-
-const mapStateToProps = (state) => ({
-  accountIds: state.getIn(['user_lists', 'suggestions'])
-});
-
-export default connect(mapStateToProps)(SuggestionsBox);
diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index 5c1b22e00..4017c8949 100644
--- a/app/assets/javascripts/components/features/compose/index.jsx
+++ b/app/assets/javascripts/components/features/compose/index.jsx
@@ -3,9 +3,7 @@ import ComposeFormContainer from './containers/compose_form_container';
 import UploadFormContainer from './containers/upload_form_container';
 import NavigationContainer from './containers/navigation_container';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import SuggestionsContainer from './containers/suggestions_container';
 import SearchContainer from './containers/search_container';
-import { fetchSuggestions } from '../../actions/suggestions';
 import { connect } from 'react-redux';
 import { mountCompose, unmountCompose } from '../../actions/compose';
 
@@ -19,7 +17,6 @@ const Compose = React.createClass({
 
   componentDidMount () {
     this.props.dispatch(mountCompose());
-    this.props.dispatch(fetchSuggestions());
   },
 
   componentWillUnmount () {
@@ -29,14 +26,10 @@ const Compose = React.createClass({
   render () {
     return (
       <Drawer>
-        <div style={{ flex: '1 1 auto' }}>
-          <SearchContainer />
-          <NavigationContainer />
-          <ComposeFormContainer />
-          <UploadFormContainer />
-        </div>
-
-        <SuggestionsContainer />
+        <SearchContainer />
+        <NavigationContainer />
+        <ComposeFormContainer />
+        <UploadFormContainer />
       </Drawer>
     );
   }
diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
index 76ddafb3b..b967d966f 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -36,9 +36,9 @@ const DetailedStatus = React.createClass({
 
     if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
-        media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
+        media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
       } else {
-        media = <MediaGallery media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
+        media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
       }
     }
 
diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx
index 85412635e..4e2a70edb 100644
--- a/app/assets/javascripts/components/locales/de.jsx
+++ b/app/assets/javascripts/components/locales/de.jsx
@@ -41,8 +41,6 @@ const en = {
   "search.placeholder": "Suche",
   "search.account": "Konto",
   "search.hashtag": "Hashtag",
-  "suggestions_box.who_to_follow": "Wem folgen",
-  "suggestions_box.refresh": "Aktualisieren",
   "upload_button.label": "Media-Datei anfügen",
   "upload_form.undo": "Entfernen",
   "notification.follow": "{name} folgt dir",
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index b2c8390c1..41a44e3dc 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -5,9 +5,11 @@ const en = {
   "status.mention": "Mention",
   "status.delete": "Delete",
   "status.reply": "Reply",
-  "status.reblog": "Reblog",
+  "status.reblog": "Boost",
   "status.favourite": "Favourite",
-  "status.reblogged_by": "{name} reblogged",
+  "status.reblogged_by": "{name} boosted",
+  "status.sensitive_warning": "Sensitive content",
+  "status.sensitive_toggle": "Click to view",
   "video_player.toggle_sound": "Toggle sound",
   "account.mention": "Mention",
   "account.edit_profile": "Edit profile",
@@ -34,7 +36,8 @@ const en = {
   "tabs_bar.public": "Public",
   "tabs_bar.notifications": "Notifications",
   "compose_form.placeholder": "What is on your mind?",
-  "compose_form.publish": "Publish",
+  "compose_form.publish": "Toot",
+  "compose_form.sensitive": "Mark content as sensitive",
   "navigation_bar.settings": "Settings",
   "navigation_bar.public_timeline": "Public timeline",
   "navigation_bar.logout": "Logout",
@@ -42,13 +45,11 @@ const en = {
   "search.placeholder": "Search",
   "search.account": "Account",
   "search.hashtag": "Hashtag",
-  "suggestions_box.who_to_follow": "Who to follow",
-  "suggestions_box.refresh": "Refresh",
   "upload_button.label": "Add media",
   "upload_form.undo": "Undo",
   "notification.follow": "{name} followed you",
   "notification.favourite": "{name} favourited your status",
-  "notification.reblog": "{name} reblogged your status",
+  "notification.reblog": "{name} boosted your status",
   "notification.mention": "{name} mentioned you"
 };
 
diff --git a/app/assets/javascripts/components/locales/es.jsx b/app/assets/javascripts/components/locales/es.jsx
index 47377e5ae..d4434bba7 100644
--- a/app/assets/javascripts/components/locales/es.jsx
+++ b/app/assets/javascripts/components/locales/es.jsx
@@ -42,8 +42,6 @@ const es = {
   "search.placeholder": "Buscar",
   "search.account": "Cuenta",
   "search.hashtag": "Etiqueta",
-  "suggestions_box.who_to_follow": "A quién seguir",
-  "suggestions_box.refresh": "Refrescar",
   "upload_button.label": "Añadir medio",
   "upload_form.undo": "Deshacer",
   "notification.follow": "{name} le esta ahora siguiendo",
diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index d6e24c523..c4458a145 100644
--- a/app/assets/javascripts/components/locales/fr.jsx
+++ b/app/assets/javascripts/components/locales/fr.jsx
@@ -7,22 +7,24 @@ const fr = {
   "status.reply": "Répondre",
   "status.reblog": "Partager",
   "status.favourite": "Ajouter aux favoris",
-  "status.reblogged_by": "{name} a partagé",
+  "status.reblogged_by": "{name} a partagé :",
+  "status.sensitive_warning": "Contenu délicat",
+  "status.sensitive_toggle": "Cliquer pour dévoiler",
   "video_player.toggle_sound": "Mettre/Couper le son",
   "account.mention": "Mentionner",
   "account.edit_profile": "Modifier le profil",
   "account.unblock": "Débloquer",
-  "account.unfollow": "Se désabonner",
+  "account.unfollow": "Ne plus suivre",
   "account.block": "Bloquer",
-  "account.follow": "S’abonner",
+  "account.follow": "Suivre",
   "account.posts": "Statuts",
   "account.follows": "Abonnements",
   "account.followers": "Abonnés",
   "account.follows_you": "Vous suit",
   "getting_started.heading": "Pour commencer",
-  "getting_started.about_addressing": "Vous pouvez vous abonner aux statuts de quelqu’un en entrant dans le champs de recherche leur nom d’utilisateur et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.",
-  "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, le nom d’utilisateur suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.",
-  "getting_started.about_developer": "Pour s’abonner au développeur de ce projet, c’est Gargron@mastodon.social",
+  "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.",
+  "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.",
+  "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social",
   "column.home": "Accueil",
   "column.mentions": "Mentions",
   "column.public": "Fil public",
@@ -32,23 +34,22 @@ const fr = {
   "tabs_bar.mentions": "Mentions",
   "tabs_bar.public": "Public",
   "tabs_bar.notifications": "Notifications",
-  "compose_form.placeholder": "Qu’avez vous en tête&nbsp;?",
-  "compose_form.publish": "Publier",
+  "compose_form.placeholder": "Qu’avez-vous en tête ?",
+  "compose_form.publish": "Pouet",
+  "compose_form.sensitive": "Marquer le contenu comme délicat", 
   "navigation_bar.settings": "Paramètres",
   "navigation_bar.public_timeline": "Public",
-  "navigation_bar.logout": "Se déconnecter",
+  "navigation_bar.logout": "Déconnexion",
   "reply_indicator.cancel": "Annuler",
   "search.placeholder": "Chercher",
   "search.account": "Compte",
   "search.hashtag": "Mot-clé",
-  "suggestions_box.who_to_follow": "Suggestions",
-  "suggestions_box.refresh": "Rafraîchir",
   "upload_button.label": "Joindre un média",
   "upload_form.undo": "Annuler",
-  "notification.follow": "{name} s’est abonné⋅e à vos statuts",
-  "notification.favourite": "{name} a ajouté votre statut à ses favoris",
-  "notification.reblog": "{name} a partagé votre statut",
-  "notification.mention": "{name} vous a mentionné⋅e"
+  "notification.follow": "{name} vous suit.",
+  "notification.favourite": "{name} a ajouté à ses favoris :",
+  "notification.reblog": "{name} a partagé votre statut :",
+  "notification.mention": "{name} vous a mentionné⋅e :"
 };
 
 export default fr;
diff --git a/app/assets/javascripts/components/locales/hu.jsx b/app/assets/javascripts/components/locales/hu.jsx
new file mode 100644
index 000000000..4a446965c
--- /dev/null
+++ b/app/assets/javascripts/components/locales/hu.jsx
@@ -0,0 +1,55 @@
+const hu = {
+  "column_back_button.label": "Vissza",
+  "lightbox.close": "Bezárás",
+  "loading_indicator.label": "Betöltés...",
+  "status.mention": "Említés",
+  "status.delete": "Törlés",
+  "status.reply": "Válasz",
+  "status.reblog": "Reblog",
+  "status.favourite": "Kedvenc",
+  "status.reblogged_by": "{name} reblogolta",
+  "status.sensitive_warning": "Érzékeny tartalom",
+  "status.sensitive_toggle": "Katt a megtekintéshez",
+  "video_player.toggle_sound": "Hang kapcsolása",
+  "account.mention": "Említés",
+  "account.edit_profile": "Profil szerkesztése",
+  "account.unblock": "Blokkolás levétele",
+  "account.unfollow": "Követés abbahagyása",
+  "account.block": "Blokkolás",
+  "account.follow": "Követés",
+  "account.posts": "Posts",
+  "account.follows": "Követők",
+  "account.followers": "Követők",
+  "account.follows_you": "Követnek téged",
+  "getting_started.heading": "Első lépések",
+  "getting_started.about_addressing": "Követhetsz embereket felhasználónevük és a doménjük ismeretében, amennyiben megadod ezt az e-mail-szerű címet az oldalsáv tetején lévő rubrikában.",
+  "getting_started.about_shortcuts": "Ha a célzott személy azonos doménen tartózkodik, a felhasználónév elegendő. Ugyanez érvényes mikor személyeket említesz az állapotokban.",
+  "getting_started.about_developer": "A projekt fejlesztője követhető, mint Gargron@mastodon.social",
+  "column.home": "Kezdőlap",
+  "column.mentions": "Említések",
+  "column.public": "Nyilvános",
+  "column.notifications": "Értesítések",
+  "tabs_bar.compose": "Összeállítás",
+  "tabs_bar.home": "Kezdőlap",
+  "tabs_bar.mentions": "Említések",
+  "tabs_bar.public": "Nyilvános",
+  "tabs_bar.notifications": "Notifications",
+  "compose_form.placeholder": "Mire gondolsz?",
+  "compose_form.publish": "Tülk!",
+  "compose_form.sensitive": "Tartalom érzékenynek jelölése",
+  "navigation_bar.settings": "Beállítások",
+  "navigation_bar.public_timeline": "Nyilvános időfolyam",
+  "navigation_bar.logout": "Kijelentkezés",
+  "reply_indicator.cancel": "Mégsem",
+  "search.placeholder": "Keresés",
+  "search.account": "Fiók",
+  "search.hashtag": "Hashtag",
+  "upload_button.label": "Média hozzáadása",
+  "upload_form.undo": "Mégsem",
+  "notification.follow": "{name} követ téged",
+  "notification.favourite": "{name} kedvencnek jelölte az állapotod",
+  "notification.reblog": "{name} reblogolta az állapotod",
+  "notification.mention": "{name} megemlített"
+};
+
+export default hu;
diff --git a/app/assets/javascripts/components/locales/index.jsx b/app/assets/javascripts/components/locales/index.jsx
index 7fb43dd33..f172b1c51 100644
--- a/app/assets/javascripts/components/locales/index.jsx
+++ b/app/assets/javascripts/components/locales/index.jsx
@@ -1,11 +1,17 @@
 import en from './en';
 import de from './de';
 import es from './es';
+import hu from './hu';
+import fr from './fr';
+import pt from './pt';
 
 const locales = {
   en,
   de,
-  es
+  es,
+  hu,
+  fr,
+  pt
 };
 
 export default function getMessagesForLocale (locale) {
diff --git a/app/assets/javascripts/components/locales/pt.jsx b/app/assets/javascripts/components/locales/pt.jsx
index 02b21f3cb..e67bd80ac 100644
--- a/app/assets/javascripts/components/locales/pt.jsx
+++ b/app/assets/javascripts/components/locales/pt.jsx
@@ -40,8 +40,6 @@ const pt = {
   "search.placeholder": "Busca",
   "search.account": "Conta",
   "search.hashtag": "Hashtag",
-  "suggestions_box.who_to_follow": "Quem seguir",
-  "suggestions_box.refresh": "Recarregar",
   "upload_button.label": "Adicionar media",
   "upload_form.undo": "Desfazer"
 };
diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx
index fb161fc4c..3a1473bc1 100644
--- a/app/assets/javascripts/components/middleware/errors.jsx
+++ b/app/assets/javascripts/components/middleware/errors.jsx
@@ -1,11 +1,13 @@
 import { showAlert } from '../actions/alerts';
 
+const defaultSuccessSuffix = 'SUCCESS';
 const defaultFailSuffix = 'FAIL';
 
 export default function errorsMiddleware() {
   return ({ dispatch }) => next => action => {
     if (action.type) {
       const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
+      const isSuccess = new RegExp(`${defaultSuccessSuffix}$`, 'g');
 
       if (action.type.match(isFail)) {
         if (action.error.response) {
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index 68247a98c..52be648b3 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -8,7 +8,6 @@ import {
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS
 } from '../actions/accounts';
-import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
 import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
 import {
   REBLOG_SUCCESS,
@@ -71,7 +70,6 @@ export default function accounts(state = initialState, action) {
     case ACCOUNT_FETCH_SUCCESS:
     case NOTIFICATIONS_UPDATE:
       return normalizeAccount(state, action.account);
-    case SUGGESTIONS_FETCH_SUCCESS:
     case FOLLOWERS_FETCH_SUCCESS:
     case FOLLOWERS_EXPAND_SUCCESS:
     case FOLLOWING_FETCH_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index e6e86d4f5..4abc3e6aa 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -15,7 +15,8 @@ import {
   COMPOSE_UPLOAD_PROGRESS,
   COMPOSE_SUGGESTIONS_CLEAR,
   COMPOSE_SUGGESTIONS_READY,
-  COMPOSE_SUGGESTION_SELECT
+  COMPOSE_SUGGESTION_SELECT,
+  COMPOSE_SENSITIVITY_CHANGE
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { ACCOUNT_SET_SELF } from '../actions/accounts';
@@ -23,6 +24,7 @@ import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   mounted: false,
+  sensitive: false,
   text: '',
   in_reply_to: null,
   is_submitting: false,
@@ -87,6 +89,8 @@ export default function compose(state = initialState, action) {
       return state.set('mounted', true);
     case COMPOSE_UNMOUNT:
       return state.set('mounted', false);
+    case COMPOSE_SENSITIVITY_CHANGE:
+      return state.set('sensitive', action.checked);
     case COMPOSE_CHANGE:
       return state.set('text', action.text);
     case COMPOSE_REPLY:
diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx
index 0e67e732a..617a833d2 100644
--- a/app/assets/javascripts/components/reducers/notifications.jsx
+++ b/app/assets/javascripts/components/reducers/notifications.jsx
@@ -3,6 +3,7 @@ import {
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS
 } from '../actions/notifications';
+import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
 import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
@@ -43,6 +44,10 @@ const appendNormalizedNotifications = (state, notifications, next) => {
   return state.update('items', list => list.push(...items)).set('next', next);
 };
 
+const filterNotifications = (state, relationship) => {
+  return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
+};
+
 export default function notifications(state = initialState, action) {
   switch(action.type) {
     case NOTIFICATIONS_UPDATE:
@@ -51,6 +56,8 @@ export default function notifications(state = initialState, action) {
       return normalizeNotifications(state, action.notifications, action.next);
     case NOTIFICATIONS_EXPAND_SUCCESS:
       return appendNormalizedNotifications(state, action.notifications, action.next);
+    case ACCOUNT_BLOCK_SUCCESS:
+      return filterNotifications(state, action.relationship);
     default:
       return state;
   }
diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx
index 2a24a75e4..c740b6d64 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -1,7 +1,11 @@
 import {
+  REBLOG_REQUEST,
   REBLOG_SUCCESS,
+  REBLOG_FAIL,
   UNREBLOG_SUCCESS,
+  FAVOURITE_REQUEST,
   FAVOURITE_SUCCESS,
+  FAVOURITE_FAIL,
   UNFAVOURITE_SUCCESS
 } from '../actions/interactions';
 import {
@@ -16,7 +20,8 @@ import {
 } from '../actions/timelines';
 import {
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
-  ACCOUNT_TIMELINE_EXPAND_SUCCESS
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  ACCOUNT_BLOCK_SUCCESS
 } from '../actions/accounts';
 import {
   NOTIFICATIONS_UPDATE,
@@ -56,6 +61,18 @@ const deleteStatus = (state, id, references) => {
   return state.delete(id);
 };
 
+const filterStatuses = (state, relationship) => {
+  state.forEach(status => {
+    if (status.get('account') !== relationship.id) {
+      return;
+    }
+
+    state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id')));
+  });
+
+  return state;
+};
+
 const initialState = Immutable.Map();
 
 export default function statuses(state = initialState, action) {
@@ -69,6 +86,14 @@ export default function statuses(state = initialState, action) {
     case FAVOURITE_SUCCESS:
     case UNFAVOURITE_SUCCESS:
       return normalizeStatus(state, action.response);
+    case FAVOURITE_REQUEST:
+      return state.setIn([action.status.get('id'), 'favourited'], true);
+    case FAVOURITE_FAIL:
+      return state.setIn([action.status.get('id'), 'favourited'], false);
+    case REBLOG_REQUEST:
+      return state.setIn([action.status.get('id'), 'reblogged'], true);
+    case REBLOG_FAIL:
+      return state.setIn([action.status.get('id'), 'reblogged'], false);
     case TIMELINE_REFRESH_SUCCESS:
     case TIMELINE_EXPAND_SUCCESS:
     case ACCOUNT_TIMELINE_FETCH_SUCCESS:
@@ -79,6 +104,8 @@ export default function statuses(state = initialState, action) {
       return normalizeStatuses(state, action.statuses);
     case TIMELINE_DELETE:
       return deleteStatus(state, action.id, action.references);
+    case ACCOUNT_BLOCK_SUCCESS:
+      return filterStatuses(state, action.relationship);
     default:
       return state;
   }
diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx
index 9e79a4100..358734eaf 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -13,7 +13,8 @@ import {
 import {
   ACCOUNT_FETCH_SUCCESS,
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
-  ACCOUNT_TIMELINE_EXPAND_SUCCESS
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  ACCOUNT_BLOCK_SUCCESS
 } from '../actions/accounts';
 import {
   STATUS_FETCH_SUCCESS,
@@ -140,6 +141,21 @@ const deleteStatus = (state, id, accountId, references) => {
   return state;
 };
 
+const filterTimelines = (state, relationship, statuses) => {
+  let references;
+
+  statuses.forEach(status => {
+    if (status.get('account') !== relationship.id) {
+      return;
+    }
+
+    references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]);
+    state = deleteStatus(state, status.get('id'), status.get('account'), references);
+  });
+
+  return state;
+};
+
 const normalizeContext = (state, id, ancestors, descendants) => {
   const ancestorsIds   = ancestors.map(ancestor => ancestor.get('id'));
   const descendantsIds = descendants.map(descendant => descendant.get('id'));
@@ -166,6 +182,8 @@ export default function timelines(state = initialState, action) {
       return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
     case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
       return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
+    case ACCOUNT_BLOCK_SUCCESS:
+      return filterTimelines(state, action.relationship, action.statuses);
     default:
       return state;
   }
diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx
index 65598f8a0..3608e4209 100644
--- a/app/assets/javascripts/components/reducers/user_lists.jsx
+++ b/app/assets/javascripts/components/reducers/user_lists.jsx
@@ -4,7 +4,6 @@ import {
   FOLLOWING_FETCH_SUCCESS,
   FOLLOWING_EXPAND_SUCCESS
 } from '../actions/accounts';
-import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
 import {
   REBLOGS_FETCH_SUCCESS,
   FAVOURITES_FETCH_SUCCESS
@@ -14,7 +13,6 @@ import Immutable from 'immutable';
 const initialState = Immutable.Map({
   followers: Immutable.Map(),
   following: Immutable.Map(),
-  suggestions: Immutable.List(),
   reblogged_by: Immutable.Map(),
   favourited_by: Immutable.Map()
 });
@@ -42,8 +40,6 @@ export default function userLists(state = initialState, action) {
       return normalizeList(state, 'following', action.id, action.accounts, action.next);
     case FOLLOWING_EXPAND_SUCCESS:
       return appendToList(state, 'following', action.id, action.accounts, action.next);
-    case SUGGESTIONS_FETCH_SUCCESS:
-      return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id)));
     case REBLOGS_FETCH_SUCCESS:
       return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
     case FAVOURITES_FETCH_SUCCESS:
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 05a309365..bbbeafefe 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -234,3 +234,4 @@ body {
 @import 'stream_entries';
 @import 'components';
 @import 'about';
+@import 'tables';
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index adf0db990..cc9f0eb3b 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -405,3 +405,109 @@
     text-decoration: underline;
   }
 }
+
+.react-toggle {
+  display: inline-block;
+  position: relative;
+  cursor: pointer;
+  background-color: transparent;
+  border: 0;
+  padding: 0;
+  user-select: none;
+  -webkit-tap-highlight-color: rgba(0,0,0,0);
+  -webkit-tap-highlight-color: transparent;
+}
+
+.react-toggle-screenreader-only {
+  border: 0;
+  clip: rect(0 0 0 0);
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  padding: 0;
+  position: absolute;
+  width: 1px;
+}
+
+.react-toggle--disabled {
+  cursor: not-allowed;
+  opacity: 0.5;
+  transition: opacity 0.25s;
+}
+
+.react-toggle-track {
+  width: 50px;
+  height: 24px;
+  padding: 0;
+  border-radius: 30px;
+  background-color: #282c37;
+  transition: all 0.2s ease;
+}
+
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background-color: darken(#282c37, 10%);
+}
+
+.react-toggle--checked .react-toggle-track {
+  background-color: #2b90d9;
+}
+
+.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background-color: lighten(#2b90d9, 10%);
+}
+
+.react-toggle-track-check {
+  position: absolute;
+  width: 14px;
+  height: 10px;
+  top: 0px;
+  bottom: 0px;
+  margin-top: auto;
+  margin-bottom: auto;
+  line-height: 0;
+  left: 8px;
+  opacity: 0;
+  transition: opacity 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-track-check {
+  opacity: 1;
+  transition: opacity 0.25s ease;
+}
+
+.react-toggle-track-x {
+  position: absolute;
+  width: 10px;
+  height: 10px;
+  top: 0px;
+  bottom: 0px;
+  margin-top: auto;
+  margin-bottom: auto;
+  line-height: 0;
+  right: 10px;
+  opacity: 1;
+  transition: opacity 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-track-x {
+  opacity: 0;
+}
+
+.react-toggle-thumb {
+  transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
+  position: absolute;
+  top: 1px;
+  left: 1px;
+  width: 22px;
+  height: 22px;
+  border: 1px solid #282c37;
+  border-radius: 50%;
+  background-color: #FAFAFA;
+  box-sizing: border-box;
+  transition: all 0.25s ease;
+}
+
+.react-toggle--checked .react-toggle-thumb {
+  left: 27px;
+  border-color: #2b90d9;
+}
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index c7bdbe2c0..81270edf6 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -48,11 +48,16 @@ code {
       display: block;
     }
 
-    input[type=checkbox] {
-      display: inline-block;
+    label.checkbox {
       position: relative;
-      top: 3px;
-      margin-right: 8px;
+	    padding-left: 25px;
+    }
+
+    input[type=checkbox] {
+	    position: absolute;
+	    left: 0;
+      top: 1px;
+      margin: 0;
     }
   }
 
diff --git a/app/assets/stylesheets/tables.scss b/app/assets/stylesheets/tables.scss
new file mode 100644
index 000000000..89b35891d
--- /dev/null
+++ b/app/assets/stylesheets/tables.scss
@@ -0,0 +1,25 @@
+.table {
+  width: 100%;
+  max-width: 100%;
+  border-spacing: 0;
+  border-collapse: collapse;
+
+  th, td {
+    padding: 8px;
+    line-height: 1.42857143;
+    vertical-align: top;
+    border-top: 1px solid #ddd;
+    text-align: left;
+  }
+
+  & > thead > tr > th {
+    vertical-align: bottom;
+    border-bottom: 2px solid #ddd;
+    border-top: 0;
+    font-weight: 500;
+  }
+}
+
+samp {
+  font-family: 'Roboto Mono', monospace;
+}
diff --git a/app/controllers/admin/pubsubhubbub_controller.rb b/app/controllers/admin/pubsubhubbub_controller.rb
new file mode 100644
index 000000000..7e6bc75ea
--- /dev/null
+++ b/app/controllers/admin/pubsubhubbub_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Admin::PubsubhubbubController < ApplicationController
+  before_action :require_admin!
+
+  layout 'public'
+
+  def index
+    @subscriptions = Subscription.order('id desc').includes(:account).paginate(page: params[:page], per_page: 40)
+  end
+end
diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb
new file mode 100644
index 000000000..78d4e36e6
--- /dev/null
+++ b/app/controllers/api/push_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class Api::PushController < ApiController
+  def update
+    mode          = params['hub.mode']
+    topic         = params['hub.topic']
+    callback      = params['hub.callback']
+    lease_seconds = params['hub.lease_seconds']
+    secret        = params['hub.secret']
+
+    case mode
+    when 'subscribe'
+      response, status = Pubsubhubbub::SubscribeService.new.call(topic_to_account(topic), callback, secret, lease_seconds)
+    when 'unsubscribe'
+      response, status = Pubsubhubbub::UnsubscribeService.new.call(topic_to_account(topic), callback)
+    else
+      response = "Unknown mode: #{mode}"
+      status   = 422
+    end
+
+    render plain: response, status: status
+  end
+
+  private
+
+  def topic_to_account(topic_url)
+    return if topic_url.blank?
+
+    uri    = Addressable::URI.parse(topic_url)
+    params = Rails.application.routes.recognize_path(uri.path)
+    domain = uri.host + (uri.port ? ":#{uri.port}" : '')
+
+    return unless TagManager.instance.local_domain?(domain) && params[:controller] == 'accounts' && params[:action] == 'show' && params[:format] == 'atom'
+
+    Account.find_local(params[:username])
+  end
+end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 2dfab0831..9a356196c 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -18,9 +18,11 @@ class Api::V1::AccountsController < ApiController
 
   def following
     results   = Follow.where(account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
-    accounts  = Account.where(id: results.map(&:target_account_id)).with_counters.map { |a| [a.id, a] }.to_h
+    accounts  = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.target_account_id] }
 
+    set_account_counters_maps(@accounts)
+
     next_path = following_api_v1_account_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
     prev_path = following_api_v1_account_url(since_id: results.first.id) unless results.empty?
 
@@ -31,9 +33,11 @@ class Api::V1::AccountsController < ApiController
 
   def followers
     results   = Follow.where(target_account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
-    accounts  = Account.where(id: results.map(&:account_id)).with_counters.map { |a| [a.id, a] }.to_h
+    accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.account_id] }
 
+    set_account_counters_maps(@accounts)
+
     next_path = followers_api_v1_account_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
     prev_path = followers_api_v1_account_url(since_id: results.first.id) unless results.empty?
 
@@ -42,20 +46,12 @@ class Api::V1::AccountsController < ApiController
     render action: :index
   end
 
-  def common_followers
-    @accounts = @account.common_followers_with(current_user.account)
-    render action: :index
-  end
-
-  def suggestions
-    @accounts = FollowSuggestion.get(current_user.account_id)
-    render action: :index
-  end
-
   def statuses
-    @statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
+    @statuses = @account.statuses.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
+    @statuses = cache_collection(@statuses, Status)
 
     set_maps(@statuses)
+    set_counters_maps(@statuses)
 
     next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
     prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -98,6 +94,9 @@ class Api::V1::AccountsController < ApiController
   def search
     limit = params[:limit] ? [DEFAULT_ACCOUNTS_LIMIT, params[:limit].to_i].min : DEFAULT_ACCOUNTS_LIMIT
     @accounts = SearchService.new.call(params[:q], limit, params[:resolve] == 'true')
+
+    set_account_counters_maps(@accounts)
+
     render action: :index
   end
 
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index bb8e8d9ee..f8139ade7 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -4,6 +4,9 @@ class Api::V1::MediaController < ApiController
   before_action -> { doorkeeper_authorize! :write }
   before_action :require_user!
 
+  include ObfuscateFilename
+  obfuscate_filename :file
+
   respond_to :json
 
   def create
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index c76189e87..a24e0beb7 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -7,7 +7,8 @@ class Api::V1::NotificationsController < ApiController
   respond_to :json
 
   def index
-    @notifications = Notification.where(account: current_account).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
+    @notifications = Notification.where(account: current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
+    @notifications = cache_collection(@notifications, Notification)
     statuses       = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status)
 
     set_maps(statuses)
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 604e2969d..a0b15cfbc 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -9,18 +9,25 @@ class Api::V1::StatusesController < ApiController
   respond_to :json
 
   def show
+    cached  = Rails.cache.read(@status.cache_key)
+    @status = cached unless cached.nil?
   end
 
   def context
     @context = OpenStruct.new(ancestors: @status.ancestors(current_account), descendants: @status.descendants(current_account))
-    set_maps([@status] + @context[:ancestors] + @context[:descendants])
+    statuses = [@status] + @context[:ancestors] + @context[:descendants]
+
+    set_maps(statuses)
+    set_counters_maps(statuses)
   end
 
   def reblogged_by
     results   = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
-    accounts  = Account.where(id: results.map(&:account_id)).with_counters.map { |a| [a.id, a] }.to_h
+    accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |r| accounts[r.account_id] }
 
+    set_account_counters_maps(@accounts)
+
     next_path = reblogged_by_api_v1_status_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
     prev_path = reblogged_by_api_v1_status_url(since_id: results.first.id) unless results.empty?
 
@@ -31,9 +38,11 @@ class Api::V1::StatusesController < ApiController
 
   def favourited_by
     results   = @status.favourites.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
-    accounts  = Account.where(id: results.map(&:account_id)).with_counters.map { |a| [a.id, a] }.to_h
+    accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.account_id] }
 
+    set_account_counters_maps(@accounts)
+
     next_path = favourited_by_api_v1_status_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
     prev_path = favourited_by_api_v1_status_url(since_id: results.first.id) unless results.empty?
 
@@ -43,13 +52,13 @@ class Api::V1::StatusesController < ApiController
   end
 
   def create
-    @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), params[:media_ids])
+    @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive])
     render action: :show
   end
 
   def destroy
     @status = Status.where(account_id: current_user.account).find(params[:id])
-    RemoveStatusService.new.call(@status)
+    RemovalWorker.perform_async(@status.id)
     render_empty
   end
 
@@ -59,8 +68,12 @@ class Api::V1::StatusesController < ApiController
   end
 
   def unreblog
-    RemoveStatusService.new.call(Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first!)
-    @status = Status.find(params[:id])
+    reblog         = Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first!
+    @status        = reblog.reblog
+    @reblogged_map = { @status.id => false }
+
+    RemovalWorker.perform_async(reblog.id)
+    
     render action: :show
   end
 
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index 19b76f11d..89e54e2cf 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -8,8 +8,11 @@ class Api::V1::TimelinesController < ApiController
 
   def home
     @statuses = Feed.new(:home, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
+    @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
+    set_counters_maps(@statuses)
+    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
     prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -21,8 +24,11 @@ class Api::V1::TimelinesController < ApiController
 
   def mentions
     @statuses = Feed.new(:mentions, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
+    @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
+    set_counters_maps(@statuses)
+    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
     prev_path = api_v1_mentions_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -34,8 +40,11 @@ class Api::V1::TimelinesController < ApiController
 
   def public
     @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
+    @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
+    set_counters_maps(@statuses)
+    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
     prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -48,8 +57,11 @@ class Api::V1::TimelinesController < ApiController
   def tag
     @tag      = Tag.find_by(name: params[:id].downcase)
     @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
+    @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
+    set_counters_maps(@statuses)
+    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
     prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?
@@ -58,4 +70,10 @@ class Api::V1::TimelinesController < ApiController
 
     render action: :index
   end
+
+  private
+
+  def cache_collection(raw)
+    super(raw, Status)
+  end
 end
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index 862358d6e..d2d3bc4a4 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -48,7 +48,7 @@ class ApiController < ApplicationController
 
     response.headers['X-RateLimit-Limit']     = match_data[:limit].to_s
     response.headers['X-RateLimit-Remaining'] = (match_data[:limit] - match_data[:count]).to_s
-    response.headers['X-RateLimit-Reset']     = (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
+    response.headers['X-RateLimit-Reset']     = (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6)
   end
 
   def set_pagination_headers(next_path = nil, prev_path = nil)
@@ -59,7 +59,7 @@ class ApiController < ApplicationController
   end
 
   def current_resource_owner
-    User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
+    @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
   end
 
   def current_user
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 3a4c95db4..ba0098c71 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -31,6 +31,10 @@ class ApplicationController < ActionController::Base
     I18n.locale = I18n.default_locale
   end
 
+  def require_admin!
+    redirect_to root_path unless current_user&.admin?
+  end
+
   protected
 
   def not_found
@@ -46,6 +50,25 @@ class ApplicationController < ActionController::Base
   end
 
   def current_account
-    current_user.try(:account)
+    @current_account ||= current_user.try(:account)
+  end
+
+  def cache_collection(raw, klass)
+    uncached_ids           = []
+    cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key))
+
+    raw.each do |item|
+      uncached_ids << item.id unless cached_keys_with_value.key?(item.cache_key)
+    end
+
+    unless uncached_ids.empty?
+      uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h
+
+      uncached.values.each do |item|
+        Rails.cache.write(item.cache_key, item)
+      end
+    end
+
+    raw.map { |item| cached_keys_with_value[item.cache_key] || uncached[item.id] }.compact
   end
 end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 5be8719ae..cacc03b65 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -14,7 +14,10 @@ class Settings::PreferencesController < ApplicationController
     current_user.settings(:notification_emails).favourite = user_params[:notification_emails][:favourite] == '1'
     current_user.settings(:notification_emails).mention   = user_params[:notification_emails][:mention]   == '1'
 
-    if current_user.update(user_params.except(:notification_emails))
+    current_user.settings(:interactions).must_be_follower  = user_params[:interactions][:must_be_follower]  == '1'
+    current_user.settings(:interactions).must_be_following = user_params[:interactions][:must_be_following] == '1'
+
+    if current_user.update(user_params.except(:notification_emails, :interactions))
       redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg')
     else
       render action: :show
@@ -24,6 +27,6 @@ class Settings::PreferencesController < ApplicationController
   private
 
   def user_params
-    params.require(:user).permit(:locale, notification_emails: [:follow, :reblog, :favourite, :mention])
+    params.require(:user).permit(:locale, notification_emails: [:follow, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following])
   end
 end
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 4b2b5a131..21fbba2af 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -6,6 +6,10 @@ class Settings::ProfilesController < ApplicationController
   before_action :authenticate_user!
   before_action :set_account
 
+  include ObfuscateFilename
+  obfuscate_filename [:account, :avatar]
+  obfuscate_filename [:account, :header]
+
   def show
   end
 
diff --git a/app/helpers/admin/pubsubhubbub_helper.rb b/app/helpers/admin/pubsubhubbub_helper.rb
new file mode 100644
index 000000000..41c874a62
--- /dev/null
+++ b/app/helpers/admin/pubsubhubbub_helper.rb
@@ -0,0 +1,2 @@
+module Admin::PubsubhubbubHelper
+end
diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb
index 52190adae..13faaa261 100644
--- a/app/helpers/atom_builder_helper.rb
+++ b/app/helpers/atom_builder_helper.rb
@@ -116,9 +116,9 @@ module AtomBuilderHelper
   end
 
   def link_avatar(xml, account)
-    single_link_avatar(xml, account, :large,  300)
-    single_link_avatar(xml, account, :medium, 96)
-    single_link_avatar(xml, account, :small,  48)
+    single_link_avatar(xml, account, :large, 300)
+    # single_link_avatar(xml, account, :medium, 96)
+    # single_link_avatar(xml, account, :small,  48)
   end
 
   def logo(xml, url)
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 75ee2f8d9..26c4cd58f 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -5,7 +5,9 @@ module SettingsHelper
     en: 'English',
     de: 'Deutsch',
     es: 'Español',
+    pt: 'Português',
     fr: 'Français',
+    hu: 'Magyar',
   }.freeze
 
   def human_locale(locale)
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index c8512476d..b812ad1f4 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -68,30 +68,34 @@ class FeedManager
   def filter_from_home?(status, receiver)
     should_filter = false
 
-    if status.reply? && !status.thread.account.nil?                      # Filter out if it's a reply
-      should_filter   = !receiver.following?(status.thread.account)      # and I'm not following the person it's a reply to
-      should_filter &&= !(receiver.id == status.thread.account_id)       # and it's not a reply to me
-      should_filter &&= !(status.account_id == status.thread.account_id) # and it's not a self-reply
-    elsif status.reblog?                                                 # Filter out a reblog
-      should_filter = receiver.blocking?(status.reblog.account)          # if I'm blocking the reblogged person
+    if status.reply? && !status.thread.account.nil?                         # Filter out if it's a reply
+      should_filter   = !receiver.following?(status.thread.account)         # and I'm not following the person it's a reply to
+      should_filter &&= !(receiver.id == status.thread.account_id)          # and it's not a reply to me
+      should_filter &&= !(status.account_id == status.thread.account_id)    # and it's not a self-reply
+    elsif status.reblog?                                                    # Filter out a reblog
+      should_filter = receiver.blocking?(status.reblog.account)             # if I'm blocking the reblogged person
     end
 
+    should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked
+
     should_filter
   end
 
   def filter_from_mentions?(status, receiver)
-    should_filter   = receiver.id == status.account_id            # Filter if I'm mentioning myself
-    should_filter ||= receiver.blocking?(status.account)          # or it's from someone I blocked
+    should_filter   = receiver.id == status.account_id                      # Filter if I'm mentioning myself
+    should_filter ||= receiver.blocking?(status.account)                    # or it's from someone I blocked
+    should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account)) # or if it mentions someone I blocked
 
-    if status.reply? && !status.thread.account.nil?               # or it's a reply
-      should_filter ||= receiver.blocking?(status.thread.account) # to a user I blocked
+    if status.reply? && !status.thread.account.nil?                         # or it's a reply
+      should_filter ||= receiver.blocking?(status.thread.account)           # to a user I blocked
     end
 
     should_filter
   end
 
   def filter_from_public?(status, receiver)
-    should_filter = receiver.blocking?(status.account)
+    should_filter   = receiver.blocking?(status.account)
+    should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account))
 
     if status.reply? && !status.thread.account.nil?
       should_filter ||= receiver.blocking?(status.thread.account)
diff --git a/app/models/account.rb b/app/models/account.rb
index 16d654195..0f3d0dda2 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -13,12 +13,12 @@ class Account < ApplicationRecord
   validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?'
 
   # Avatar upload
-  has_attached_file :avatar, styles: { large: '300x300#', medium: '96x96#', small: '48x48#' }
+  has_attached_file :avatar, styles: { large: '300x300#' }, convert_options: { all: '-strip' }
   validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :avatar, less_than: 2.megabytes
 
   # Header upload
-  has_attached_file :header, styles: { medium: '700x335#' }
+  has_attached_file :header, styles: { medium: '700x335#' }, convert_options: { all: '-strip' }
   validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :header, less_than: 2.megabytes
 
@@ -44,8 +44,12 @@ class Account < ApplicationRecord
   has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
   has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
 
+  # Media
   has_many :media_attachments, dependent: :destroy
 
+  # PuSH subscriptions
+  has_many :subscriptions, dependent: :destroy
+
   pg_search_scope :search_for, against: { username: 'A', domain: 'B' }, using: { tsearch: { prefix: true } }
 
   scope :remote, -> { where.not(domain: nil) }
@@ -66,12 +70,12 @@ class Account < ApplicationRecord
 
   def unfollow!(other_account)
     follow = active_relationships.find_by(target_account: other_account)
-    follow.destroy unless follow.nil?
+    follow&.destroy
   end
 
   def unblock!(other_account)
     block = block_relationships.find_by(target_account: other_account)
-    block.destroy unless block.nil?
+    block&.destroy
   end
 
   def following?(other_account)
@@ -116,7 +120,11 @@ class Account < ApplicationRecord
   end
 
   def avatar_remote_url=(url)
-    self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url
+    parsed_url = URI.parse(url)
+
+    return if !%w(http https).include?(parsed_url.scheme) || self[:avatar_remote_url] == url
+
+    self.avatar              = parsed_url
     self[:avatar_remote_url] = url
   rescue OpenURI::HTTPError => e
     Rails.logger.debug "Error fetching remote avatar: #{e}"
@@ -130,15 +138,6 @@ class Account < ApplicationRecord
     username
   end
 
-  def common_followers_with(other_account)
-    results  = Neography::Rest.new.execute_query('MATCH (a {account_id: {a_id}})-[:follows]->(b)-[:follows]->(c {account_id: {c_id}}) RETURN b.account_id', a_id: id, c_id: other_account.id)
-    ids      = results['data'].map(&:first)
-    accounts = Account.where(id: ids).with_counters.limit(20).map { |a| [a.id, a] }.to_h
-    ids.map { |id| accounts[id] }.compact
-  rescue Neography::NeographyError, Excon::Error::Socket
-    []
-  end
-
   class << self
     def find_local!(username)
       find_remote!(username, nil)
diff --git a/app/models/concerns/obfuscate_filename.rb b/app/models/concerns/obfuscate_filename.rb
new file mode 100644
index 000000000..dc25cdbc2
--- /dev/null
+++ b/app/models/concerns/obfuscate_filename.rb
@@ -0,0 +1,16 @@
+module ObfuscateFilename
+  extend ActiveSupport::Concern
+
+  class_methods do
+    def obfuscate_filename(*args)
+      before_action { obfuscate_filename(*args) }
+    end
+  end
+
+  def obfuscate_filename(path)
+    file = params.dig(*path)
+    return if file.nil?
+
+    file.original_filename = "media" + File.extname(file.original_filename)
+  end
+end
diff --git a/app/models/feed.rb b/app/models/feed.rb
index e7f2ab3a5..7b181d529 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -16,8 +16,8 @@ class Feed
       RegenerationWorker.perform_async(@account.id, @type)
       @statuses = Status.send("as_#{@type}_timeline", @account).paginate_by_max_id(limit, nil, nil)
     else
-      status_map = Status.where(id: unhydrated).with_includes.with_counters.map { |status| [status.id, status] }.to_h
-      @statuses = unhydrated.map { |id| status_map[id] }.compact
+      status_map = Status.where(id: unhydrated).map { |s| [s.id, s] }.to_h
+      @statuses  = unhydrated.map { |id| status_map[id] }.compact
     end
 
     @statuses
diff --git a/app/models/follow.rb b/app/models/follow.rb
index cc5bceb75..f83490caa 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -27,32 +27,4 @@ class Follow < ApplicationRecord
   def title
     destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}"
   end
-
-  after_create  :add_to_graph
-  after_destroy :remove_from_graph
-
-  def sync!
-    add_to_graph
-  end
-
-  private
-
-  def add_to_graph
-    neo = Neography::Rest.new
-
-    a = neo.create_unique_node('account_index', 'Account', account_id.to_s, account_id: account_id)
-    b = neo.create_unique_node('account_index', 'Account', target_account_id.to_s, account_id: target_account_id)
-
-    neo.create_unique_relationship('follow_index', 'Follow', id.to_s, 'follows', a, b)
-  rescue Neography::NeographyError, Excon::Error::Socket => e
-    Rails.logger.error e
-  end
-
-  def remove_from_graph
-    neo = Neography::Rest.new
-    rel = neo.get_relationship_index('follow_index', 'Follow', id.to_s)
-    neo.delete_relationship(rel)
-  rescue Neography::NeographyError, Excon::Error::Socket => e
-    Rails.logger.error e
-  end
 end
diff --git a/app/models/follow_suggestion.rb b/app/models/follow_suggestion.rb
deleted file mode 100644
index 2daa40dcb..000000000
--- a/app/models/follow_suggestion.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-class FollowSuggestion
-  class << self
-    def get(for_account_id, limit = 10)
-      neo = Neography::Rest.new
-
-      query = <<END
-MATCH (a {account_id: {id}})-[:follows]->(b)-[:follows]->(c)
-WHERE a <> c
-AND NOT (a)-[:follows]->(c)
-RETURN DISTINCT c.account_id, count(b), c.nodeRank
-ORDER BY count(b) DESC, c.nodeRank DESC
-LIMIT {limit}
-END
-
-      results = neo.execute_query(query, id: for_account_id, limit: limit)
-
-      if results.empty? || results['data'].empty?
-        results = fallback(for_account_id, limit)
-      elsif results['data'].size < limit
-        results['data'] = (results['data'] + fallback(for_account_id, limit - results['data'].size)['data']).uniq
-      end
-
-      account_ids  = results['data'].map(&:first)
-      blocked_ids  = Block.where(account_id: for_account_id).pluck(:target_account_id)
-      accounts_map = Account.where(id: account_ids - blocked_ids).with_counters.map { |a| [a.id, a] }.to_h
-
-      account_ids.map { |id| accounts_map[id] }.compact
-    rescue Neography::NeographyError, Excon::Error::Socket => e
-      Rails.logger.error e
-      return []
-    end
-
-    private
-
-    def fallback(for_account_id, limit)
-      neo = Neography::Rest.new
-
-      query = <<END
-MATCH (b)
-RETURN b.account_id
-ORDER BY b.nodeRank DESC
-LIMIT {limit}
-END
-
-      neo.execute_query(query, id: for_account_id, limit: limit)
-    end
-  end
-end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index bfbf00d76..f1b9b8112 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -16,6 +16,8 @@ class MediaAttachment < ApplicationRecord
 
   validates :account, presence: true
 
+  default_scope { order('id asc') }
+
   def local?
     remote_url.blank?
   end
diff --git a/app/models/status.rb b/app/models/status.rb
index 1bf4b49c9..f9dcd97e4 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -4,7 +4,7 @@ class Status < ApplicationRecord
   include Paginable
   include Streamable
 
-  belongs_to :account, -> { with_counters }, inverse_of: :statuses
+  belongs_to :account, inverse_of: :statuses
 
   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
   belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, touch: true
@@ -27,7 +27,7 @@ class Status < ApplicationRecord
   default_scope { order('id desc') }
 
   scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
-  scope :with_includes, -> { includes(:account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
+  scope :with_includes, -> { includes(:account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account) }
 
   def local?
     uri.nil?
@@ -71,7 +71,7 @@ class Status < ApplicationRecord
 
   def ancestors(account = nil)
     ids      = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, in_reply_to_id, path) AS (SELECT id, in_reply_to_id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, statuses.in_reply_to_id, path || statuses.id FROM search_tree JOIN statuses ON statuses.id = search_tree.in_reply_to_id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path DESC', id]) - [self]).pluck(:id)
-    statuses = Status.where(id: ids).with_counters.with_includes.group_by(&:id)
+    statuses = Status.where(id: ids).with_includes.group_by(&:id)
     results  = ids.map { |id| statuses[id].first }
     results  = results.reject { |status| account.blocking?(status.account) } unless account.nil?
 
@@ -80,7 +80,7 @@ class Status < ApplicationRecord
 
   def descendants(account = nil)
     ids      = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, path) AS (SELECT id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, path || statuses.id FROM search_tree JOIN statuses ON statuses.in_reply_to_id = search_tree.id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path', id]) - [self]).pluck(:id)
-    statuses = Status.where(id: ids).with_counters.with_includes.group_by(&:id)
+    statuses = Status.where(id: ids).with_includes.group_by(&:id)
     results  = ids.map { |id| statuses[id].first }
     results  = results.reject { |status| account.blocking?(status.account) } unless account.nil?
 
@@ -89,28 +89,30 @@ class Status < ApplicationRecord
 
   class << self
     def as_home_timeline(account)
-      where(account: [account] + account.following).with_includes.with_counters
+      where(account: [account] + account.following).with_includes
     end
 
     def as_mentions_timeline(account)
-      where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
+      where(id: Mention.where(account: account).pluck(:status_id)).with_includes
     end
 
     def as_public_timeline(account = nil)
-      query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id').where('accounts.silenced = FALSE')
+      query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
+              .where('accounts.silenced = FALSE')
+              .where('statuses.in_reply_to_id IS NULL')
+              .where('statuses.reblog_of_id IS NULL')
       query = filter_timeline(query, account) unless account.nil?
-
-      query.with_includes.with_counters
+      query
     end
 
     def as_tag_timeline(tag, account = nil)
       query = tag.statuses
                  .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
                  .where('accounts.silenced = FALSE')
-
+                 .where('statuses.in_reply_to_id IS NULL')
+                 .where('statuses.reblog_of_id IS NULL')
       query = filter_timeline(query, account) unless account.nil?
-
-      query.with_includes.with_counters
+      query
     end
 
     def favourites_map(status_ids, account_id)
@@ -126,13 +128,7 @@ class Status < ApplicationRecord
     def filter_timeline(query, account)
       blocked = Block.where(account: account).pluck(:target_account_id)
       return query if blocked.empty?
-
-      query
-        .joins('LEFT OUTER JOIN statuses AS parents ON statuses.in_reply_to_id = parents.id')
-        .joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id')
-        .where('statuses.account_id NOT IN (?)', blocked)
-        .where('(parents.id IS NULL OR parents.account_id NOT IN (?))', blocked)
-        .where('(reblogs.id IS NULL OR reblogs.account_id NOT IN (?))', blocked)
+      query.where('statuses.account_id NOT IN (?)', blocked)
     end
   end
 
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
new file mode 100644
index 000000000..497cabb09
--- /dev/null
+++ b/app/models/subscription.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class Subscription < ApplicationRecord
+  MIN_EXPIRATION = 3600 * 24 * 7
+  MAX_EXPIRATION = 3600 * 24 * 30
+
+  belongs_to :account
+
+  validates :callback_url, presence: true
+  validates :callback_url, uniqueness: { scope: :account_id }
+
+  scope :active, -> { where(confirmed: true).where('expires_at > ?', Time.now.utc) }
+
+  def lease_seconds=(str)
+    self.expires_at = Time.now.utc + [[MIN_EXPIRATION, str.to_i].max, MAX_EXPIRATION].min.seconds
+  end
+
+  def lease_seconds
+    (expires_at - Time.now.utc).to_i
+  end
+
+  before_validation :set_min_expiration
+
+  private
+
+  def set_min_expiration
+    self.lease_seconds = 0 unless expires_at
+  end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 7e3547dff..423833d47 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -14,7 +14,8 @@ class User < ApplicationRecord
   scope :admins,   -> { where(admin: true) }
 
   has_settings do |s|
-    s.key :notification_emails, defaults: { follow: true, reblog: true, favourite: true, mention: true }
+    s.key :notification_emails, defaults: { follow: false, reblog: false, favourite: false, mention: false }
+    s.key :interactions, defaults: { must_be_follower: false, must_be_following: false }
   end
 
   def send_devise_notification(notification, *args)
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index 388a592e0..6a032a5a1 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -6,19 +6,27 @@ class BlockService < BaseService
 
     UnfollowService.new.call(account, target_account) if account.following?(target_account)
     account.block!(target_account)
-    clear_mentions(account, target_account)
+    clear_timelines(account, target_account)
+    clear_notifications(account, target_account)
   end
 
   private
 
-  def clear_mentions(account, target_account)
-    timeline_key = FeedManager.instance.key(:mentions, account.id)
+  def clear_timelines(account, target_account)
+    mentions_key = FeedManager.instance.key(:mentions, account.id)
+    home_key     = FeedManager.instance.key(:home, account.id)
 
     target_account.statuses.select('id').find_each do |status|
-      redis.zrem(timeline_key, status.id)
+      redis.zrem(mentions_key, status.id)
+      redis.zrem(home_key, status.id)
     end
+  end
 
-    FeedManager.instance.broadcast(account.id, type: 'block', id: target_account.id)
+  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
   end
 
   def redis
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 78cb0b13f..40d8a0fee 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -24,7 +24,7 @@ class FanOutOnWriteService < BaseService
   def deliver_to_followers(status)
     Rails.logger.debug "Delivering status #{status.id} to followers"
 
-    status.account.followers.where(domain: nil).find_each do |follower|
+    status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).find_each do |follower|
       next if FeedManager.instance.filter?(:home, status, follower)
       FeedManager.instance.push(:home, follower, status)
     end
@@ -41,14 +41,17 @@ class FanOutOnWriteService < BaseService
   end
 
   def deliver_to_hashtags(status)
-    Rails.logger.debug "Delivering status #{status.id} to hashtags"
+    return if status.reblog? || status.reply?
 
+    Rails.logger.debug "Delivering status #{status.id} to hashtags"
     status.tags.find_each do |tag|
       FeedManager.instance.broadcast("hashtag:#{tag.name}", type: 'update', id: status.id)
     end
   end
 
   def deliver_to_public(status)
+    return if status.reblog? || status.reply?
+
     Rails.logger.debug "Delivering status #{status.id} to public timeline"
     FeedManager.instance.broadcast(:public, type: 'update', id: status.id)
   end
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 781b03b40..2f280e03f 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -7,7 +7,9 @@ class FavouriteService < BaseService
   # @return [Favourite]
   def call(account, status)
     favourite = Favourite.create!(account: account, status: status)
+
     HubPingWorker.perform_async(account.id)
+    Pubsubhubbub::DistributionWorker.perform_async(favourite.stream_entry.id)
 
     if status.local?
       NotifyService.new.call(status.account, favourite)
diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb
index 37339d8ed..f640222b0 100644
--- a/app/services/follow_remote_account_service.rb
+++ b/app/services/follow_remote_account_service.rb
@@ -80,8 +80,7 @@ class FollowRemoteAccountService < BaseService
   end
 
   def get_profile(xml, account)
-    author = xml.at_xpath('/xmlns:feed/xmlns:author') || xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
-    update_remote_profile_service.call(author, account)
+    update_remote_profile_service.call(xml.at_xpath('/xmlns:feed'), account)
   end
 
   def update_remote_profile_service
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index cdae254bf..09fa295e3 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -19,7 +19,10 @@ class FollowService < BaseService
     end
 
     merge_into_timeline(target_account, source_account)
+
     HubPingWorker.perform_async(source_account.id)
+    Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id)
+
     follow
   end
 
@@ -33,7 +36,6 @@ class FollowService < BaseService
     end
 
     FeedManager.instance.trim(:home, into_account.id)
-    FeedManager.instance.broadcast(into_account.id, type: 'merge')
   end
 
   def redis
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 772adfb90..1efd326b0 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -36,6 +36,8 @@ class NotifyService < BaseService
     blocked   = false
     blocked ||= @recipient.id == @notification.from_account.id
     blocked ||= @recipient.blocking?(@notification.from_account)
+    blocked ||= (@recipient.user.settings(:interactions).must_be_follower  && !@notification.from_account.following?(@recipient))
+    blocked ||= (@recipient.user.settings(:interactions).must_be_following && !@recipient.following?(@notification.from_account))
     blocked ||= send("blocked_#{@notification.type}?")
     blocked
   end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index cf824ff99..979a157e9 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -5,15 +5,20 @@ class PostStatusService < BaseService
   # @param [Account] account Account from which to post
   # @param [String] text Message
   # @param [Status] in_reply_to Optional status to reply to
-  # @param [Enumerable] media_ids Optional array of media IDs to attach
+  # @param [Hash] options
+  # @option [Boolean] :sensitive
+  # @option [Enumerable] :media_ids Optional array of media IDs to attach
   # @return [Status]
-  def call(account, text, in_reply_to = nil, media_ids = nil)
-    status = account.statuses.create!(text: text, thread: in_reply_to)
-    attach_media(status, media_ids)
+  def call(account, text, in_reply_to = nil, options = {})
+    status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive])
+    attach_media(status, options[:media_ids])
     process_mentions_service.call(status)
     process_hashtags_service.call(status)
+
     DistributionWorker.perform_async(status.id)
     HubPingWorker.perform_async(account.id)
+    Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+
     status
   end
 
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 1cd801b80..a7a4cb2b0 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -16,7 +16,7 @@ class ProcessFeedService < BaseService
 
   def update_author(xml, account)
     return if xml.at_xpath('/xmlns:feed').nil?
-    UpdateRemoteProfileService.new.call(xml.at_xpath('/xmlns:feed/xmlns:author'), account)
+    UpdateRemoteProfileService.new.call(xml.at_xpath('/xmlns:feed'), account, true)
   end
 
   def process_entries(xml, account)
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index 3bf3471ec..fa14c44da 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -4,7 +4,7 @@ class ProcessHashtagsService < BaseService
   def call(status, tags = [])
     tags = status.text.scan(Tag::HASHTAG_RE).map(&:first) if status.local?
 
-    tags.map(&:downcase).uniq.each do |tag|
+    tags.map { |str| str.mb_chars.downcase }.uniq.each do |tag|
       status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
     end
   end
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index e7bb3c73b..6b2f6e2d2 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -26,7 +26,7 @@ class ProcessInteractionService < BaseService
     end
 
     if salmon.verify(envelope, account.keypair)
-      update_remote_profile_service.call(xml.at_xpath('/xmlns:entry/xmlns:author'), account)
+      update_remote_profile_service.call(xml.at_xpath('/xmlns:entry'), account, true)
 
       case verb(xml)
       when :follow
@@ -74,7 +74,7 @@ class ProcessInteractionService < BaseService
   end
 
   def delete_post!(xml, account)
-    status = Status.find(activity_id(xml))
+    status = Status.find(xml.at_xpath('//xmlns:id').content)
 
     return if status.nil?
 
diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb
new file mode 100644
index 000000000..343376d77
--- /dev/null
+++ b/app/services/pubsubhubbub/subscribe_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::SubscribeService < BaseService
+  def call(account, callback, secret, lease_seconds)
+    return ['Invalid topic URL', 422] if account.nil?
+    return ['Invalid callback URL', 422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/
+
+    subscription = Subscription.where(account: account, callback_url: callback).first_or_create!(account: account, callback_url: callback)
+    Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'subscribe', secret, lease_seconds)
+
+    ['', 202]
+  end
+end
diff --git a/app/services/pubsubhubbub/unsubscribe_service.rb b/app/services/pubsubhubbub/unsubscribe_service.rb
new file mode 100644
index 000000000..62459a0aa
--- /dev/null
+++ b/app/services/pubsubhubbub/unsubscribe_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::UnsubscribeService < BaseService
+  def call(account, callback)
+    return ['Invalid topic URL', 422] if account.nil?
+
+    subscription = Subscription.where(account: account, callback_url: callback)
+
+    unless subscription.nil?
+      Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'unsubscribe')
+    end
+
+    ['', 202]
+  end
+end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 6543d4ae7..39fdb4ea7 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -7,8 +7,10 @@ class ReblogService < BaseService
   # @return [Status]
   def call(account, reblogged_status)
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
+
     DistributionWorker.perform_async(reblog.id)
     HubPingWorker.perform_async(account.id)
+    Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
 
     if reblogged_status.local?
       NotifyService.new.call(reblogged_status.account, reblog)
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 689abc97b..4e03661da 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -10,6 +10,11 @@ class RemoveStatusService < BaseService
     remove_from_public(status)
 
     status.destroy!
+
+    if status.account.local?
+      HubPingWorker.perform_async(status.account.id)
+      Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+    end
   end
 
   private
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index c4cffda13..1ae1d5a80 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -2,9 +2,9 @@
 
 class SearchService < BaseService
   def call(query, limit, resolve = false)
-    return if query.blank?
+    return if query.blank? || query.start_with?('#')
 
-    username, domain = query.split('@')
+    username, domain = query.gsub(/\A@/, '').split('@')
 
     results = if domain.nil?
                 Account.search_for(username)
@@ -12,7 +12,7 @@ class SearchService < BaseService
                 Account.search_for("#{username} #{domain}")
               end
 
-    results = results.limit(limit).with_counters
+    results = results.limit(limit)
 
     if resolve && results.empty? && !domain.nil?
       results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")]
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index b3386a99c..7973a3611 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -17,9 +17,8 @@ class UnfollowService < BaseService
 
     from_account.statuses.select('id').find_each do |status|
       redis.zrem(timeline_key, status.id)
+      redis.zremrangebyscore(timeline_key, status.id, status.id)
     end
-
-    FeedManager.instance.broadcast(into_account.id, type: 'unmerge')
   end
 
   def redis
diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb
index 2909ae12a..56b25816f 100644
--- a/app/services/update_remote_profile_service.rb
+++ b/app/services/update_remote_profile_service.rb
@@ -2,24 +2,24 @@
 
 class UpdateRemoteProfileService < BaseService
   POCO_NS = 'http://portablecontacts.net/spec/1.0'
+  DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
 
-  def call(author_xml, account)
-    return if author_xml.nil?
+  def call(xml, account, resubscribe = false)
+    return if xml.nil?
 
-    account.display_name = if author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil?
-                             account.username
-                           else
-                             author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content
-                           end
+    author_xml = xml.at_xpath('./xmlns:author') || xml.at_xpath('./dfrn:owner', dfrn: DFRN_NS)
+    hub_link   = xml.at_xpath('./xmlns:link[@rel="hub"]')
 
-    unless author_xml.at_xpath('./poco:note').nil?
-      account.note = author_xml.at_xpath('./poco:note', poco: POCO_NS).content
-    end
-
-    unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil?
-      account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]').attribute('href').value
+    unless author_xml.nil?
+      account.display_name      = author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content unless author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil?
+      account.note              = author_xml.at_xpath('./poco:note', poco: POCO_NS).content unless author_xml.at_xpath('./poco:note').nil?
+      account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]')['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]')['href'].blank?
     end
 
+    old_hub_url     = account.hub_url
+    account.hub_url = hub_link['href'] if !hub_link.nil? && !hub_link['href'].blank? && (hub_link['href'] != old_hub_url)
     account.save!
+
+    SubscribeService.new.call(account) if resubscribe && (account.hub_url != old_hub_url)
   end
 end
diff --git a/app/views/accounts/show.atom.ruby b/app/views/accounts/show.atom.ruby
index d7b2201d4..558c777f0 100644
--- a/app/views/accounts/show.atom.ruby
+++ b/app/views/accounts/show.atom.ruby
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
 Nokogiri::XML::Builder.new do |xml|
   feed(xml) do
     simple_id  xml, account_url(@account, format: 'atom')
@@ -12,6 +14,7 @@ Nokogiri::XML::Builder.new do |xml|
 
     link_alternate xml, TagManager.instance.url_for(@account)
     link_self      xml, account_url(@account, format: 'atom')
+    link_hub       xml, api_push_url
     link_hub       xml, Rails.configuration.x.hub_url
     link_salmon    xml, api_salmon_url(@account.id)
 
diff --git a/app/views/admin/pubsubhubbub/index.html.haml b/app/views/admin/pubsubhubbub/index.html.haml
new file mode 100644
index 000000000..bb897eb89
--- /dev/null
+++ b/app/views/admin/pubsubhubbub/index.html.haml
@@ -0,0 +1,20 @@
+%table.table
+  %thead
+    %tr
+      %th Topic
+      %th Callback URL
+      %th Confirmed
+      %th Expires in
+  %tbody
+    - @subscriptions.each do |subscription|
+      %tr
+        %td
+          %samp= subscription.account.acct
+        %td
+          %samp= subscription.callback_url
+        %td
+          - if subscription.confirmed?
+            %i.fa.fa-check
+        %td= distance_of_time_in_words(Time.now, subscription.expires_at)
+
+= will_paginate @subscriptions, pagination_options
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index 90457eca9..579c47b26 100644
--- a/app/views/api/v1/statuses/_show.rabl
+++ b/app/views/api/v1/statuses/_show.rabl
@@ -1,4 +1,4 @@
-attributes :id, :created_at, :in_reply_to_id
+attributes :id, :created_at, :in_reply_to_id, :sensitive
 
 node(:uri)              { |status| TagManager.instance.uri_for(status) }
 node(:content)          { |status| Formatter.instance.format(status) }
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 693702ff7..db5b9fb48 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -12,6 +12,10 @@
     = ff.input :favourite, as: :boolean, wrapper: :with_label
     = ff.input :mention, as: :boolean, wrapper: :with_label
 
+  = f.simple_fields_for :interactions, current_user.settings(:interactions) do |ff|
+    = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
+    = ff.input :must_be_following, as: :boolean, wrapper: :with_label
+
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
 
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index b16258679..2c6de32d9 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -1,5 +1,8 @@
 - content_for :header_tags do
   %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/
+  %meta{ name: 'og:site_name', content: 'Mastodon' }/
+  %meta{ name: 'og:type', content: 'article' }/
+  %meta{ name: 'og:article:author', content: @account.username }/
 
 .activity-stream.activity-stream-headless
   = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true }
diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb
index 3b11a4c5e..b31cd0aaf 100644
--- a/app/workers/processing_worker.rb
+++ b/app/workers/processing_worker.rb
@@ -2,6 +2,8 @@
 
 class ProcessingWorker
   include Sidekiq::Worker
+  
+  sidekiq_options backtrace: true
 
   def perform(account_id, body)
     ProcessFeedService.new.call(body, Account.find(account_id))
diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb
new file mode 100644
index 000000000..489bd8359
--- /dev/null
+++ b/app/workers/pubsubhubbub/confirmation_worker.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::ConfirmationWorker
+  include Sidekiq::Worker
+  include RoutingHelper
+
+  sidekiq_options queue: 'push'
+
+  def perform(subscription_id, mode, secret = nil, lease_seconds = nil)
+    subscription = Subscription.find(subscription_id)
+    challenge    = SecureRandom.hex
+
+    subscription.secret        = secret
+    subscription.lease_seconds = lease_seconds
+    subscription.confirmed     = true
+
+    response = HTTP.headers(user_agent: 'Mastodon/PubSubHubbub')
+                   .timeout(:per_operation, write: 20, connect: 20, read: 50)
+                   .get(subscription.callback_url, params: {
+                          'hub.topic' => account_url(subscription.account, format: :atom),
+                          'hub.mode'          => mode,
+                          'hub.challenge'     => challenge,
+                          'hub.lease_seconds' => subscription.lease_seconds,
+                        })
+
+    body = response.body.to_s
+
+    Rails.logger.debug "Confirming PuSH subscription for #{subscription.callback_url} with challenge #{challenge}: #{body}"
+
+    if mode == 'subscribe' && body == challenge
+      subscription.save!
+    elsif (mode == 'unsubscribe' && body == challenge) || !subscription.confirmed?
+      subscription.destroy!
+    end
+  end
+end
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
new file mode 100644
index 000000000..6d526c2b1
--- /dev/null
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::DeliveryWorker
+  include Sidekiq::Worker
+  include RoutingHelper
+
+  sidekiq_options queue: 'push'
+
+  def perform(subscription_id, payload)
+    subscription = Subscription.find(subscription_id)
+    headers      = {}
+
+    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
+    headers['X-Hub-Signature'] = signature(subscription.secret, payload) unless subscription.secret.blank?
+
+    response = HTTP.timeout(:per_operation, write: 50, connect: 20, read: 50)
+                   .headers(headers)
+                   .post(subscription.callback_url, body: payload)
+
+    raise "Delivery failed for #{subscription.callback_url}: HTTP #{response.code}" unless response.code > 199 && response.code < 300
+  end
+
+  private
+
+  def signature(secret, payload)
+    hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), secret, payload)
+    "sha1=#{hmac}"
+  end
+end
diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb
new file mode 100644
index 000000000..b0ddc71c1
--- /dev/null
+++ b/app/workers/pubsubhubbub/distribution_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Pubsubhubbub::DistributionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push'
+
+  def perform(stream_entry_id)
+    stream_entry = StreamEntry.find(stream_entry_id)
+    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])
+
+    Subscription.where(account: account).active.select('id').find_each do |subscription|
+      Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload)
+    end
+  end
+end
diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb
new file mode 100644
index 000000000..7470c54f5
--- /dev/null
+++ b/app/workers/removal_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class RemovalWorker
+  include Sidekiq::Worker
+
+  def perform(status_id)
+    RemoveStatusService.new.call(Status.find(status_id))
+  end
+end
\ No newline at end of file
diff --git a/app/workers/salmon_worker.rb b/app/workers/salmon_worker.rb
index 24fb94012..0903ca487 100644
--- a/app/workers/salmon_worker.rb
+++ b/app/workers/salmon_worker.rb
@@ -2,6 +2,8 @@
 
 class SalmonWorker
   include Sidekiq::Worker
+  
+  sidekiq_options backtrace: true
 
   def perform(account_id, body)
     ProcessInteractionService.new.call(body, Account.find(account_id))
diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb
index 700161989..84eae73be 100644
--- a/app/workers/thread_resolve_worker.rb
+++ b/app/workers/thread_resolve_worker.rb
@@ -7,9 +7,9 @@ class ThreadResolveWorker
     child_status  = Status.find(child_status_id)
     parent_status = FetchRemoteStatusService.new.call(parent_url)
 
-    unless parent_status.nil?
-      child_status.thread = parent_status
-      child_status.save!
-    end
+    return if parent_status.nil?
+
+    child_status.thread = parent_status
+    child_status.save!
   end
 end
diff --git a/config/application.rb b/config/application.rb
index 7ba13bfbe..1e5fd9c7c 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -20,7 +20,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, :fr]
+    config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu]
     config.i18n.default_locale    = :en
 
     # config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 7f13fcf6b..9254d494c 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -13,7 +13,7 @@ Rails.application.configure do
   # Full error reports are disabled and caching is turned on.
   config.consider_all_requests_local       = false
   config.action_controller.perform_caching = true
-  config.action_controller.asset_host      = ENV['CDN_HOST']
+  config.action_controller.asset_host      = ENV['CDN_HOST'] if ENV.key?('CDN_HOST')
 
   # Disable serving static files from the `/public` folder by default since
   # Apache or NGINX already handles this.
@@ -30,7 +30,7 @@ Rails.application.configure do
 
   # Specifies the header that your server uses for sending files.
   # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
-  # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
+  config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
 
   # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
   config.force_ssl = false
@@ -50,7 +50,8 @@ Rails.application.configure do
     host: ENV.fetch('REDIS_HOST') { 'localhost' },
     port: ENV.fetch('REDIS_PORT') { 6379 },
     db: 0,
-    namespace: 'cache'
+    namespace: 'cache',
+    expires_in: 20.minutes
   }
 
   # Enable serving of images, stylesheets, and JavaScripts from an asset server.
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index d345ce6c0..4dc6985b7 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -30,9 +30,6 @@ search:
     - app/assets/fonts
     - app/assets/videos
 
-ignore_missing:
-  - '{devise,simple_form}.*'
-
 ignore_unused:
   - 'activerecord.attributes.*'
   - '{devise,will_paginate,doorkeeper}.*'
diff --git a/config/initializers/neography.rb b/config/initializers/neography.rb
deleted file mode 100644
index bd6ead3b0..000000000
--- a/config/initializers/neography.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-Neography.configure do |config|
-  config.protocol             = "http"
-  config.server               = ENV.fetch('NEO4J_HOST') { 'localhost' }
-  config.port                 = ENV.fetch('NEO4J_PORT') { 7474 }
-end
diff --git a/config/initializers/ostatus.rb b/config/initializers/ostatus.rb
index 3dd501b65..4ba432b6a 100644
--- a/config/initializers/ostatus.rb
+++ b/config/initializers/ostatus.rb
@@ -1,14 +1,16 @@
-port = ENV.fetch('PORT') { 3000 }
-
+port  = ENV.fetch('PORT') { 3000 }
+host  = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" }
+https = ENV['LOCAL_HTTPS'] == 'true'
+  
 Rails.application.configure do
-  config.x.local_domain = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" }
-  config.x.hub_url      = ENV.fetch('HUB_URL')      { 'https://pubsubhubbub.superfeedr.com' }
-  config.x.use_https    = ENV['LOCAL_HTTPS'] == 'true'
+  config.x.local_domain = host
+  config.x.hub_url      = ENV.fetch('HUB_URL') { 'https://pubsubhubbub.superfeedr.com' }
+  config.x.use_https    = https
   config.x.use_s3       = ENV['S3_ENABLED'] == 'true'
 
-  config.action_mailer.default_url_options = { host: config.x.local_domain, protocol: config.x.use_https ? 'https://' : 'http://', trailing_slash: false }
+  config.action_mailer.default_url_options = { host: host, protocol: https ? 'https://' : 'http://', trailing_slash: false }
 
   if Rails.env.production?
-    config.action_cable.allowed_request_origins = ["http#{config.x.use_https ? 's' : ''}://#{config.x.local_domain}"]
+    config.action_cable.allowed_request_origins = ["http#{https ? 's' : ''}://#{host}"]
   end
 end
diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb
index 643c5d384..80effc05e 100644
--- a/config/initializers/paperclip.rb
+++ b/config/initializers/paperclip.rb
@@ -1,4 +1,6 @@
 if ENV['S3_ENABLED'] == 'true'
+  Aws.eager_autoload!(services: %w(S3))
+
   Paperclip::Attachment.default_options[:storage]      = :s3
   Paperclip::Attachment.default_options[:s3_protocol]  = 'https'
   Paperclip::Attachment.default_options[:url]          = ':s3_domain_url'
@@ -9,6 +11,6 @@ if ENV['S3_ENABLED'] == 'true'
     bucket: ENV.fetch('S3_BUCKET'),
     access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'),
     secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY'),
-    s3_region: ENV.fetch('S3_REGION')
+    s3_region: ENV.fetch('S3_REGION'),
   }
 end
diff --git a/config/initializers/rack-attack.rb b/config/initializers/rack-attack.rb
index 6d9286e66..3f0ee1d7a 100644
--- a/config/initializers/rack-attack.rb
+++ b/config/initializers/rack-attack.rb
@@ -1,7 +1,7 @@
 class Rack::Attack
   # Rate limits for the API
   throttle('api', limit: 150, period: 5.minutes) do |req|
-    req.ip if req.path.match(/\A\/api\//)
+    req.ip if req.path.match(/\A\/api\/v/)
   end
 
   self.throttled_response = lambda do |env|
@@ -11,7 +11,7 @@ class Rack::Attack
     headers = {
       'X-RateLimit-Limit'     => match_data[:limit].to_s,
       'X-RateLimit-Remaining' => '0',
-      'X-RateLimit-Reset'     => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
+      'X-RateLimit-Reset'     => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6)
     }
 
     [429, headers, [{ error: 'Throttled' }.to_json]]
diff --git a/config/initializers/timeout.rb b/config/initializers/timeout.rb
new file mode 100644
index 000000000..8b7311e39
--- /dev/null
+++ b/config/initializers/timeout.rb
@@ -0,0 +1 @@
+Rack::Timeout.timeout = 30
\ No newline at end of file
diff --git a/config/locales/devise.hu.yml b/config/locales/devise.hu.yml
new file mode 100644
index 000000000..2eb7da45c
--- /dev/null
+++ b/config/locales/devise.hu.yml
@@ -0,0 +1,61 @@
+---
+hu:
+  devise:
+    confirmations:
+      confirmed: Az e-mail címed sikeresen meg lett erősítve.
+      send_instructions: Pár percen belül kapni fogsz egy e-mailt az e-mail címed megerősítéséhez szükséges lépésekről.
+      send_paranoid_instructions: Ha az e-mail címed létezik az adatbázisunkban, pár percen belül kapni fogsz egy e-mailt az e-mail címed megerősítéséhez szükséges lépésekről.
+    failure:
+      already_authenticated: Már bejelentkeztél
+      inactive: Fiókod még nem lett aktiválva.
+      invalid: Helytelen %{authentication_keys} vagy jelszó.
+      last_attempt: Már csak egy próbálkozásod maradt mielőtt a fiókod lezárásra kerül.
+      locked: Fiókod le van zárva.
+      not_found_in_database: Helytelen %{authentication_keys} vagy jelszó.
+      timeout: A munkamenet lejárt. Jelentkezz be újra a folytatáshoz.
+      unauthenticated: A folytatás előtt be kell jelentkezned.
+      unconfirmed: A folytatás előtt meg kell erősítened az e-mail címed.
+    mailer:
+      confirmation_instructions:
+        subject: 'Mastodon: Megerősítési lépések'
+      password_change:
+        subject: 'Mastodon: Jelszó megváltoztatva'
+      reset_password_instructions:
+        subject: 'Mastodon: Jelszó visszaállítási lépések'
+      unlock_instructions:
+        subject: 'Mastodon: Feloldási lépések'
+    omniauth_callbacks:
+      failure: "%{kind} nem hitelesíthető, mert %{reason}."
+      success: Sikeres hitelesítés %{kind} fiókról.
+    passwords:
+      no_token: Nem férhetsz hozzá az oldalhoz jelszó visszaállító e-mail nélkül. Ha egy jelszó visszaállító e-mail hozott ide, ellenőrizd, hogy a megadott teljes URL-t használd. 
+      send_instructions: Pár percen belül kapni fogsz egy e-mailt arról, hogy hogyan tudod visszaállítani a jelszavadat.
+      send_paranoid_instructions: Ha létezik az e-mail cím, pár percen belül kapni fogsz egy e-mailt arról, hogy hogyan tudod visszaállítani a jelszavadat.
+      updated: Jelszavad sikeresen frissült. Bejelentkeztél.
+      updated_not_active: Jelszavad sikeresen meg lett változtatva.
+    registrations:
+      destroyed: Viszlát! A fiókod sikeresen törölve. Reméljük hamarosan viszontláthatunk.
+      signed_up: Üdvözlünk! Sikeresen regisztráltál.
+      signed_up_but_inactive: Sikeresen regisztráltál. Ennek ellenére nem tudunk beléptetni, ugyanis a fiókod még nem lett aktiválva. 
+      signed_up_but_locked: Sikeresen regisztráltál. Ennek ellenére nem tudunk beléptetni, ugyanis a fiókod le lett zárva.
+      signed_up_but_unconfirmed: Egy üzenet a megerősítési linkkel kiküldésre került az e-mail címedre. Kérjük használd a linket a fiókod aktiválásához.
+      update_needs_confirmation: Sikeresen frissítetted a fiókodat, de szükségünk van az e-mail címed megerősítésére. Kérlek ellenőrizd az e-mailedet és kövesd a levélben szereplő megerősítési linket az e-mail címed megerősítéséhez.
+      updated: Fiókod frissítése sikeres.
+    sessions:
+      already_signed_out: Sikeres kijelenkezés.
+      signed_in: Sikeres bejelentkezés.
+      signed_out: Sikeres kijelentkezés.
+    unlocks:
+      send_instructions: Pár percen belül egy e-mailt fogsz kapni a feloldáshoz szükséges lépésekkel.
+      send_paranoid_instructions: Ha a fiókod létezik, pár percen belül egy e-mailt fogsz kapni a feloldáshoz szükséges lépésekkel.
+      unlocked: A fiókod sikeresen fel lett oldva. Jelentkezz be a folytatáshoz.
+  errors:
+    messages:
+      already_confirmed: már meg lett erősítve, kérjük jelentkezz be
+      confirmation_period_expired: "%{period} belül kellett megerősíteni, kérjük igényelj újat"
+      expired: lejárt, kérjük igényelj újat
+      not_found: nem található
+      not_locked: nincs lezárva
+      not_saved:
+        one: '1 hiba megakadályozta %{resource} mentését:'
+        other: "%{count} számú hiba megakadályozta %{resource} mentését:"
diff --git a/config/locales/doorkeeper.fr.yml b/config/locales/doorkeeper.fr.yml
index c04e378b6..6f3c0864a 100644
--- a/config/locales/doorkeeper.fr.yml
+++ b/config/locales/doorkeeper.fr.yml
@@ -31,7 +31,7 @@ fr:
       help:
         native_redirect_uri: Utiliser %{native_redirect_uri} pour les tests locaux
         redirect_uri: Utiliser une ligne par URL
-        scopes:
+        scopes: Séparer les portées avec des espaces. Laisser vide pour utiliser les portées par défaut.
       index:
         callback_url: URL de retour d'appel
         name: Nom
@@ -43,7 +43,7 @@ fr:
         actions: Actions
         application_id: ID de l'application
         callback_urls: URL du retour d'appel
-        scopes:
+        scopes: Portées
         secret: Secret
         title: 'Application : %{name}'
     authorizations:
@@ -77,7 +77,7 @@ fr:
         invalid_redirect_uri: L'URL de redirection n'est pas valide.
         invalid_request: La demande manque un paramètre requis, inclut une valeur de paramètre non prise en charge, ou est autrement mal formée.
         invalid_resource_owner: Les identifiants fournis du propriétaire de la ressource ne sont pas valides, ou le propriétaire de la ressource ne peut être trouvé
-        invalid_scope: Le scope demandé n'est pas valide, est inconnu, ou est mal formé.
+        invalid_scope: La portée demandée n'est pas valide, est inconnue, ou est mal formée.
         invalid_token:
           expired: Le jeton d'accès a expiré
           revoked: Le jeton d'accès a été annulé
@@ -107,6 +107,6 @@ fr:
       application:
         title: Autorisation OAuth requise
     scopes:
-      follow:
-      read:
-      write:
+      follow: s’abonner, se désabonner, bloquer, et débloquer des comptes
+      read: lire les données de votre compte  
+      write: poster en tant que vous
diff --git a/config/locales/doorkeeper.hu.yml b/config/locales/doorkeeper.hu.yml
new file mode 100644
index 000000000..b1c6dd6c9
--- /dev/null
+++ b/config/locales/doorkeeper.hu.yml
@@ -0,0 +1,112 @@
+---
+hu:
+  activerecord:
+    attributes:
+      doorkeeper/application:
+        name: Név
+        redirect_uri: Visszairányító URI
+    errors:
+      models:
+        doorkeeper/application:
+          attributes:
+            redirect_uri:
+              fragment_present: nem tartalmazhat töredéket.
+              invalid_uri: érvényes URI-nak kell lennie.
+              relative_uri: abszolút URI-nak kell lennie.
+              secured_uri: HTTPS/SSL URI-nak kell lennie.
+  doorkeeper:
+    applications:
+      buttons:
+        authorize: Engedélyezés
+        cancel: Mégsem
+        destroy: Törlés
+        edit: Szerkesztés
+        submit: Elküldés
+      confirmations:
+        destroy: Biztos vagy benne?
+      edit:
+        title: Alkalmazás szerkesztése
+      form:
+        error: Hoppá! Ellenőrizd az űrlapot az esetleges hibák miatt
+      help:
+        native_redirect_uri: Használj %{native_redirect_uri} a helyi tesztekhez
+        redirect_uri: Egy sor URI-nként
+        scopes: A nézeteket szóközzel válaszd el. Hagyd üresen az alapértelmezett nézetekhez.
+      index:
+        callback_url: Callback URL
+        name: Név
+        new: Új alkalmazás
+        title: Alkalmazásod
+      new:
+        title: Új alkalmazás
+      show:
+        actions: Műveletek
+        application_id: Alkalmazás azonosító
+        callback_urls: Callback urlek
+        scopes: Nézetek
+        secret: Titok
+        title: 'Alkalmazás: %{name}'
+    authorizations:
+      buttons:
+        authorize: Engedélyezés
+        deny: Tiltás
+      error:
+        title: Hiba történt
+      new:
+        able_to: Képes lesz
+        prompt: "%{client_name} nevű alkalmazás engedélyt kér a fiókodhoz való hozzáféréshez."
+        title: Engedély szükséges
+      show:
+        title: Engedély kódja
+    authorized_applications:
+      buttons:
+        revoke: Visszavonás
+      confirmations:
+        revoke: Biztos vagy benne?
+      index:
+        application: Alkalmazás
+        created_at: Készítve
+        date_format: "%Y-%m-%d %H:%M:%S"
+        title: Engedélyezett alkalmazásaid
+    errors:
+      messages:
+        access_denied: Az erőforrás tulajdonosa vagy hitelesítő kiszolgálója megtakadta a kérést.
+        credential_flow_not_configured: Az erőforrás tulajdonos jelszóadatainak átadása megszakadt, mert a Doorkeeper.configure.resource_owner_from_credentials beállítatlan.
+        invalid_client: A kliens hitelesítése megszakadt, mert a ismeretlen a kliens, kliens nem küldött hitelesítést, vagy ismeretlen a kliens
+        invalid_grant: A biztosított hitelesítés érvénytelen, lejárt, visszavont, vagy nem egyezik a hitelesítéi kérésben használt URIval, vagy más kliensnek lett címezve.
+        invalid_redirect_uri: A redirect uri nem valós.
+        invalid_request: A kérésből hiányzik egy szükséges paraméter, nem támogatott paramétert tartalmaz, vagy egyéb módon hibás.
+        invalid_resource_owner: A biztosított erőforrás tulajdonosának hitelesítő adatai nem valósak, vagy az erőforrás tulajdonosa nem található.
+        invalid_scope: A kért nézet érvénytelen, ismeretlen, vagy hibás.
+        invalid_token:
+          expired: Hozzáférési kulcs lejárt
+          revoked: Hozzáférési kulcs vissza lett vonva
+          unknown: Hozzáférési kulcs érvénytelen
+        resource_owner_authenticator_not_configured: Erőforrás tulajdonos keresés megszakadt, ugyanis a Doorkeeper.configure.resource_owner_authenticator beállítatlan.
+        server_error: Hitelesítő szervert váratlan esemény érte, mely meggátolta a kérés teljesítését.
+        temporarily_unavailable: A hitelesítő szerver jelenleg nem tudja teljesíteni a kérést egy átmeneti túlterheltség vagy a kiszolgáló karbantartása miatt. 
+        unauthorized_client: A kliens nincs feljogosítva a kérés teljesítésére.
+        unsupported_grant_type: A hitelesítés módja nem támogatott a hitelesítő kiszolgálón.
+        unsupported_response_type: A hitelesítő kiszolgáló nem támogatja ezt a választ.
+    flash:
+      applications:
+        create:
+          notice: Alkalmazás létrehozva.
+        destroy:
+          notice: Alkalmazás törölve.
+        update:
+          notice: Alkalmazás frissítve.
+      authorized_applications:
+        destroy:
+          notice: Alkalmazás visszavonva.
+    layouts:
+      admin:
+        nav:
+          applications: Alkalmazások
+          oauth2_provider: OAuth2 szolgáltató
+      application:
+        title: OAuth engedély szükséges
+    scopes:
+      follow: fiókok követése, blokkoláse, blokkolás feloldása és követés abbahagyása
+      read: fiókod adatainak olvasása
+      write: bejegyzés írása a nevedben
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index c9258381c..f78cd0de5 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -7,44 +7,44 @@ fr:
     source_code: "Code source"
     terms: "Conditions d’utilisation"
   accounts:
-    follow: "S’abonner"
+    follow: "Suivre"
     followers: "Abonnés"
     following: "Abonnements"
-    nothing_here: "Rien à voir ici&nbsp;!"
-    people_followed_by: "Personnes auxquelles %{name} est abonné⋅e"
-    people_who_follow: "Personnes abonnées à %{name}"
+    nothing_here: "Rien à voir ici !"
+    people_followed_by: "Personnes suivies par %{name}"
+    people_who_follow: "Personnes qui suivent %{name}"
     posts: "Statuts"
-    unfollow: "Se désabonner"
+    unfollow: "Ne plus suivre"
   application_mailer:
     signature: "Notifications de Mastodon depuis %{instance}"
   auth:
     change_password: "Changer de mot de passe"
-    didnt_get_confirmation: "Vous n’avez pas reçu les consignes de confirmation&nbsp;?"
-    forgot_password: "Mode passe oublié&nbsp;?"
+    didnt_get_confirmation: "Vous n’avez pas reçu les consignes de confirmation ?"
+    forgot_password: "Mode passe oublié ?"
     login: "Se connecter"
     register: "S’inscrire"
     resend_confirmation: "Envoyer à nouveau les consignes de confirmation"
     reset_password: "Réinitialiser le mot de passe"
     set_new_password: "Établir le nouveau mot de passe"
   generic:
-    changes_saved_msg: "Les modifications ont été enregistrées avec succès&nbsp;!"
+    changes_saved_msg: "Les modifications ont été enregistrées avec succès !"
     powered_by: "propulsé par %{link}"
     save_changes: "Enregistrer les modifications"
     validation_errors:
-      one: "Quelque chose ne va pas&nbsp;! Vérifiez l’erreur ci-dessous."
-      other: "Quelques choses ne vont pas&nbsp;! Vérifiez les erreurs ci-dessous."
+      one: "Quelque chose ne va pas ! Vérifiez l’erreur ci-dessous."
+      other: "Quelques choses ne vont pas ! Vérifiez les erreurs ci-dessous."
   notification_mailer:
     favourite:
-      body: "%{name} a ajouté votre statut à ses favoris&nbsp;:"
+      body: "%{name} a ajouté votre statut à ses favoris :"
       subject: "%{name} a ajouté votre statut à ses favoris"
     follow:
-      body: "%{name} s’est abonné⋅e à vos statuts&nbsp;!"
-      subject: "%{name} s’est abonné⋅e à vos statuts"
+      body: "%{name} vous suit !"
+      subject: "%{name} vous suit"
     mention:
-      body: "%{name} vous a mentionné⋅e dans&nbsp;:"
+      body: "%{name} vous a mentionné⋅e dans :"
       subject: "%{name} vous a mentionné⋅e"
     reblog:
-      body: "%{name} a partagé votre statut&nbsp;:"
+      body: "%{name} a partagé votre statut :"
       subject: "%{name} a partagé votre statut"
   pagination:
     next: "Suivant"
@@ -54,6 +54,6 @@ fr:
     preferences: "Préférences"
   stream_entries:
     favourited: "a ajouté à ses favoris un statut de"
-    is_now_following: "s’est abonné⋅e à"
+    is_now_following: "suit désormais"
   will_paginate:
     page_gap: "&hellip;"
diff --git a/config/locales/hu.yml b/config/locales/hu.yml
new file mode 100644
index 000000000..d891b2b28
--- /dev/null
+++ b/config/locales/hu.yml
@@ -0,0 +1,59 @@
+---
+hu:
+  about:
+    about_instance: "<em>%{instance}</em> egy Mastodon másolat."
+    about_mastodon: Mastodon egy <em>szabad, nyílt forráskódú</em> szociális hálózati kiszolgálo. Egy <em>központosítatlan</em> alternatíva a kereskedelmi platformokra, elkerüli a kommunikációd monopolizációját veszélyét. Bárki futtathatja a Mastodon-t és részt vehet a <em>szociális hálózatban</em>.
+    get_started: Első lépések
+    source_code: Forráskód
+    terms: Feltételek
+  accounts:
+    follow: Követés
+    followers: Követők
+    following: Követed őket
+    nothing_here: Nincs itt semmi!
+    people_followed_by: "%{name} követett személyei"
+    people_who_follow: "%{name} követői"
+    posts: Bejegyzések
+    unfollow: Követés abbahagyása
+  application_mailer:
+    signature: "%{instance} Mastodon értesítései"
+  auth:
+    change_password: Jelszó változtatása
+    didnt_get_confirmation: Nem kaptad meg a megerősítési lépéseket?
+    forgot_password: Elfelejtetted a jelszavad?
+    login: Belépés
+    register: Regisztráció
+    resend_confirmation: Megerősítési lépések újraküldése
+    reset_password: Jelszó visszaállítása
+    set_new_password: Új jelszó beállítása
+  generic:
+    changes_saved_msg: Változások sikeresen elmentve!
+    powered_by: powered by %{link}
+    save_changes: Változások mentése
+    validation_errors:
+      one: Valami nincs rendjén! Kérlek tekintsd meg a hibát alant
+      other: Valami nincs rendjén! Kérlek tekintsd meg a %{count} darab hibát alant.
+  notification_mailer:
+    favourite:
+      body: 'Az állapotodat kedvencnek jelölte %{name}:'
+      subject: "%{name} kedvencnek jelölte az állapotod"
+    follow:
+      body: "%{name} mostantól követ téged!"
+      subject: "%{name} mostantól követ téged"
+    mention:
+      body: '%{name} megemlített téged:'
+      subject: "%{name} megemlített téged"
+    reblog:
+      body: 'Az állapotod reblogolta %{name}:'
+      subject: "%{name} reblogolta az állapotod"
+  pagination:
+    next: Következő
+    prev: Előző
+  settings:
+    edit_profile: Profil szerkesztése
+    preferences: Beállítások
+  stream_entries:
+    favourited: kedvencnek jelölték a bejegyzésedet 
+    is_now_following: mostantól követ
+  will_paginate:
+    page_gap: "&hellip;"
diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml
index 36c5141a2..47e30ccb4 100644
--- a/config/locales/simple_form.de.yml
+++ b/config/locales/simple_form.de.yml
@@ -16,6 +16,9 @@ de:
         password: Passwort
         silenced: Öffentliche Beiträge nicht auflisten
         username: Nutzername
+      interactions:
+        must_be_follower: Benachrichtigungen von nicht-Folgern blockieren
+        must_be_following: Benachrichtigungen von Nutzern blockieren, denen ich nicht folge
       notification_emails:
         favourite: E-mail senden, wenn jemand meinen Beitrag favorisiert
         follow: E-mail senden, wenn mir jemand folgt
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index a7d958c06..1e975af14 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -16,6 +16,9 @@ en:
         password: Password
         silenced: Unlisted mode
         username: Username
+      interactions:
+        must_be_follower: Block notifications from non-followers
+        must_be_following: Block notifications from people you don't follow
       notification_emails:
         favourite: Send e-mail when someone favourites your status
         follow: Send e-mail when someone follows you
diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml
index 7333c9e11..73905a7b3 100644
--- a/config/locales/simple_form.fr.yml
+++ b/config/locales/simple_form.fr.yml
@@ -15,12 +15,15 @@ fr:
         note: Présentation
         password: Mot de passe
         silenced: Ne pas apparaître dans le fil public
-        username: Nom d’utilisateur
+        username: Identifiant
       notification_emails:
-        favourite: Envoyer un courriel lorsque quelqu’un ajoute un de mes statuts à ses favoris
-        follow: Envoyer un courriel lorsque quelqu’un s’abonne à mes statuts
+        favourite: Envoyer un courriel lorsque quelqu’un ajoute mes statut à ses favoris
+        follow: Envoyer un courriel lorsque quelqu’un me suit
         mention: Envoyer un courriel lorsque quelqu’un me mentionne
-        reblog: Envoyer un courriel lorsque quelqu’un partage un de mes statuts
+        reblog: Envoyer un courriel lorsque quelqu’un partage mes statuts
+      interactions:
+        must_be_follower: Masquer les notifications des personnes qui ne vous suivent pas
+        must_be_following: Masquer les notifications des personnes que vous ne suivez pas
     'no': Non
     required:
       mark: "*"
diff --git a/config/locales/simple_form.hu.yml b/config/locales/simple_form.hu.yml
new file mode 100644
index 000000000..39c450087
--- /dev/null
+++ b/config/locales/simple_form.hu.yml
@@ -0,0 +1,28 @@
+---
+hu:
+  simple_form:
+    labels:
+      defaults:
+        avatar: Profilkép
+        confirm_new_password: Új jelszó megerősítése
+        confirm_password: Jelszó megerősítése
+        current_password: Jelenlegi jelszó
+        display_name: Megjelenített név
+        email: E-mail cím
+        header: Fejléc
+        locale: Nyelv
+        new_password: Új jelszó
+        note: Önéletrajz
+        password: Jelszó
+        silenced: Listázatlan mód
+        username: Felhasználónév
+      notification_emails:
+        favourite: E-mail küldése amikor valaki kedvencnek jelöli az állapotod
+        follow: E-mail küldése amikor valaki követni kezd téged
+        mention: E-mail küldése amikor valaki megemlít téged
+        reblog: E-mail küldése amikor valaki reblogolja az állapotod
+    'no': 'Nem'
+    required:
+      mark: "*"
+      text: kötelező
+    'yes': 'Igen'
diff --git a/config/routes.rb b/config/routes.rb
index 00185c5e8..cd544a62b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,7 +1,9 @@
+# frozen_string_literal: true
+
 require 'sidekiq/web'
 
 Rails.application.routes.draw do
-  mount ActionCable.server => '/cable'
+  mount ActionCable.server, at: 'cable'
 
   authenticate :user, lambda { |u| u.admin? } do
     mount Sidekiq::Web, at: 'sidekiq'
@@ -19,7 +21,7 @@ Rails.application.routes.draw do
     sessions:           'auth/sessions',
     registrations:      'auth/registrations',
     passwords:          'auth/passwords',
-    confirmations:      'auth/confirmations'
+    confirmations:      'auth/confirmations',
   }
 
   resources :accounts, path: 'users', only: [:show], param: :username do
@@ -42,11 +44,18 @@ Rails.application.routes.draw do
   resources :media, only: [:show]
   resources :tags,  only: [:show]
 
+  namespace :admin do
+    resources :pubsubhubbub, only: [:index]
+  end
+
   namespace :api do
-    # PubSubHubbub
+    # PubSubHubbub outgoing subscriptions
     resources :subscriptions, only: [:show]
     post '/subscriptions/:id', to: 'subscriptions#update'
 
+    # PubSubHubbub incoming subscriptions
+    post '/push', to: 'push#update', as: :push
+
     # Salmon
     post '/salmon/:id', to: 'salmon#update', as: :salmon
 
@@ -80,7 +89,6 @@ Rails.application.routes.draw do
         collection do
           get :relationships
           get :verify_credentials
-          get :suggestions
           get :search
         end
 
@@ -88,7 +96,6 @@ Rails.application.routes.draw do
           get :statuses
           get :followers
           get :following
-          get :common_followers
 
           post :follow
           post :unfollow
diff --git a/db/migrate/20161123093447_add_sensitive_to_statuses.rb b/db/migrate/20161123093447_add_sensitive_to_statuses.rb
new file mode 100644
index 000000000..109f761ed
--- /dev/null
+++ b/db/migrate/20161123093447_add_sensitive_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddSensitiveToStatuses < ActiveRecord::Migration[5.0]
+  def change
+    add_column :statuses, :sensitive, :boolean, default: false
+  end
+end
diff --git a/db/migrate/20161128103007_create_subscriptions.rb b/db/migrate/20161128103007_create_subscriptions.rb
new file mode 100644
index 000000000..46443680a
--- /dev/null
+++ b/db/migrate/20161128103007_create_subscriptions.rb
@@ -0,0 +1,15 @@
+class CreateSubscriptions < ActiveRecord::Migration[5.0]
+  def change
+    create_table :subscriptions do |t|
+      t.string :callback_url, null: false, default: ''
+      t.string :secret
+      t.datetime :expires_at, null: true, default: nil
+      t.boolean :confirmed, null: false, default: false
+      t.integer :account_id, null: false
+
+      t.timestamps
+    end
+
+    add_index :subscriptions, [:callback_url, :account_id], unique: true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 42e4e081c..2c0e6de5b 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: 20161122163057) do
+ActiveRecord::Schema.define(version: 20161128103007) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -143,6 +143,19 @@ ActiveRecord::Schema.define(version: 20161122163057) do
     t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree
   end
 
+  create_table "pubsubhubbub_subscriptions", force: :cascade do |t|
+    t.string   "topic",      default: "",    null: false
+    t.string   "callback",   default: "",    null: false
+    t.string   "mode",       default: "",    null: false
+    t.string   "challenge",  default: "",    null: false
+    t.string   "secret"
+    t.boolean  "confirmed",  default: false, null: false
+    t.datetime "expires_at",                 null: false
+    t.datetime "created_at",                 null: false
+    t.datetime "updated_at",                 null: false
+    t.index ["topic", "callback"], name: "index_pubsubhubbub_subscriptions_on_topic_and_callback", unique: true, using: :btree
+  end
+
   create_table "settings", force: :cascade do |t|
     t.string   "var",         null: false
     t.text     "value"
@@ -155,13 +168,14 @@ ActiveRecord::Schema.define(version: 20161122163057) do
 
   create_table "statuses", force: :cascade do |t|
     t.string   "uri"
-    t.integer  "account_id",                  null: false
-    t.text     "text",           default: "", null: false
-    t.datetime "created_at",                  null: false
-    t.datetime "updated_at",                  null: false
+    t.integer  "account_id",                     null: false
+    t.text     "text",           default: "",    null: false
+    t.datetime "created_at",                     null: false
+    t.datetime "updated_at",                     null: false
     t.integer  "in_reply_to_id"
     t.integer  "reblog_of_id"
     t.string   "url"
+    t.boolean  "sensitive",      default: false
     t.index ["account_id"], name: "index_statuses_on_account_id", using: :btree
     t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", using: :btree
     t.index ["reblog_of_id"], name: "index_statuses_on_reblog_of_id", using: :btree
@@ -184,6 +198,17 @@ ActiveRecord::Schema.define(version: 20161122163057) do
     t.index ["activity_id", "activity_type"], name: "index_stream_entries_on_activity_id_and_activity_type", using: :btree
   end
 
+  create_table "subscriptions", force: :cascade do |t|
+    t.string   "callback_url", default: "",    null: false
+    t.string   "secret"
+    t.datetime "expires_at"
+    t.boolean  "confirmed",    default: false, null: false
+    t.integer  "account_id",                   null: false
+    t.datetime "created_at",                   null: false
+    t.datetime "updated_at",                   null: false
+    t.index ["callback_url", "account_id"], name: "index_subscriptions_on_callback_url_and_account_id", unique: true, using: :btree
+  end
+
   create_table "tags", force: :cascade do |t|
     t.string   "name",       default: "", null: false
     t.datetime "created_at",              null: false
diff --git a/docker-compose.yml b/docker-compose.yml
index 9f9126805..e1f1f1c4c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,16 +6,9 @@ services:
   redis:
     restart: always
     image: redis
-  neo4j:
-    restart: always
-    build:
-      context: .
-      dockerfile: Dockerfile.neo4j
   web:
     restart: always
-    build:
-      context: .
-      dockerfile: Dockerfile.app
+    build: .
     env_file: .env.production
     command: bundle exec rails s -p 3000 -b '0.0.0.0'
     ports:
@@ -23,20 +16,16 @@ services:
     depends_on:
       - db
       - redis
-      - neo4j
     volumes:
       - ./public/assets:/mastodon/public/assets
       - ./public/system:/mastodon/public/system
   sidekiq:
     restart: always
-    build:
-      context: .
-      dockerfile: Dockerfile.app
+    build: .
     env_file: .env.production
-    command: bundle exec sidekiq -q default -q mailers
+    command: bundle exec sidekiq -q default -q mailers -q push
     depends_on:
       - db
       - redis
-      - neo4j
     volumes:
       - ./public/system:/mastodon/public/system
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 58bafff66..8730a64cd 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -36,16 +36,17 @@ namespace :mastodon do
   end
 
   namespace :feeds do
-    desc 'Clears all timelines so that they would be regenerated on next hit'
+    desc 'Clear timelines of inactive users'
     task clear: :environment do
-      Redis.current.keys('feed:*').each { |key| Redis.current.del(key) }
+      User.where('current_sign_in_at < ?', 14.days.ago).find_each do |user|
+        Redis.current.del(FeedManager.instance.key(:home, user.account_id))
+        Redis.current.del(FeedManager.instance.key(:mentions, user.account_id))
+      end
     end
-  end
 
-  namespace :graphs do
-    desc 'Syncs all follow relationships to Neo4J'
-    task sync: :environment do
-      Follow.find_each(&:sync!)
+    desc 'Clears all timelines so that they would be regenerated on next hit'
+    task clear_all: :environment do
+      Redis.current.keys('feed:*').each { |key| Redis.current.del(key) }
     end
   end
 end
diff --git a/package.json b/package.json
index a9f816b83..3bd2eacd3 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
     "@kadira/storybook": "^2.24.0",
     "axios": "^0.14.0",
     "babel-plugin-react-transform": "^2.0.2",
+    "babel-plugin-transform-decorators-legacy": "^1.3.4",
     "babel-plugin-transform-object-rest-spread": "^6.8.0",
     "babel-preset-es2015": "^6.13.2",
     "babel-preset-react": "^6.11.1",
@@ -16,37 +17,39 @@
     "browserify-incremental": "^3.1.1",
     "chai": "^3.5.0",
     "chai-enzyme": "^0.5.2",
+    "emojione": "^2.2.6",
     "enzyme": "^2.4.1",
     "es6-promise": "^3.2.1",
+    "http-link-header": "^0.5.0",
     "immutable": "^3.8.1",
+    "intl": "^1.2.5",
     "jsdom": "^9.6.0",
     "mocha": "^3.1.1",
     "react": "^15.3.2",
     "react-addons-perf": "^15.3.2",
     "react-addons-pure-render-mixin": "^15.3.1",
     "react-addons-test-utils": "^15.3.2",
+    "react-autosuggest": "^7.0.1",
+    "react-decoration": "^1.4.0",
     "react-dom": "^15.3.0",
     "react-immutable-proptypes": "^2.1.0",
+    "react-intl": "^2.1.5",
+    "react-motion": "^0.4.5",
     "react-notification": "^6.4.0",
     "react-proxy": "^1.1.8",
     "react-redux": "^5.0.0-beta.3",
     "react-redux-loading-bar": "^2.4.1",
+    "react-responsive": "^1.1.5",
     "react-router": "^2.8.0",
+    "react-router-scroll": "^0.3.2",
     "react-simple-dropdown": "^1.1.4",
     "redux": "^3.5.2",
     "redux-immutable": "^3.0.8",
     "redux-thunk": "^2.1.0",
     "reselect": "^2.5.4",
-    "sinon": "^1.17.6",
-    "babel-plugin-transform-decorators-legacy": "^1.3.4",
-    "emojione": "^2.2.6",
-    "http-link-header": "^0.5.0",
-    "intl": "^1.2.5",
-    "react-autosuggest": "^7.0.1",
-    "react-decoration": "^1.4.0",
-    "react-intl": "^2.1.5",
-    "react-motion": "^0.4.5",
-    "react-responsive": "^1.1.5",
-    "react-router-scroll": "^0.3.2"
+    "sinon": "^1.17.6"
+  },
+  "dependencies": {
+    "react-toggle": "^2.1.1"
   }
 }
diff --git a/spec/controllers/admin/pubsubhubbub_controller_spec.rb b/spec/controllers/admin/pubsubhubbub_controller_spec.rb
new file mode 100644
index 000000000..068bd09a6
--- /dev/null
+++ b/spec/controllers/admin/pubsubhubbub_controller_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+require 'rails_helper'
+
+RSpec.describe Admin::PubsubhubbubController, type: :controller do
+  describe 'GET #index' do
+    before do
+      sign_in Fabricate(:user, admin: true), scope: :user
+    end
+
+    it 'returns http success' do
+      get :index
+      expect(response).to have_http_status(:success)
+    end
+  end
+end
diff --git a/spec/controllers/api/push_controller_spec.rb b/spec/controllers/api/push_controller_spec.rb
new file mode 100644
index 000000000..e699006f7
--- /dev/null
+++ b/spec/controllers/api/push_controller_spec.rb
@@ -0,0 +1,13 @@
+require 'rails_helper'
+
+RSpec.describe Api::PushController, type: :controller do
+  describe 'POST #update' do
+    context 'with hub.mode=subscribe' do
+      pending
+    end
+
+    context 'with hub.mode=unsubscribe' do
+      pending
+    end
+  end
+end
diff --git a/spec/controllers/api/salmon_controller_spec.rb b/spec/controllers/api/salmon_controller_spec.rb
index 6897caeeb..3d3a973d2 100644
--- a/spec/controllers/api/salmon_controller_spec.rb
+++ b/spec/controllers/api/salmon_controller_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Api::SalmonController, type: :controller do
   let(:account) { Fabricate(:user, account: Fabricate(:account, username: 'catsrgr8')).account }
 
   before do
+    stub_request(:post, "https://pubsubhubbub.superfeedr.com/").to_return(:status => 200, :body => "", :headers => {})
     stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt'))
     stub_request(:get, "https://quitter.no/.well-known/webfinger?resource=acct:gargron@quitter.no").to_return(request_fixture('webfinger.txt'))
     stub_request(:get, "https://quitter.no/api/statuses/user_timeline/7477.atom").to_return(request_fixture('feed.txt'))
diff --git a/spec/controllers/api/subscriptions_controller_spec.rb b/spec/controllers/api/subscriptions_controller_spec.rb
index 2af6cb725..44841176a 100644
--- a/spec/controllers/api/subscriptions_controller_spec.rb
+++ b/spec/controllers/api/subscriptions_controller_spec.rb
@@ -23,6 +23,7 @@ RSpec.describe Api::SubscriptionsController, type: :controller do
     let(:feed) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) }
 
     before do
+      stub_request(:post, "https://quitter.no/main/push/hub").to_return(:status => 200, :body => "", :headers => {})
       stub_request(:get, "https://quitter.no/avatar/7477-300-20160211190340.png").to_return(request_fixture('avatar.txt'))
       stub_request(:head, "https://quitter.no/notice/1269244").to_return(status: 404)
       stub_request(:head, "https://quitter.no/notice/1265331").to_return(status: 404)
@@ -37,7 +38,7 @@ RSpec.describe Api::SubscriptionsController, type: :controller do
       stub_request(:head, "https://social.umeahackerspace.se/user/2").to_return(status: 404)
       stub_request(:head, "https://gs.kawa-kun.com/user/2").to_return(status: 404)
       stub_request(:head, "https://mastodon.social/users/Gargron").to_return(status: 404)
-      
+
       request.env['HTTP_X_HUB_SIGNATURE'] = "sha1=#{OpenSSL::HMAC.hexdigest('sha1', 'abc', feed)}"
       request.env['RAW_POST_DATA'] = feed
 
diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb
index 98eea28ce..e4532305b 100644
--- a/spec/controllers/api/v1/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts_controller_spec.rb
@@ -46,20 +46,6 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
     end
   end
 
-  describe 'GET #suggestions' do
-    it 'returns http success' do
-      get :suggestions
-      expect(response).to have_http_status(:success)
-    end
-  end
-
-  describe 'GET #common_followers' do
-    it 'returns http success' do
-      get :common_followers, params: { id: user.account.id }
-      expect(response).to have_http_status(:success)
-    end
-  end
-
   describe 'POST #follow' do
     let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
 
diff --git a/spec/fabricators/subscription_fabricator.rb b/spec/fabricators/subscription_fabricator.rb
new file mode 100644
index 000000000..0c8290494
--- /dev/null
+++ b/spec/fabricators/subscription_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:subscription) do
+  callback_url "http://example.com/callback"
+  secret       "foobar"
+  expires_at   "2016-11-28 11:30:07"
+  confirmed    false
+end
diff --git a/spec/helpers/admin/pubsubhubbub_helper_spec.rb b/spec/helpers/admin/pubsubhubbub_helper_spec.rb
new file mode 100644
index 000000000..6603e6dc0
--- /dev/null
+++ b/spec/helpers/admin/pubsubhubbub_helper_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+# Specs in this file have access to a helper object that includes
+# the Admin::PubsubhubbubHelper. For example:
+#
+# describe Admin::PubsubhubbubHelper do
+#   describe "string concat" do
+#     it "concats two strings with spaces" do
+#       expect(helper.concat_strings("this","that")).to eq("this that")
+#     end
+#   end
+# end
+RSpec.describe Admin::PubsubhubbubHelper, type: :helper do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb
new file mode 100644
index 000000000..d40bf0b44
--- /dev/null
+++ b/spec/models/subscription_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Subscription, type: :model do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb
index e4e5858ea..5e57d823b 100644
--- a/spec/services/process_feed_service_spec.rb
+++ b/spec/services/process_feed_service_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe ProcessFeedService do
   subject { ProcessFeedService.new }
 
   before do
+    stub_request(:post, "https://pubsubhubbub.superfeedr.com/").to_return(:status => 200, :body => "", :headers => {})
     stub_request(:get, "http://kickass.zone/system/accounts/avatars/000/000/001/large/eris.png").to_return(request_fixture('avatar.txt'))
     stub_request(:get, "http://kickass.zone/system/media_attachments/files/000/000/002/original/morpheus_linux.jpg?1476059910").to_return(request_fixture('attachment1.txt'))
     stub_request(:get, "http://kickass.zone/system/media_attachments/files/000/000/003/original/gizmo.jpg?1476060065").to_return(request_fixture('attachment2.txt'))
diff --git a/spec/services/update_remote_profile_service_spec.rb b/spec/services/update_remote_profile_service_spec.rb
index 1ffcfbfac..c3d76c653 100644
--- a/spec/services/update_remote_profile_service_spec.rb
+++ b/spec/services/update_remote_profile_service_spec.rb
@@ -1,7 +1,7 @@
 require 'rails_helper'
 
 RSpec.describe UpdateRemoteProfileService do
-  let(:xml) { Nokogiri::XML(File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom'))).at_xpath('//xmlns:author') }
+  let(:xml) { Nokogiri::XML(File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom'))).at_xpath('//xmlns:feed') }
 
   subject { UpdateRemoteProfileService.new }
 
@@ -13,7 +13,7 @@ RSpec.describe UpdateRemoteProfileService do
     let(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com') }
 
     before do
-      subject.(xml, remote_account)
+      subject.call(xml, remote_account)
     end
 
     it 'downloads new avatar' do
@@ -34,10 +34,10 @@ RSpec.describe UpdateRemoteProfileService do
   end
 
   context 'with unchanged details' do
-    let(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com',display_name: 'DIGITAL CAT', note: 'Software engineer, free time musician and DIGITAL SPORTS enthusiast. Likes cats. Warning: May contain memes', avatar_remote_url: 'https://quitter.no/avatar/7477-300-20160211190340.png') }
+    let(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com', display_name: 'DIGITAL CAT', note: 'Software engineer, free time musician and DIGITAL SPORTS enthusiast. Likes cats. Warning: May contain memes', avatar_remote_url: 'https://quitter.no/avatar/7477-300-20160211190340.png') }
 
     before do
-      subject.(xml, remote_account)
+      subject.call(xml, remote_account)
     end
 
     it 'does not re-download avatar' do
diff --git a/yarn.lock b/yarn.lock
index afafe3bc5..0a41f8b90 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1335,7 +1335,7 @@ clap@^1.0.9:
   dependencies:
     chalk "^1.1.3"
 
-classnames@^2.1.2, classnames@^2.2.3:
+classnames@^2.1.2, classnames@^2.2.3, classnames@~2.2:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
 
@@ -3841,7 +3841,7 @@ react-addons-perf@^15.3.2:
   version "15.3.2"
   resolved "https://registry.yarnpkg.com/react-addons-perf/-/react-addons-perf-15.3.2.tgz#bbdbebe8649f936f9636a5750ac145bf5c620213"
 
-react-addons-pure-render-mixin@^15.3.1:
+react-addons-pure-render-mixin@>=0.14.0, react-addons-pure-render-mixin@^15.3.1:
   version "15.3.2"
   resolved "https://registry.yarnpkg.com/react-addons-pure-render-mixin/-/react-addons-pure-render-mixin-15.3.2.tgz#c5f54764667ead26e6cdf7178b6c8dbbd8463ec2"
 
@@ -4022,6 +4022,13 @@ react-themeable@^1.1.0:
   dependencies:
     object-assign "^3.0.0"
 
+react-toggle@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/react-toggle/-/react-toggle-2.1.1.tgz#80600a64417a1acc8aaa4c1477f7fbdb88b988fb"
+  dependencies:
+    classnames "~2.2"
+    react-addons-pure-render-mixin ">=0.14.0"
+
 react@^15.3.2:
   version "15.3.2"
   resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e"