about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.env.vagrant1
-rw-r--r--.gitignore3
-rw-r--r--.rubocop.yml1
-rw-r--r--Gemfile10
-rw-r--r--Gemfile.lock34
-rw-r--r--Procfile2
-rw-r--r--README.md30
-rw-r--r--Vagrantfile109
-rw-r--r--app.json91
-rw-r--r--app/assets/images/background-photo.jpegbin128834 -> 894792 bytes
-rw-r--r--app/assets/images/boost_sprite.pngbin0 -> 1326 bytes
-rw-r--r--app/assets/javascripts/application_public.js5
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx60
-rw-r--r--app/assets/javascripts/components/actions/cards.jsx47
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx17
-rw-r--r--app/assets/javascripts/components/actions/favourites.jsx83
-rw-r--r--app/assets/javascripts/components/actions/meta.jsx8
-rw-r--r--app/assets/javascripts/components/actions/notifications.jsx28
-rw-r--r--app/assets/javascripts/components/actions/settings.jsx19
-rw-r--r--app/assets/javascripts/components/actions/statuses.jsx28
-rw-r--r--app/assets/javascripts/components/actions/store.jsx17
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx52
-rw-r--r--app/assets/javascripts/components/components/account.jsx26
-rw-r--r--app/assets/javascripts/components/components/autosuggest_textarea.jsx18
-rw-r--r--app/assets/javascripts/components/components/avatar.jsx33
-rw-r--r--app/assets/javascripts/components/components/button.jsx2
-rw-r--r--app/assets/javascripts/components/components/column_collapsable.jsx60
-rw-r--r--app/assets/javascripts/components/components/dropdown_menu.jsx6
-rw-r--r--app/assets/javascripts/components/components/icon_button.jsx22
-rw-r--r--app/assets/javascripts/components/components/lightbox.jsx22
-rw-r--r--app/assets/javascripts/components/components/loading_indicator.jsx22
-rw-r--r--app/assets/javascripts/components/components/media_gallery.jsx53
-rw-r--r--app/assets/javascripts/components/components/missing_indicator.jsx17
-rw-r--r--app/assets/javascripts/components/components/relative_timestamp.jsx21
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx6
-rw-r--r--app/assets/javascripts/components/components/status_content.jsx62
-rw-r--r--app/assets/javascripts/components/components/status_list.jsx37
-rw-r--r--app/assets/javascripts/components/components/video_player.jsx66
-rw-r--r--app/assets/javascripts/components/containers/account_container.jsx12
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx39
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx6
-rw-r--r--app/assets/javascripts/components/emoji.jsx2
-rw-r--r--app/assets/javascripts/components/features/account/components/action_bar.jsx2
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx4
-rw-r--r--app/assets/javascripts/components/features/account/index.jsx11
-rw-r--r--app/assets/javascripts/components/features/account_timeline/index.jsx11
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx47
-rw-r--r--app/assets/javascripts/components/features/compose/components/drawer.jsx73
-rw-r--r--app/assets/javascripts/components/features/compose/components/navigation_bar.jsx4
-rw-r--r--app/assets/javascripts/components/features/compose/components/search.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_button.jsx10
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_form.jsx13
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx15
-rw-r--r--app/assets/javascripts/components/features/compose/containers/navigation_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx1
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx5
-rw-r--r--app/assets/javascripts/components/features/favourited_statuses/index.jsx63
-rw-r--r--app/assets/javascripts/components/features/generic_not_found/index.jsx10
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx35
-rw-r--r--app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx68
-rw-r--r--app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx41
-rw-r--r--app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx21
-rw-r--r--app/assets/javascripts/components/features/home_timeline/index.jsx12
-rw-r--r--app/assets/javascripts/components/features/notifications/components/column_settings.jsx150
-rw-r--r--app/assets/javascripts/components/features/notifications/components/notification.jsx7
-rw-r--r--app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx32
-rw-r--r--app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx10
-rw-r--r--app/assets/javascripts/components/features/notifications/index.jsx22
-rw-r--r--app/assets/javascripts/components/features/status/components/action_bar.jsx4
-rw-r--r--app/assets/javascripts/components/features/status/components/card.jsx100
-rw-r--r--app/assets/javascripts/components/features/status/components/detailed_status.jsx13
-rw-r--r--app/assets/javascripts/components/features/status/containers/card_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx8
-rw-r--r--app/assets/javascripts/components/features/ui/components/column_link.jsx4
-rw-r--r--app/assets/javascripts/components/features/ui/components/tabs_bar.jsx7
-rw-r--r--app/assets/javascripts/components/features/ui/containers/modal_container.jsx29
-rw-r--r--app/assets/javascripts/components/features/ui/containers/status_list_container.jsx60
-rw-r--r--app/assets/javascripts/components/features/ui/index.jsx21
-rw-r--r--app/assets/javascripts/components/is_mobile.jsx5
-rw-r--r--app/assets/javascripts/components/locales/de.jsx27
-rw-r--r--app/assets/javascripts/components/locales/en.jsx6
-rw-r--r--app/assets/javascripts/components/locales/es.jsx3
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx3
-rw-r--r--app/assets/javascripts/components/locales/hu.jsx3
-rw-r--r--app/assets/javascripts/components/locales/pt.jsx3
-rw-r--r--app/assets/javascripts/components/locales/uk.jsx3
-rw-r--r--app/assets/javascripts/components/middleware/errors.jsx2
-rw-r--r--app/assets/javascripts/components/middleware/loading_bar.jsx25
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx83
-rw-r--r--app/assets/javascripts/components/reducers/cards.jsx14
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx134
-rw-r--r--app/assets/javascripts/components/reducers/index.jsx8
-rw-r--r--app/assets/javascripts/components/reducers/meta.jsx18
-rw-r--r--app/assets/javascripts/components/reducers/modal.jsx18
-rw-r--r--app/assets/javascripts/components/reducers/notifications.jsx60
-rw-r--r--app/assets/javascripts/components/reducers/search.jsx2
-rw-r--r--app/assets/javascripts/components/reducers/settings.jsx46
-rw-r--r--app/assets/javascripts/components/reducers/status_lists.jsx39
-rw-r--r--app/assets/javascripts/components/reducers/statuses.jsx68
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx91
-rw-r--r--app/assets/javascripts/components/reducers/user_lists.jsx38
-rw-r--r--app/assets/javascripts/components/store/configureStore.jsx28
-rw-r--r--app/assets/javascripts/extras.jsx4
-rw-r--r--app/assets/stylesheets/about.scss203
-rw-r--r--app/assets/stylesheets/accounts.scss60
-rw-r--r--app/assets/stylesheets/admin.scss28
-rw-r--r--app/assets/stylesheets/application.scss30
-rw-r--r--app/assets/stylesheets/boost.scss7
-rw-r--r--app/assets/stylesheets/components.scss259
-rw-r--r--app/assets/stylesheets/forms.scss50
-rw-r--r--app/assets/stylesheets/stream_entries.scss42
-rw-r--r--app/assets/stylesheets/tables.scss16
-rw-r--r--app/assets/stylesheets/variables.scss8
-rw-r--r--app/controllers/about_controller.rb12
-rw-r--r--app/controllers/admin/settings_controller.rb25
-rw-r--r--app/controllers/api/oembed_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts_controller.rb24
-rw-r--r--app/controllers/api/v1/apps_controller.rb2
-rw-r--r--app/controllers/api/v1/blocks_controller.rb4
-rw-r--r--app/controllers/api/v1/favourites_controller.rb4
-rw-r--r--app/controllers/api/v1/notifications_controller.rb15
-rw-r--r--app/controllers/api/v1/statuses_controller.rb26
-rw-r--r--app/controllers/api/v1/timelines_controller.rb16
-rw-r--r--app/controllers/api/web/settings_controller.rb15
-rw-r--r--app/controllers/api_controller.rb11
-rw-r--r--app/controllers/application_controller.rb10
-rw-r--r--app/controllers/auth/registrations_controller.rb4
-rw-r--r--app/controllers/home_controller.rb1
-rw-r--r--app/controllers/media_controller.rb3
-rw-r--r--app/controllers/settings/preferences_controller.rb20
-rw-r--r--app/controllers/stream_entries_controller.rb2
-rw-r--r--app/helpers/atom_builder_helper.rb13
-rw-r--r--app/helpers/home_helper.rb2
-rw-r--r--app/helpers/settings_helper.rb4
-rw-r--r--app/helpers/stream_entries_helper.rb8
-rw-r--r--app/lib/application_extension.rb9
-rw-r--r--app/lib/feed_manager.rb10
-rw-r--r--app/lib/formatter.rb15
-rw-r--r--app/lib/hash_object.rb10
-rw-r--r--app/lib/settings/extend.rb11
-rw-r--r--app/lib/settings/scoped_settings.rb14
-rw-r--r--app/lib/status_length_validator.rb10
-rw-r--r--app/lib/url_validator.rb14
-rw-r--r--app/models/account.rb32
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/follow_request.rb2
-rw-r--r--app/models/media_attachment.rb18
-rw-r--r--app/models/notification.rb4
-rw-r--r--app/models/preview_card.rb20
-rw-r--r--app/models/setting.rb50
-rw-r--r--app/models/status.rb11
-rw-r--r--app/models/user.rb7
-rw-r--r--app/models/web.rb7
-rw-r--r--app/models/web/setting.rb7
-rw-r--r--app/services/after_block_service.rb31
-rw-r--r--app/services/block_domain_service.rb15
-rw-r--r--app/services/block_service.rb27
-rw-r--r--app/services/fetch_link_card_service.rb35
-rw-r--r--app/services/follow_remote_account_service.rb5
-rw-r--r--app/services/follow_service.rb2
-rw-r--r--app/services/notify_service.rb18
-rw-r--r--app/services/post_status_service.rb12
-rw-r--r--app/services/process_feed_service.rb10
-rw-r--r--app/services/process_hashtags_service.rb2
-rw-r--r--app/services/process_interaction_service.rb2
-rw-r--r--app/services/process_mentions_service.rb2
-rw-r--r--app/services/reblog_service.rb4
-rw-r--r--app/services/remove_status_service.rb7
-rw-r--r--app/services/suspend_account_service.rb1
-rw-r--r--app/services/unfollow_service.rb17
-rw-r--r--app/services/update_remote_profile_service.rb9
-rw-r--r--app/views/about/index.html.haml12
-rw-r--r--app/views/about/more.html.haml56
-rw-r--r--app/views/accounts/_grid_card.html.haml4
-rw-r--r--app/views/accounts/_header.html.haml14
-rw-r--r--app/views/accounts/show.html.haml24
-rw-r--r--app/views/admin/domain_blocks/index.html.haml2
-rw-r--r--app/views/admin/settings/index.html.haml36
-rw-r--r--app/views/api/v1/apps/show.rabl3
-rw-r--r--app/views/api/v1/statuses/_show.rabl6
-rw-r--r--app/views/api/v1/statuses/card.rabl5
-rw-r--r--app/views/authorize_follow/_card.html.haml4
-rw-r--r--app/views/errors/404.html.haml5
-rw-r--r--app/views/errors/410.html.haml5
-rw-r--r--app/views/errors/422.html.haml5
-rw-r--r--app/views/home/index.html.haml3
-rw-r--r--app/views/home/initial_state.json.rabl24
-rw-r--r--app/views/layouts/error.html.haml36
-rw-r--r--app/views/layouts/public.html.haml2
-rw-r--r--app/views/settings/preferences/show.html.haml4
-rw-r--r--app/views/settings/shared/_links.html.haml1
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml30
-rw-r--r--app/views/stream_entries/_favourite.html.haml2
-rw-r--r--app/views/stream_entries/_follow.html.haml2
-rw-r--r--app/views/stream_entries/_simple_status.html.haml20
-rw-r--r--app/views/stream_entries/show.html.haml8
-rw-r--r--app/views/tags/show.html.haml2
-rw-r--r--app/workers/block_worker.rb9
-rw-r--r--app/workers/link_crawl_worker.rb13
-rw-r--r--app/workers/merge_worker.rb9
-rw-r--r--app/workers/notification_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/confirmation_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb6
-rw-r--r--app/workers/thread_resolve_worker.rb2
-rw-r--r--app/workers/unfavourite_worker.rb2
-rw-r--r--app/workers/unmerge_worker.rb9
-rw-r--r--config/application.rb4
-rw-r--r--config/cable.yml2
-rw-r--r--config/database.yml2
-rw-r--r--config/environments/development.rb5
-rw-r--r--config/environments/production.rb19
-rw-r--r--config/initializers/inflections.rb8
-rw-r--r--config/initializers/paperclip.rb4
-rw-r--r--config/initializers/redis.rb1
-rw-r--r--config/initializers/sidekiq.rb5
-rw-r--r--config/initializers/statsd.rb20
-rw-r--r--config/initializers/trusted_proxies.rb11
-rw-r--r--config/locales/de.yml33
-rw-r--r--config/locales/en.yml19
-rw-r--r--config/locales/simple_form.de.yml5
-rw-r--r--config/navigation.rb1
-rw-r--r--config/puma.rb45
-rw-r--r--config/routes.rb30
-rw-r--r--config/settings.yml23
-rw-r--r--db/migrate/20170105224407_add_shortcode_to_media_attachments.rb14
-rw-r--r--db/migrate/20170109120109_create_web_settings.rb12
-rw-r--r--db/migrate/20170112154826_migrate_settings.rb19
-rw-r--r--db/migrate/20170114194937_add_application_to_statuses.rb5
-rw-r--r--db/migrate/20170114203041_add_website_to_oauth_application.rb5
-rw-r--r--db/migrate/20170119214911_create_preview_cards.rb17
-rw-r--r--db/migrate/20170123162658_add_severity_to_domain_blocks.rb5
-rw-r--r--db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb5
-rw-r--r--db/migrate/20170125145934_add_spoiler_text_to_statuses.rb5
-rw-r--r--db/schema.rb55
-rw-r--r--docs/Contributing-to-Mastodon/Sponsors.md31
-rw-r--r--docs/Contributing-to-Mastodon/Translating.md48
-rw-r--r--docs/Extensions.md15
-rw-r--r--docs/README.md24
-rw-r--r--docs/Running-Mastodon/Administration-guide.md28
-rw-r--r--docs/Running-Mastodon/Development-guide.md48
-rw-r--r--docs/Running-Mastodon/Heroku-guide.md13
-rw-r--r--docs/Running-Mastodon/Production-guide.md188
-rw-r--r--docs/Running-Mastodon/Vagrant-guide.md64
-rw-r--r--docs/Specs-and-RFCs-used.md12
-rw-r--r--docs/Using-Mastodon/Apps.md15
-rw-r--r--docs/Using-Mastodon/FAQ.md43
-rw-r--r--docs/Using-Mastodon/List-of-Mastodon-instances.md12
-rw-r--r--docs/Using-the-API/API.md280
-rw-r--r--docs/Using-the-API/OAuth-details.md12
-rw-r--r--docs/Using-the-API/Testing-with-cURL.md16
-rw-r--r--docs/Using-the-API/Tips-for-app-developers.md16
-rw-r--r--lib/statsd_monitor.rb11
-rw-r--r--lib/tasks/mastodon.rake5
-rw-r--r--package.json13
-rw-r--r--public/404.html43
-rw-r--r--public/500.html4
-rw-r--r--public/headers/original/missing.pngbin0 -> 14573 bytes
-rw-r--r--public/sounds/boop.mp3bin0 -> 12070 bytes
-rw-r--r--spec/controllers/api/oembed_controller_spec.rb13
-rw-r--r--spec/controllers/api/v1/statuses_controller_spec.rb3
-rw-r--r--spec/controllers/auth/registrations_controller_spec.rb4
-rw-r--r--spec/fabricators/application_fabricator.rb5
-rw-r--r--spec/fabricators/media_attachment_fabricator.rb1
-rw-r--r--spec/fabricators/preview_card_fabricator.rb5
-rw-r--r--spec/fabricators/web_setting_fabricator.rb3
-rw-r--r--spec/fixtures/xml/mastodon.atom8
-rw-r--r--spec/i18n_spec.rb4
-rw-r--r--spec/lib/formatter_spec.rb2
-rw-r--r--spec/mailers/notification_mailer_spec.rb4
-rw-r--r--spec/models/account_spec.rb25
-rw-r--r--spec/models/preview_card_spec.rb5
-rw-r--r--spec/models/subscription_spec.rb2
-rw-r--r--spec/models/web/setting_spec.rb5
-rw-r--r--spec/services/block_domain_service_spec.rb4
-rw-r--r--storybook/storybook.scss2
-rw-r--r--yarn.lock188
276 files changed, 4847 insertions, 1342 deletions
diff --git a/.env.vagrant b/.env.vagrant
new file mode 100644
index 000000000..0ab0552c9
--- /dev/null
+++ b/.env.vagrant
@@ -0,0 +1 @@
+VAGRANT=true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index a60603c7d..7f51045aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,6 @@ public/assets
 .env.production
 node_modules/
 neo4j/
+
+# Ignore Vagrant files
+.vagrant/
diff --git a/.rubocop.yml b/.rubocop.yml
index 28c735913..ab28c0fe1 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -87,3 +87,4 @@ AllCops:
   - 'bin/*'
   - 'Rakefile'
   - 'node_modules/**/*'
+  - 'Vagrantfile'
diff --git a/Gemfile b/Gemfile
index 6bf95ec5e..7fb3ab91d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 source 'https://rubygems.org'
+ruby '2.3.1'
 
 gem 'rails', '~> 5.0.1.0'
 gem 'sass-rails', '~> 5.0'
@@ -16,8 +17,9 @@ gem 'pg'
 gem 'pghero'
 gem 'dotenv-rails'
 gem 'font-awesome-rails'
+gem 'best_in_place', '~> 3.0.1'
 
-gem 'paperclip', '~> 5.0'
+gem 'paperclip', '~> 5.1'
 gem 'paperclip-av-transcoder'
 gem 'aws-sdk', '>= 2.0'
 
@@ -29,7 +31,6 @@ gem 'link_header'
 gem 'ostatus2'
 gem 'goldfinger'
 gem 'devise'
-gem 'rails_autolink'
 gem 'doorkeeper'
 gem 'rabl'
 gem 'oj'
@@ -42,9 +43,11 @@ gem 'will_paginate'
 gem 'rack-attack'
 gem 'rack-cors', require: 'rack/cors'
 gem 'sidekiq'
-gem 'ledermann-rails-settings'
+gem 'rails-settings-cached'
 gem 'pg_search'
 gem 'simple-navigation'
+gem 'statsd-instrument'
+gem 'ruby-oembed', require: 'oembed'
 
 gem 'react-rails'
 gem 'browserify-rails'
@@ -69,6 +72,7 @@ group :development do
   gem 'better_errors'
   gem 'binding_of_caller'
   gem 'letter_opener'
+  gem 'letter_opener_web'
   gem 'bullet'
   gem 'active_record_query_trace'
 end
diff --git a/Gemfile.lock b/Gemfile.lock
index 2467b76cc..12f6679c7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -60,6 +60,9 @@ GEM
       babel-source (>= 4.0, < 6)
       execjs (~> 2.0)
     bcrypt (3.1.11)
+    best_in_place (3.0.3)
+      actionpack (>= 3.2)
+      railties (>= 3.2)
     better_errors (2.1.1)
       coderay (>= 1.0.0)
       erubis (>= 2.6.6)
@@ -73,8 +76,7 @@ GEM
     bullet (5.3.0)
       activesupport (>= 3.0.0)
       uniform_notifier (~> 1.10.0)
-    climate_control (0.0.3)
-      activesupport (>= 3.0)
+    climate_control (0.1.0)
     cocaine (0.5.8)
       climate_control (>= 0.0.3, < 1.0)
     coderay (1.1.1)
@@ -86,7 +88,7 @@ GEM
       execjs
     coffee-script-source (1.10.0)
     colorize (0.8.1)
-    concurrent-ruby (1.0.3)
+    concurrent-ruby (1.0.4)
     connection_pool (2.2.1)
     crack (0.4.3)
       safe_yaml (~> 1.0.0)
@@ -172,10 +174,12 @@ GEM
     json (1.8.3)
     launchy (2.4.3)
       addressable (~> 2.3)
-    ledermann-rails-settings (2.4.2)
-      activerecord (>= 3.1)
     letter_opener (1.4.1)
       launchy (~> 2.2)
+    letter_opener_web (1.3.0)
+      actionmailer (>= 3.2)
+      letter_opener (~> 1.0)
+      railties (>= 3.2)
     link_header (0.0.8)
     lograge (0.4.1)
       actionpack (>= 4, < 5.1)
@@ -259,11 +263,11 @@ GEM
       nokogiri (~> 1.6.0)
     rails-html-sanitizer (1.0.3)
       loofah (~> 2.0)
+    rails-settings-cached (0.6.5)
+      rails (>= 4.2.0)
     rails_12factor (0.0.3)
       rails_serve_static_assets
       rails_stdout_logging
-    rails_autolink (1.1.6)
-      rails (> 3.1)
     rails_serve_static_assets (0.0.5)
     rails_stdout_logging (0.0.5)
     railties (5.0.1)
@@ -332,6 +336,7 @@ GEM
       rainbow (>= 1.99.1, < 3.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (~> 1.0, >= 1.0.1)
+    ruby-oembed (0.10.1)
     ruby-progressbar (1.8.1)
     safe_yaml (1.0.4)
     sass (3.4.22)
@@ -367,6 +372,7 @@ GEM
       actionpack (>= 4.0)
       activesupport (>= 4.0)
       sprockets (>= 3.0.0)
+    statsd-instrument (2.1.2)
     temple (0.7.7)
     term-ansicolor (1.4.0)
       tins (~> 1.0)
@@ -405,6 +411,7 @@ DEPENDENCIES
   addressable
   autoprefixer-rails
   aws-sdk (>= 2.0)
+  best_in_place (~> 3.0.1)
   better_errors
   binding_of_caller
   browserify-rails
@@ -426,14 +433,14 @@ DEPENDENCIES
   i18n-tasks (~> 0.9.6)
   jbuilder (~> 2.0)
   jquery-rails
-  ledermann-rails-settings
   letter_opener
+  letter_opener_web
   link_header
   lograge
   nokogiri
   oj
   ostatus2
-  paperclip (~> 5.0)
+  paperclip (~> 5.1)
   paperclip-av-transcoder
   pg
   pg_search
@@ -445,23 +452,28 @@ DEPENDENCIES
   rack-cors
   rack-timeout-puma
   rails (~> 5.0.1.0)
+  rails-settings-cached
   rails_12factor
-  rails_autolink
   react-rails
   redis (~> 3.2)
   redis-rails
   rspec-rails
   rspec-sidekiq
   rubocop
+  ruby-oembed
   sass-rails (~> 5.0)
   sdoc (~> 0.4.0)
   sidekiq
   simple-navigation
   simple_form
   simplecov
+  statsd-instrument
   uglifier (>= 1.3.0)
   webmock
   will_paginate
 
+RUBY VERSION
+   ruby 2.3.1p112
+
 BUNDLED WITH
-   1.13.6
+   1.13.7
diff --git a/Procfile b/Procfile
new file mode 100644
index 000000000..6cdd89518
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,2 @@
+web: bundle exec puma -C config/puma.rb
+worker: bundle exec sidekiq -q default -q mailers -q push
diff --git a/README.md b/README.md
index 2d84062a7..7d3f5a975 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
 Mastodon
 ========
 
-[![Build Status](http://img.shields.io/travis/Gargron/goldfinger.svg)][travis]
-[![Code Climate](https://img.shields.io/codeclimate/github/Gargron/mastodon.svg)][code_climate]
+[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis]
+[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate]
 
-[travis]: https://travis-ci.org/Gargron/mastodon
-[code_climate]: https://codeclimate.com/github/Gargron/mastodon
+[travis]: https://travis-ci.org/tootsuite/mastodon
+[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
 
 Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
 
@@ -25,11 +25,11 @@ If you would like, you can [support the development of this project on Patreon][
 
 ## Resources
 
-- [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances)
+- [List of Mastodon instances](docs/Using-Mastodon/List-of-Mastodon-instances.md)
 - [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
-- [API overview](https://github.com/Gargron/mastodon/wiki/API)
-- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
-- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ)
+- [API overview](docs/Using-the-API/API.md)
+- [Frequently Asked Questions](docs/Using-Mastodon/FAQ.md)
+- [List of apps](docs/Using-Mastodon/Apps.md)
 
 ## Features
 
@@ -115,7 +115,19 @@ Which will re-create the updated containers, leaving databases and data as is. D
 
 ## Deployment without Docker
 
-Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](https://github.com/Gargron/mastodon/wiki/Production-guide) for examples, configuration and instructions.
+Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](docs/Running-Mastodon/Production-guide.md) for examples, configuration and instructions.
+
+## Deployment on Heroku (experimental)
+
+[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
+
+Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. [You can view a guide for deployment on Heroku here.](docs/Running-Mastodon/Heroku.md)
+
+## Development with Vagrant
+
+A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed.
+
+[You can find the guide for setting up a Vagrant development environment here.](docs/Running-Mastodon/Vagrant.md)
 
 ## Contributing
 
diff --git a/Vagrantfile b/Vagrantfile
new file mode 100644
index 000000000..154d0e895
--- /dev/null
+++ b/Vagrantfile
@@ -0,0 +1,109 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+$provision = <<SCRIPT
+
+cd /vagrant # This is where the host folder/repo is mounted
+
+# Add the yarn repo + yarn repo keys
+curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
+sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
+
+# Add repo for NodeJS
+curl -sL https://deb.nodesource.com/setup_4.x | sudo bash -
+
+# Add firewall rule to redirect 80 to 3000 and save
+sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000
+echo iptables-persistent iptables-persistent/autosave_v4 boolean true | sudo debconf-set-selections
+echo iptables-persistent iptables-persistent/autosave_v6 boolean true | sudo debconf-set-selections
+sudo apt-get install iptables-persistent -y
+
+# Add packages to build and run Mastodon
+sudo apt-get install \
+  git-core \
+  g++ \
+  libpq-dev \
+  libxml2-dev \
+  libxslt1-dev \
+  imagemagick \
+  nodejs \
+  redis-server \
+  redis-tools \
+  postgresql \
+  postgresql-contrib \
+  yarn \
+  libreadline-dev \
+  -y
+
+# Install rbenv
+git clone https://github.com/rbenv/rbenv.git ~/.rbenv
+cd ~/.rbenv && src/configure && make -C src
+echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
+echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
+
+git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
+
+export PATH="$HOME/.rbenv/bin::$PATH"
+eval "$(rbenv init -)"
+
+echo "Compiling Ruby 2.3.1: warning, this takes a while!!!"
+rbenv install 2.3.1
+rbenv global 2.3.1
+
+cd /vagrant
+
+# Configure database
+sudo -u postgres createuser -U postgres vagrant -s
+sudo -u postgres createdb -U postgres mastodon_development
+
+# Install gems and node modules
+gem install bundler
+bundle install
+yarn install
+
+# Build Mastodon
+bundle exec rails db:setup
+bundle exec rails assets:precompile
+
+SCRIPT
+
+$start = <<SCRIPT
+
+cd /vagrant
+export $(cat ".env.vagrant" | xargs)
+rails s -d -b 0.0.0.0
+
+SCRIPT
+
+VAGRANTFILE_API_VERSION = "2"
+
+Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
+
+  config.vm.box = "ubuntu/trusty64"
+
+  config.vm.provider :virtualbox do |vb|
+    vb.name = "mastodon"
+    vb.customize ["modifyvm", :id, "--memory", "1024"]
+  end
+
+  config.vm.hostname = "mastodon.dev"
+
+  # This uses the vagrant-hostsupdater plugin, and lets you
+  # access the development site at http://mastodon.dev.
+  # To install:
+  #   $ vagrant plugin install hostsupdater
+  if defined?(VagrantPlugins::HostsUpdater)
+    config.vm.network :private_network, ip: "192.168.42.42"
+    config.hostsupdater.remove_on_suspend = false
+  end
+
+  # Otherwise, you can access the site at http://localhost:3000
+  config.vm.network :forwarded_port, guest: 80, host: 3000
+
+  # Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision'
+  config.vm.provision :shell, inline: $provision, privileged: false
+
+  # Start up script, runs on every 'vagrant up'
+  config.vm.provision :shell, inline: $start, run: 'always', privileged: false
+
+end
diff --git a/app.json b/app.json
new file mode 100644
index 000000000..c0579d33e
--- /dev/null
+++ b/app.json
@@ -0,0 +1,91 @@
+{
+  "name": "Mastodon",
+  "description": "A GNU Social-compatible microblogging server",
+  "repository": "https://github.com/tootsuite/mastodon",
+  "logo": "https://github.com/tootsuite/mastodon/raw/master/app/assets/images/logo.png",
+  "env": {
+    "HEROKU": {
+      "description": "Leave this as true",
+      "value": "true",
+      "required": true
+    },
+    "LOCAL_DOMAIN": {
+      "description": "The domain that your Mastodon instance will run on (this can be appname.herokuapp.com or a custom domain)",
+      "required": true
+    },
+    "LOCAL_HTTPS": {
+      "description": "Will your domain support HTTPS? (Automatic for herokuapp, requires manual configuration for custom domains)",
+      "value": "false",
+      "required": true
+    },
+    "PAPERCLIP_SECRET": {
+      "description": "The secret key for storing media files",
+      "generator": "secret"
+    },
+    "SECRET_KEY_BASE": {
+      "description": "The secret key base",
+      "generator": "secret"
+    },
+    "SINGLE_USER_MODE": {
+      "description": "Should the instance run in single user mode? (Disable registrations, redirect to front page)",
+      "value": "false",
+      "required": true
+    },
+    "S3_ENABLED": {
+      "description": "Should Mastodon use Amazon S3 for storage? This is highly recommended, as Heroku does not have persistent file storage (files will be lost).",
+      "value": "true",
+      "required": false
+    },
+    "S3_BUCKET": {
+      "description": "Amazon S3 Bucket",
+      "required": false
+    },
+    "S3_REGION": {
+      "description": "Amazon S3 region that the bucket is located in",
+      "required": false
+    },
+    "AWS_ACCESS_KEY_ID": {
+      "description": "Amazon S3 Access Key",
+      "required": false
+    },
+    "AWS_SECRET_ACCESS_KEY": {
+      "description": "Amazon S3 Secret Key",
+      "required": false
+    },
+    "SMTP_SERVER": {
+      "description": "Hostname for SMTP server, if you want to enable email",
+      "required": false
+    },
+    "SMTP_PORT": {
+      "description": "Port for SMTP server",
+      "required": false
+    },
+    "SMTP_LOGIN": {
+      "description": "Username for SMTP server",
+      "required": false
+    },
+    "SMTP_PASSWORD": {
+      "description": "Password for SMTP server",
+      "required": false
+    },
+    "SMTP_DOMAIN": {
+      "description": "Domain for SMTP server. Will default to instance domain if blank.",
+      "required": false
+    }
+  },
+  "buildpacks": [
+    {
+      "url": "heroku/nodejs"
+    },
+    {
+      "url": "heroku/ruby"
+    }
+  ],
+  "scripts": {
+    "postdeploy": "bundle exec rails db:migrate && bundle exec rails db:seed"
+  },
+  "addons": [
+    "heroku-postgresql",
+    "heroku-redis"
+  ]
+}
\ No newline at end of file
diff --git a/app/assets/images/background-photo.jpeg b/app/assets/images/background-photo.jpeg
index 4390fca66..b0a88ff35 100644
--- a/app/assets/images/background-photo.jpeg
+++ b/app/assets/images/background-photo.jpeg
Binary files differdiff --git a/app/assets/images/boost_sprite.png b/app/assets/images/boost_sprite.png
new file mode 100644
index 000000000..564bf2646
--- /dev/null
+++ b/app/assets/images/boost_sprite.png
Binary files differdiff --git a/app/assets/javascripts/application_public.js b/app/assets/javascripts/application_public.js
index f131a267a..9626c5dae 100644
--- a/app/assets/javascripts/application_public.js
+++ b/app/assets/javascripts/application_public.js
@@ -1,3 +1,8 @@
 //= require jquery
 //= require jquery_ujs
 //= require extras
+//= require best_in_place
+
+$(function () {
+  $(".best_in_place").best_in_place();
+});
diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 8d28b051f..0be05034e 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -1,8 +1,6 @@
 import api, { getLinks } from '../api'
 import Immutable from 'immutable';
 
-export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
-
 export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
 export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
 export const ACCOUNT_FETCH_FAIL    = 'ACCOUNT_FETCH_FAIL';
@@ -67,13 +65,6 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
 export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
 export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
 
-export function setAccountSelf(account) {
-  return {
-    type: ACCOUNT_SET_SELF,
-    account
-  };
-};
-
 export function fetchAccount(id) {
   return (dispatch, getState) => {
     dispatch(fetchAccountRequest(id));
@@ -89,32 +80,39 @@ export function fetchAccount(id) {
 
 export function fetchAccountTimeline(id, replace = false) {
   return (dispatch, getState) => {
-    dispatch(fetchAccountTimelineRequest(id));
-
-    const ids      = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
+    const ids      = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List());
     const newestId = ids.size > 0 ? ids.first() : null;
 
     let params = '';
+    let skipLoading = false;
 
     if (newestId !== null && !replace) {
-      params = `?since_id=${newestId}`;
+      params      = `?since_id=${newestId}`;
+      skipLoading = true;
     }
 
+    dispatch(fetchAccountTimelineRequest(id, skipLoading));
+
     api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
-      dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
+      dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading));
     }).catch(error => {
-      dispatch(fetchAccountTimelineFail(id, error));
+      dispatch(fetchAccountTimelineFail(id, error, skipLoading));
     });
   };
 };
 
 export function expandAccountTimeline(id) {
   return (dispatch, getState) => {
-    const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last();
+    const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last();
 
     dispatch(expandAccountTimelineRequest(id));
 
-    api(getState).get(`/api/v1/accounts/${id}/statuses?max_id=${lastId}`).then(response => {
+    api(getState).get(`/api/v1/accounts/${id}/statuses`, {
+      params: {
+        limit: 10,
+        max_id: lastId
+      }
+    }).then(response => {
       dispatch(expandAccountTimelineSuccess(id, response.data));
     }).catch(error => {
       dispatch(expandAccountTimelineFail(id, error));
@@ -210,27 +208,30 @@ export function unfollowAccountFail(error) {
   };
 };
 
-export function fetchAccountTimelineRequest(id) {
+export function fetchAccountTimelineRequest(id, skipLoading) {
   return {
     type: ACCOUNT_TIMELINE_FETCH_REQUEST,
-    id
+    id,
+    skipLoading
   };
 };
 
-export function fetchAccountTimelineSuccess(id, statuses, replace) {
+export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) {
   return {
     type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
     id,
     statuses,
-    replace
+    replace,
+    skipLoading
   };
 };
 
-export function fetchAccountTimelineFail(id, error) {
+export function fetchAccountTimelineFail(id, error, skipLoading) {
   return {
     type: ACCOUNT_TIMELINE_FETCH_FAIL,
     id,
-    error
+    error,
+    skipLoading
   };
 };
 
@@ -495,6 +496,10 @@ export function expandFollowingFail(id, error) {
 
 export function fetchRelationships(account_ids) {
   return (dispatch, getState) => {
+    if (account_ids.length === 0) {
+      return;
+    }
+
     dispatch(fetchRelationshipsRequest(account_ids));
 
     api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
@@ -508,21 +513,24 @@ export function fetchRelationships(account_ids) {
 export function fetchRelationshipsRequest(ids) {
   return {
     type: RELATIONSHIPS_FETCH_REQUEST,
-    ids
+    ids,
+    skipLoading: true
   };
 };
 
 export function fetchRelationshipsSuccess(relationships) {
   return {
     type: RELATIONSHIPS_FETCH_SUCCESS,
-    relationships
+    relationships,
+    skipLoading: true
   };
 };
 
 export function fetchRelationshipsFail(error) {
   return {
     type: RELATIONSHIPS_FETCH_FAIL,
-    error
+    error,
+    skipLoading: true
   };
 };
 
diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx
new file mode 100644
index 000000000..503c2bfeb
--- /dev/null
+++ b/app/assets/javascripts/components/actions/cards.jsx
@@ -0,0 +1,47 @@
+import api from '../api';
+
+export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
+export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
+export const STATUS_CARD_FETCH_FAIL    = 'STATUS_CARD_FETCH_FAIL';
+
+export function fetchStatusCard(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchStatusCardRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
+      if (!response.data.url || !response.data.title || !response.data.description) {
+        return;
+      }
+
+      dispatch(fetchStatusCardSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchStatusCardFail(id, error));
+    });
+  };
+};
+
+export function fetchStatusCardRequest(id) {
+  return {
+    type: STATUS_CARD_FETCH_REQUEST,
+    id,
+    skipLoading: true
+  };
+};
+
+export function fetchStatusCardSuccess(id, card) {
+  return {
+    type: STATUS_CARD_FETCH_SUCCESS,
+    id,
+    card,
+    skipLoading: true
+  };
+};
+
+export function fetchStatusCardFail(id, error) {
+  return {
+    type: STATUS_CARD_FETCH_FAIL,
+    id,
+    error,
+    skipLoading: true
+  };
+};
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index 05674ba89..6d0188166 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -23,6 +23,8 @@ export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
 export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
 
 export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
+export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
 export const COMPOSE_VISIBILITY_CHANGE  = 'COMPOSE_VISIBILITY_CHANGE';
 export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
 
@@ -68,6 +70,7 @@ export function submitCompose() {
       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
       media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
       sensitive: getState().getIn(['compose', 'sensitive']),
+      spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
       visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
     }).then(function (response) {
       dispatch(submitComposeSuccess({ ...response.data }));
@@ -218,6 +221,20 @@ export function changeComposeSensitivity(checked) {
   };
 };
 
+export function changeComposeSpoilerness(checked) {
+  return {
+    type: COMPOSE_SPOILERNESS_CHANGE,
+    checked
+  };
+};
+
+export function changeComposeSpoilerText(text) {
+  return {
+    type: COMPOSE_SPOILER_TEXT_CHANGE,
+    text
+  };
+};
+
 export function changeComposeVisibility(checked) {
   return {
     type: COMPOSE_VISIBILITY_CHANGE,
diff --git a/app/assets/javascripts/components/actions/favourites.jsx b/app/assets/javascripts/components/actions/favourites.jsx
new file mode 100644
index 000000000..a25c1ae1c
--- /dev/null
+++ b/app/assets/javascripts/components/actions/favourites.jsx
@@ -0,0 +1,83 @@
+import api, { getLinks } from '../api'
+
+export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
+export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
+export const FAVOURITED_STATUSES_FETCH_FAIL    = 'FAVOURITED_STATUSES_FETCH_FAIL';
+
+export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
+export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
+export const FAVOURITED_STATUSES_EXPAND_FAIL    = 'FAVOURITED_STATUSES_EXPAND_FAIL';
+
+export function fetchFavouritedStatuses() {
+  return (dispatch, getState) => {
+    dispatch(fetchFavouritedStatusesRequest());
+
+    api(getState).get('/api/v1/favourites').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(fetchFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function fetchFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_REQUEST
+  };
+};
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next
+  };
+};
+
+export function fetchFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_FAIL,
+    error
+  };
+};
+
+export function expandFavouritedStatuses() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFavouritedStatusesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function expandFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_REQUEST
+  };
+};
+
+export function expandFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
+    statuses,
+    next
+  };
+};
+
+export function expandFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_FAIL,
+    error
+  };
+};
diff --git a/app/assets/javascripts/components/actions/meta.jsx b/app/assets/javascripts/components/actions/meta.jsx
deleted file mode 100644
index d0adbce3f..000000000
--- a/app/assets/javascripts/components/actions/meta.jsx
+++ /dev/null
@@ -1,8 +0,0 @@
-export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET';
-
-export function setAccessToken(token) {
-  return {
-    type: ACCESS_TOKEN_SET,
-    token: token
-  };
-};
diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx
index 8bd835406..1731c1857 100644
--- a/app/assets/javascripts/components/actions/notifications.jsx
+++ b/app/assets/javascripts/components/actions/notifications.jsx
@@ -14,8 +14,6 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
 export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
 export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
 
-export const NOTIFICATIONS_SETTING_CHANGE = 'NOTIFICATIONS_SETTING_CHANGE';
-
 const fetchRelatedRelationships = (dispatch, notifications) => {
   const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
 
@@ -26,21 +24,25 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
 
 export function updateNotifications(notification, intlMessages, intlLocale) {
   return (dispatch, getState) => {
+    const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+    const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
     dispatch({
       type: NOTIFICATIONS_UPDATE,
       notification,
       account: notification.account,
-      status: notification.status
+      status: notification.status,
+      meta: playSound ? { sound: 'boop' } : undefined
     });
 
     fetchRelatedRelationships(dispatch, [notification]);
 
     // Desktop notifications
-    if (typeof window.Notification !== 'undefined' && getState().getIn(['notifications', 'settings', 'alerts', notification.type], false)) {
+    if (typeof window.Notification !== 'undefined' && showAlert) {
       const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
       const body  = $('<p>').html(notification.status ? notification.status.content : '').text();
 
-      new Notification(title, { body, icon: notification.account.avatar });
+      new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
     }
   };
 };
@@ -94,13 +96,17 @@ export function expandNotifications() {
   return (dispatch, getState) => {
     const url = getState().getIn(['notifications', 'next'], null);
 
-    if (url === null) {
+    if (url === null || getState().getIn(['notifications', 'isLoading'])) {
       return;
     }
 
     dispatch(expandNotificationsRequest());
 
-    api(getState).get(url).then(response => {
+    api(getState).get(url, {
+      params: {
+        limit: 5
+      }
+    }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
 
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
@@ -133,11 +139,3 @@ export function expandNotificationsFail(error) {
     error
   };
 };
-
-export function changeNotificationsSetting(key, checked) {
-  return {
-    type: NOTIFICATIONS_SETTING_CHANGE,
-    key,
-    checked
-  };
-};
diff --git a/app/assets/javascripts/components/actions/settings.jsx b/app/assets/javascripts/components/actions/settings.jsx
new file mode 100644
index 000000000..c754b30ca
--- /dev/null
+++ b/app/assets/javascripts/components/actions/settings.jsx
@@ -0,0 +1,19 @@
+import axios from 'axios';
+
+export const SETTING_CHANGE = 'SETTING_CHANGE';
+
+export function changeSetting(key, value) {
+  return {
+    type: SETTING_CHANGE,
+    key,
+    value
+  };
+};
+
+export function saveSettings() {
+  return (_, getState) => {
+    axios.put('/api/web/settings', {
+      data: getState().get('settings').toJS()
+    });
+  };
+};
diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx
index cbee94bca..9ac215727 100644
--- a/app/assets/javascripts/components/actions/statuses.jsx
+++ b/app/assets/javascripts/components/actions/statuses.jsx
@@ -1,6 +1,7 @@
 import api from '../api';
 
 import { deleteFromTimelines } from './timelines';
+import { fetchStatusCard } from './cards';
 
 export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
 export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@@ -14,39 +15,44 @@ export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
 export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
 export const CONTEXT_FETCH_FAIL    = 'CONTEXT_FETCH_FAIL';
 
-export function fetchStatusRequest(id) {
+export function fetchStatusRequest(id, skipLoading) {
   return {
     type: STATUS_FETCH_REQUEST,
-    id: id
+    id,
+    skipLoading
   };
 };
 
 export function fetchStatus(id) {
   return (dispatch, getState) => {
-    dispatch(fetchStatusRequest(id));
+    const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+    dispatch(fetchStatusRequest(id, skipLoading));
 
     api(getState).get(`/api/v1/statuses/${id}`).then(response => {
-      dispatch(fetchStatusSuccess(response.data));
+      dispatch(fetchStatusSuccess(response.data, skipLoading));
       dispatch(fetchContext(id));
+      dispatch(fetchStatusCard(id));
     }).catch(error => {
-      dispatch(fetchStatusFail(id, error));
+      dispatch(fetchStatusFail(id, error, skipLoading));
     });
   };
 };
 
-export function fetchStatusSuccess(status, context) {
+export function fetchStatusSuccess(status, skipLoading) {
   return {
     type: STATUS_FETCH_SUCCESS,
-    status: status,
-    context: context
+    status,
+    skipLoading
   };
 };
 
-export function fetchStatusFail(id, error) {
+export function fetchStatusFail(id, error, skipLoading) {
   return {
     type: STATUS_FETCH_FAIL,
-    id: id,
-    error: error
+    id,
+    error,
+    skipLoading
   };
 };
 
diff --git a/app/assets/javascripts/components/actions/store.jsx b/app/assets/javascripts/components/actions/store.jsx
new file mode 100644
index 000000000..3bba99549
--- /dev/null
+++ b/app/assets/javascripts/components/actions/store.jsx
@@ -0,0 +1,17 @@
+import Immutable from 'immutable';
+
+export const STORE_HYDRATE = 'STORE_HYDRATE';
+
+const convertState = rawState =>
+  Immutable.fromJS(rawState, (k, v) =>
+    Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
+      Number.isNaN(x * 1) ? x : x * 1));
+
+export function hydrateStore(rawState) {
+  const state = convertState(rawState);
+
+  return {
+    type: STORE_HYDRATE,
+    state
+  };
+};
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 0e6f09190..29a060e87 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -14,11 +14,12 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
-export function refreshTimelineSuccess(timeline, statuses) {
+export function refreshTimelineSuccess(timeline, statuses, skipLoading) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
-    timeline: timeline,
-    statuses: statuses
+    timeline,
+    statuses,
+    skipLoading
   };
 };
 
@@ -39,55 +40,65 @@ export function deleteFromTimelines(id) {
   return (dispatch, getState) => {
     const accountId  = getState().getIn(['statuses', id, 'account']);
     const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
+    const reblogOf   = getState().getIn(['statuses', id, 'reblog'], null);
 
     dispatch({
       type: TIMELINE_DELETE,
       id,
       accountId,
-      references
+      references,
+      reblogOf
     });
   };
 };
 
-export function refreshTimelineRequest(timeline, id) {
+export function refreshTimelineRequest(timeline, id, skipLoading) {
   return {
     type: TIMELINE_REFRESH_REQUEST,
     timeline,
-    id
+    id,
+    skipLoading
   };
 };
 
 export function refreshTimeline(timeline, id = null) {
   return function (dispatch, getState) {
-    dispatch(refreshTimelineRequest(timeline, id));
+    if (getState().getIn(['timelines', timeline, 'isLoading'])) {
+      return;
+    }
 
     const ids      = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
     const newestId = ids.size > 0 ? ids.first() : null;
 
-    let params = '';
-    let path   = timeline;
+    let params      = '';
+    let path        = timeline;
+    let skipLoading = false;
 
     if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) {
-      params = `?since_id=${newestId}`;
+      params      = `?since_id=${newestId}`;
+      skipLoading = true;
     }
 
     if (id) {
       path = `${path}/${id}`
     }
 
+    dispatch(refreshTimelineRequest(timeline, id, skipLoading));
+
     api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
-      dispatch(refreshTimelineSuccess(timeline, response.data));
+      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading));
     }).catch(function (error) {
-      dispatch(refreshTimelineFail(timeline, error));
+      dispatch(refreshTimelineFail(timeline, error, skipLoading));
     });
   };
 };
 
-export function refreshTimelineFail(timeline, error) {
+export function refreshTimelineFail(timeline, error, skipLoading) {
   return {
     type: TIMELINE_REFRESH_FAIL,
     timeline,
-    error
+    error,
+    skipLoading
   };
 };
 
@@ -95,6 +106,12 @@ export function expandTimeline(timeline, id = null) {
   return (dispatch, getState) => {
     const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
 
+    if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) {
+      // If timeline is empty, don't try to load older posts since there are none
+      // Also if already loading
+      return;
+    }
+
     dispatch(expandTimelineRequest(timeline));
 
     let path = timeline;
@@ -103,7 +120,12 @@ export function expandTimeline(timeline, id = null) {
       path = `${path}/${id}`
     }
 
-    api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => {
+    api(getState).get(`/api/v1/timelines/${path}`, {
+      params: {
+        limit: 10,
+        max_id: lastId
+      }
+    }).then(response => {
       dispatch(expandTimelineSuccess(timeline, response.data));
     }).catch(error => {
       dispatch(expandTimelineFail(timeline, error));
diff --git a/app/assets/javascripts/components/components/account.jsx b/app/assets/javascripts/components/components/account.jsx
index 814d8a9c8..108401b2f 100644
--- a/app/assets/javascripts/components/components/account.jsx
+++ b/app/assets/javascripts/components/components/account.jsx
@@ -8,7 +8,9 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }
 });
 
 const outerStyle = {
@@ -42,7 +44,9 @@ const Account = React.createClass({
     account: ImmutablePropTypes.map.isRequired,
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func.isRequired,
-    withNote: React.PropTypes.bool
+    onBlock: React.PropTypes.func.isRequired,
+    withNote: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired
   },
 
   getDefaultProps () {
@@ -57,6 +61,10 @@ const Account = React.createClass({
     this.props.onFollow(this.props.account);
   },
 
+  handleBlock () {
+    this.props.onBlock(this.props.account);
+  },
+
   render () {
     const { account, me, withNote, intl } = this.props;
 
@@ -70,10 +78,18 @@ const Account = React.createClass({
       note = <div style={noteStyle}>{account.get('note')}</div>;
     }
 
-    if (account.get('id') !== me && account.get('relationship', null) != null) {
+    if (account.get('id') !== me && account.get('relationship', null) !== null) {
       const following = account.getIn(['relationship', 'following']);
-
-      buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      const requested = account.getIn(['relationship', 'requested']);
+      const blocking  = account.getIn(['relationship', 'blocking']);
+
+      if (requested) {
+        buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
+      } else if (blocking) {
+        buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
+      } else {
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      }
     }
 
     return (
diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
index 39ccbcaf9..81ec7a236 100644
--- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx
+++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
@@ -38,7 +38,8 @@ const AutosuggestTextarea = React.createClass({
     onSuggestionsClearRequested: React.PropTypes.func.isRequired,
     onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
     onChange: React.PropTypes.func.isRequired,
-    onKeyUp: React.PropTypes.func
+    onKeyUp: React.PropTypes.func,
+    onKeyDown: React.PropTypes.func
   },
 
   getInitialState () {
@@ -108,15 +109,28 @@ const AutosuggestTextarea = React.createClass({
 
         break;
     }
+
+    if (e.defaultPrevented || !this.props.onKeyDown) {
+      return;
+    }
+
+    this.props.onKeyDown(e);
   },
 
   onBlur () {
-    this.setState({ suggestionsHidden: true });
+    // If we hide the suggestions immediately, then this will prevent the
+    // onClick for the suggestions themselves from firing.
+    // Setting a short window for that to take place before hiding the
+    // suggestions ensures that can't happen.
+    setTimeout(() => {
+      this.setState({ suggestionsHidden: true });
+    }, 100);
   },
 
   onSuggestionClick (suggestion, e) {
     e.preventDefault();
     this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+    this.textarea.focus();
   },
 
   componentWillReceiveProps (nextProps) {
diff --git a/app/assets/javascripts/components/components/avatar.jsx b/app/assets/javascripts/components/components/avatar.jsx
index 687aa7bb9..b8420014b 100644
--- a/app/assets/javascripts/components/components/avatar.jsx
+++ b/app/assets/javascripts/components/components/avatar.jsx
@@ -8,12 +8,41 @@ const Avatar = React.createClass({
     style: React.PropTypes.object
   },
 
+  getInitialState () {
+    return {
+      hovering: false
+    };
+  },
+
   mixins: [PureRenderMixin],
 
+  handleMouseEnter () {
+    this.setState({ hovering: true });
+  },
+
+  handleMouseLeave () {
+    this.setState({ hovering: false });
+  },
+
+  handleLoad () {
+    this.canvas.getContext('2d').drawImage(this.image, 0, 0, this.props.size, this.props.size);
+  },
+
+  setImageRef (c) {
+    this.image = c;
+  },
+
+  setCanvasRef (c) {
+    this.canvas = c;
+  },
+
   render () {
+    const { hovering } = this.state;
+
     return (
-      <div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}>
-        <img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} />
+      <div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}>
+        <img ref={this.setImageRef} onLoad={this.handleLoad} src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ position: 'absolute', top: '0', left: '0', visibility: hovering ? 'visible' : 'hidden', borderRadius: '4px' }} />
+        <canvas ref={this.setCanvasRef} width={this.props.size} height={this.props.size} style={{ borderRadius: '4px' }} />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx
index d63129013..19c52550a 100644
--- a/app/assets/javascripts/components/components/button.jsx
+++ b/app/assets/javascripts/components/components/button.jsx
@@ -27,7 +27,7 @@ const Button = React.createClass({
 
   render () {
     const style = {
-      fontFamily: 'Roboto',
+      fontFamily: 'inherit',
       display: this.props.block ? 'block' : 'inline-block',
       width: this.props.block ? '100%' : 'auto',
       position: 'relative',
diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx
new file mode 100644
index 000000000..203dc5e0c
--- /dev/null
+++ b/app/assets/javascripts/components/components/column_collapsable.jsx
@@ -0,0 +1,60 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Motion, spring } from 'react-motion';
+
+const iconStyle = {
+  fontSize: '16px',
+  padding: '15px',
+  position: 'absolute',
+  right: '0',
+  top: '-48px',
+  cursor: 'pointer'
+};
+
+const ColumnCollapsable = React.createClass({
+
+  propTypes: {
+    icon: React.PropTypes.string.isRequired,
+    fullHeight: React.PropTypes.number.isRequired,
+    children: React.PropTypes.node,
+    onCollapse: React.PropTypes.func
+  },
+
+  getInitialState () {
+    return {
+      collapsed: true
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleToggleCollapsed () {
+    const currentState = this.state.collapsed;
+
+    this.setState({ collapsed: !currentState });
+
+    if (!currentState && this.props.onCollapse) {
+      this.props.onCollapse();
+    }
+  },
+
+  render () {
+    const { icon, fullHeight, children } = this.props;
+    const { collapsed } = this.state;
+
+    return (
+      <div style={{ position: 'relative' }}>
+        <div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
+
+        <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
+          {({ opacity, height }) =>
+            <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
+              {children}
+            </div>
+          }
+        </Motion>
+      </div>
+    );
+  }
+});
+
+export default ColumnCollapsable;
diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx
index 450550d55..ffef29c00 100644
--- a/app/assets/javascripts/components/components/dropdown_menu.jsx
+++ b/app/assets/javascripts/components/components/dropdown_menu.jsx
@@ -1,13 +1,15 @@
 import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
 
-const DropdownMenu = ({ icon, items, size }) => {
+const DropdownMenu = ({ icon, items, size, direction }) => {
+  const directionClass = (direction == "left") ? "dropdown__left" : "dropdown__right";
+
   return (
     <Dropdown>
       <DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}>
         <i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} />
       </DropdownTrigger>
 
-      <DropdownContent style={{ lineHeight: '18px', textAlign: 'left' }}>
+      <DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
         <ul>
           {items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
             if (typeof action === 'function') {
diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx
index e9a7228e4..f9b6192c0 100644
--- a/app/assets/javascripts/components/components/icon_button.jsx
+++ b/app/assets/javascripts/components/components/icon_button.jsx
@@ -1,4 +1,5 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Motion, spring } from 'react-motion';
 
 const IconButton = React.createClass({
 
@@ -10,14 +11,16 @@ const IconButton = React.createClass({
     active: React.PropTypes.bool,
     style: React.PropTypes.object,
     activeStyle: React.PropTypes.object,
-    disabled: React.PropTypes.bool
+    disabled: React.PropTypes.bool,
+    animate: React.PropTypes.bool
   },
 
   getDefaultProps () {
     return {
       size: 18,
       active: false,
-      disabled: false
+      disabled: false,
+      animate: false
     };
   },
 
@@ -49,9 +52,18 @@ const IconButton = React.createClass({
     }
 
     return (
-      <button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} onClick={this.handleClick} style={style}>
-        <i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
-      </button>
+      <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
+        {({ rotate }) =>
+          <button
+            aria-label={this.props.title}
+            title={this.props.title}
+            className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`}
+            onClick={this.handleClick}
+            style={style}>
+            <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
+          </button>
+        }
+      </Motion>
     );
   }
 
diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx
index b5c2a69d8..1e3a88955 100644
--- a/app/assets/javascripts/components/components/lightbox.jsx
+++ b/app/assets/javascripts/components/components/lightbox.jsx
@@ -35,7 +35,9 @@ const Lightbox = React.createClass({
   propTypes: {
     isVisible: React.PropTypes.bool,
     onOverlayClicked: React.PropTypes.func,
-    onCloseClicked: React.PropTypes.func
+    onCloseClicked: React.PropTypes.func,
+    intl: React.PropTypes.object.isRequired,
+    children: React.PropTypes.node
   },
 
   mixins: [PureRenderMixin],
@@ -57,19 +59,17 @@ 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)`}}>
+      <Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
+        {({ backgroundOpacity, opacity, y }) =>
+          <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}>
+            <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}>
               <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
-              {content}
+              {children}
             </div>
-          }
-        </Motion>
-      </div>
+          </div>
+        }
+      </Motion>
     );
   }
 
diff --git a/app/assets/javascripts/components/components/loading_indicator.jsx b/app/assets/javascripts/components/components/loading_indicator.jsx
index fd5acae84..c8a263924 100644
--- a/app/assets/javascripts/components/components/loading_indicator.jsx
+++ b/app/assets/javascripts/components/components/loading_indicator.jsx
@@ -1,15 +1,17 @@
 import { FormattedMessage } from 'react-intl';
 
-const LoadingIndicator = () => {
-  const style = {
-    textAlign: 'center',
-    fontSize: '16px',
-    fontWeight: '500',
-    color: '#616b86',
-    paddingTop: '120px'
-  };
-
-  return <div style={style}><FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /></div>;
+const style = {
+  textAlign: 'center',
+  fontSize: '16px',
+  fontWeight: '500',
+  color: '#616b86',
+  paddingTop: '120px'
 };
 
+const LoadingIndicator = () => (
+  <div style={style}>
+    <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
+  </div>
+);
+
 export default LoadingIndicator;
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index 9aafd8181..7e92abe2d 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -1,12 +1,18 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import { FormattedMessage } from 'react-intl';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
+});
 
 const outerStyle = {
   marginTop: '8px',
   overflow: 'hidden',
   width: '100%',
-  boxSizing: 'border-box'
+  boxSizing: 'border-box',
+  position: 'relative'
 };
 
 const spoilerStyle = {
@@ -32,11 +38,18 @@ const spoilerSubSpanStyle = {
   fontWeight: '500'
 };
 
+const spoilerButtonStyle = {
+  position: 'absolute',
+  top: '6px',
+  left: '8px',
+  zIndex: '100'
+};
+
 const MediaGallery = React.createClass({
 
   getInitialState () {
     return {
-      visible: false
+      visible: !this.props.sensitive
     };
   },
 
@@ -59,21 +72,30 @@ const MediaGallery = React.createClass({
   },
 
   handleOpen () {
-    this.setState({ visible: true });
+    this.setState({ visible: !this.state.visible });
   },
 
   render () {
-    const { media, sensitive } = this.props;
+    const { media, intl, sensitive } = this.props;
 
     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>
-      );
+    if (!this.state.visible) {
+      if (sensitive) {
+        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 {
+        children = (
+          <div style={spoilerStyle} onClick={this.handleOpen}>
+            <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
+            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      }
     } else {
       const size = media.take(4).size;
 
@@ -134,9 +156,12 @@ const MediaGallery = React.createClass({
         );
       });
     }
-
+    
     return (
       <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
+        <div style={spoilerButtonStyle} >
+          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
+        </div>
         {children}
       </div>
     );
@@ -144,4 +169,4 @@ const MediaGallery = React.createClass({
 
 });
 
-export default MediaGallery;
+export default injectIntl(MediaGallery);
diff --git a/app/assets/javascripts/components/components/missing_indicator.jsx b/app/assets/javascripts/components/components/missing_indicator.jsx
new file mode 100644
index 000000000..ed8b4fe24
--- /dev/null
+++ b/app/assets/javascripts/components/components/missing_indicator.jsx
@@ -0,0 +1,17 @@
+import { FormattedMessage } from 'react-intl';
+
+const style = {
+  textAlign: 'center',
+  fontSize: '16px',
+  fontWeight: '500',
+  color: '#616b86',
+  paddingTop: '120px'
+};
+
+const MissingIndicator = () => (
+  <div style={style}>
+    <FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
+  </div>
+);
+
+export default MissingIndicator;
diff --git a/app/assets/javascripts/components/components/relative_timestamp.jsx b/app/assets/javascripts/components/components/relative_timestamp.jsx
index 3a5b88523..3b012b184 100644
--- a/app/assets/javascripts/components/components/relative_timestamp.jsx
+++ b/app/assets/javascripts/components/components/relative_timestamp.jsx
@@ -1,15 +1,18 @@
-import {
-  FormattedMessage,
-  FormattedDate,
-  FormattedRelative
-} from 'react-intl';
-
-const RelativeTimestamp = ({ timestamp }) => {
-  return <FormattedRelative value={new Date(timestamp)} />;
+import { injectIntl, FormattedRelative } from 'react-intl';
+
+const RelativeTimestamp = ({ intl, timestamp }) => {
+  const date = new Date(timestamp);
+
+  return (
+    <time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
+      <FormattedRelative value={date} />
+    </time>
+  );
 };
 
 RelativeTimestamp.propTypes = {
+  intl: React.PropTypes.object.isRequired,
   timestamp: React.PropTypes.string.isRequired
 };
 
-export default RelativeTimestamp;
+export default injectIntl(RelativeTimestamp);
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index afaf82561..f2cc1fb12 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -49,7 +49,7 @@ const StatusActionBar = React.createClass({
   },
 
   handleMentionClick () {
-    this.props.onMention(this.props.status.get('account'));
+    this.props.onMention(this.props.status.get('account'), this.context.router);
   },
 
   handleBlockClick () {
@@ -77,10 +77,10 @@ const StatusActionBar = React.createClass({
       <div style={{ marginTop: '10px', overflow: 'hidden' }}>
         <div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
         <div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
-        <div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        <div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
 
         <div style={{ width: '18px', height: '18px', float: 'left' }}>
-          <DropdownMenu items={menu} icon='ellipsis-h' size={18} />
+          <DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" />
         </div>
       </div>
     );
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index f2c88cee0..521b557f0 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -1,6 +1,7 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import emojify from '../emoji';
+import { FormattedMessage } from 'react-intl';
 
 const StatusContent = React.createClass({
 
@@ -13,6 +14,12 @@ const StatusContent = React.createClass({
     onClick: React.PropTypes.func
   },
 
+  getInitialState () {
+    return {
+      hidden: true
+    };
+  },
+
   mixins: [PureRenderMixin],
 
   componentDidMount () {
@@ -31,8 +38,6 @@ const StatusContent = React.createClass({
         link.setAttribute('target', '_blank');
         link.setAttribute('rel', 'noopener');
       }
-
-      link.addEventListener('click', this.onNormalClick, false);
     }
   },
 
@@ -52,16 +57,59 @@ const StatusContent = React.createClass({
     }
   },
 
-  onNormalClick (e) {
-    e.stopPropagation();
+  handleMouseDown (e) {
+    this.startXY = [e.clientX, e.clientY];
+  },
+
+  handleMouseUp (e) {
+    const [ startX, startY ] = this.startXY;
+    const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+    if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) {
+      return;
+    }
+
+    if (deltaX + deltaY < 5 && e.button === 0) {
+      this.props.onClick();
+    }
+
+    this.startXY = null;
+  },
+
+  handleSpoilerClick () {
+    this.setState({ hidden: !this.state.hidden });
   },
 
   render () {
-    const { status, onClick } = this.props;
+    const { status } = this.props;
+    const { hidden } = this.state;
 
     const content = { __html: emojify(status.get('content')) };
-
-    return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={onClick} />;
+    const spoilerContent = { __html: emojify(status.get('spoiler_text', '')) };
+
+    if (status.get('spoiler_text').length > 0) {
+      const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
+
+      return (
+        <div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+          <p style={{ marginBottom: hidden ? '0px' : '' }} >
+            <span dangerouslySetInnerHTML={spoilerContent} /> <a onClick={this.handleSpoilerClick}>{toggleText}</a>
+          </p>
+
+          <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} />
+        </div>
+      );
+    } else {
+      return (
+        <div
+          className='status__content'
+          style={{ cursor: 'pointer' }}
+          onMouseDown={this.handleMouseDown}
+          onMouseUp={this.handleMouseUp}
+          dangerouslySetInnerHTML={content}
+        />
+      );
+    }
   },
 
 });
diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx
index e0a73435f..69cc013f2 100644
--- a/app/assets/javascripts/components/components/status_list.jsx
+++ b/app/assets/javascripts/components/components/status_list.jsx
@@ -11,7 +11,8 @@ const StatusList = React.createClass({
     onScrollToBottom: React.PropTypes.func,
     onScrollToTop: React.PropTypes.func,
     onScroll: React.PropTypes.func,
-    trackScroll: React.PropTypes.bool
+    trackScroll: React.PropTypes.bool,
+    isLoading: React.PropTypes.bool
   },
 
   getDefaultProps () {
@@ -24,10 +25,10 @@ const StatusList = React.createClass({
 
   handleScroll (e) {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
-
+    const offset = scrollHeight - scrollTop - clientHeight;
     this._oldScrollPosition = scrollHeight - scrollTop;
 
-    if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) {
+    if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
       this.props.onScrollToBottom();
     } else if (scrollTop < 100 && this.props.onScrollToTop) {
       this.props.onScrollToTop();
@@ -36,21 +37,37 @@ const StatusList = React.createClass({
     }
   },
 
-  componentDidUpdate (prevProps) {
-    if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
-      const node = ReactDOM.findDOMNode(this);
+  componentDidMount () {
+    this.attachScrollListener();
+  },
 
-      if (node.scrollTop > 0) {
-        node.scrollTop = node.scrollHeight - this._oldScrollPosition;
-      }
+  componentDidUpdate (prevProps) {
+    if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) {
+      this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
     }
   },
 
+  componentWillUnmount () {
+    this.detachScrollListener();
+  },
+
+  attachScrollListener () {
+    this.node.addEventListener('scroll', this.handleScroll);
+  },
+
+  detachScrollListener () {
+    this.node.removeEventListener('scroll', this.handleScroll);
+  },
+
+  setRef (c) {
+    this.node = c;
+  },
+
   render () {
     const { statusIds, onScrollToBottom, trackScroll } = this.props;
 
     const scrollableArea = (
-      <div className='scrollable' onScroll={this.handleScroll}>
+      <div className='scrollable' ref={this.setRef}>
         <div>
           {statusIds.map((statusId) => {
             return <StatusContainer key={statusId} id={statusId} />;
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index 8f64ad3cd..3edc8f672 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -4,7 +4,8 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 
 const messages = defineMessages({
-  toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }
+  toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
+  toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }
 });
 
 const videoStyle = {
@@ -20,7 +21,7 @@ const videoStyle = {
 const muteStyle = {
   position: 'absolute',
   top: '10px',
-  left: '10px',
+  right: '10px',
   opacity: '0.8',
   zIndex: '5'
 };
@@ -35,7 +36,8 @@ const spoilerStyle = {
   display: 'flex',
   alignItems: 'center',
   justifyContent: 'center',
-  flexDirection: 'column'
+  flexDirection: 'column',
+  position: 'relative'
 };
 
 const spoilerSpanStyle = {
@@ -49,6 +51,13 @@ const spoilerSubSpanStyle = {
   fontWeight: '500'
 };
 
+const spoilerButtonStyle = {
+  position: 'absolute',
+  top: '6px',
+  left: '8px',
+  zIndex: '100'
+};
+
 const VideoPlayer = React.createClass({
   propTypes: {
     media: ImmutablePropTypes.map.isRequired,
@@ -66,7 +75,8 @@ const VideoPlayer = React.createClass({
 
   getInitialState () {
     return {
-      visible: false,
+      visible: !this.props.sensitive,
+      preview: true,
       muted: true
     };
   },
@@ -90,22 +100,49 @@ const VideoPlayer = React.createClass({
   },
 
   handleOpen () {
-    this.setState({ visible: true });
+    this.setState({ preview: !this.state.preview });
+  },
+
+  handleVisibility () {
+    this.setState({
+      visible: !this.state.visible,
+      preview: true
+    });
   },
 
   render () {
     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>
-      );
-    } else if (!sensitive && !this.state.visible) {
+    let spoilerButton = (
+      <div style={spoilerButtonStyle} >
+        <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
+      </div>
+    );
+
+    if (!this.state.visible) {
+      if (sensitive) {
+        return (
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleVisibility}>
+            {spoilerButton}
+            <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 {
+        return (
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
+            {spoilerButton}
+            <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
+            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      }
+    }
+
+    if (this.state.preview) {
       return (
         <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
+          {spoilerButton}
           <div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
         </div>
       );
@@ -113,7 +150,8 @@ const VideoPlayer = React.createClass({
 
     return (
       <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
-        <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
+        {spoilerButton}
+        <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
         <video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
       </div>
     );
diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx
index 1f49f9819..889c0ac4c 100644
--- a/app/assets/javascripts/components/containers/account_container.jsx
+++ b/app/assets/javascripts/components/containers/account_container.jsx
@@ -3,7 +3,9 @@ import { makeGetAccount } from '../selectors';
 import Account from '../components/account';
 import {
   followAccount,
-  unfollowAccount
+  unfollowAccount,
+  blockAccount,
+  unblockAccount
 } from '../actions/accounts';
 
 const makeMapStateToProps = () => {
@@ -24,6 +26,14 @@ const mapDispatchToProps = (dispatch) => ({
     } else {
       dispatch(followAccount(account.get('id')));
     }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(blockAccount(account.get('id')));
+    }
   }
 });
 
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index 670455376..5f4b2cf79 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -7,15 +7,13 @@ import {
   refreshTimeline
 } from '../actions/timelines';
 import { updateNotifications } from '../actions/notifications';
-import { setAccessToken } from '../actions/meta';
-import { setAccountSelf } from '../actions/accounts';
-import PureRenderMixin from 'react-addons-pure-render-mixin';
 import createBrowserHistory from 'history/lib/createBrowserHistory';
 import {
   applyRouterMiddleware,
   useRouterHistory,
   Router,
   Route,
+  IndexRedirect,
   IndexRoute
 } from 'react-router';
 import { useScroll } from 'react-router-scroll';
@@ -35,6 +33,8 @@ import Favourites from '../features/favourites';
 import HashtagTimeline from '../features/hashtag_timeline';
 import Notifications from '../features/notifications';
 import FollowRequests from '../features/follow_requests';
+import GenericNotFound from '../features/generic_not_found';
+import FavouritedStatuses from '../features/favourited_statuses';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import en from 'react-intl/locale-data/en';
 import de from 'react-intl/locale-data/de';
@@ -44,9 +44,12 @@ import pt from 'react-intl/locale-data/pt';
 import hu from 'react-intl/locale-data/hu';
 import uk from 'react-intl/locale-data/uk';
 import getMessagesForLocale from '../locales';
+import { hydrateStore } from '../actions/store';
 
 const store = configureStore();
 
+store.dispatch(hydrateStore(window.INITIAL_STATE));
+
 const browserHistory = useRouterHistory(createBrowserHistory)({
   basename: '/web'
 });
@@ -56,31 +59,26 @@ addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
 const Mastodon = React.createClass({
 
   propTypes: {
-    token: React.PropTypes.string.isRequired,
-    timelines: React.PropTypes.object,
-    account: React.PropTypes.string,
     locale: React.PropTypes.string.isRequired
   },
 
-  mixins: [PureRenderMixin],
-
   componentWillMount() {
-    const { token, account, locale } = this.props;
-
-    store.dispatch(setAccessToken(token));
-    store.dispatch(setAccountSelf(JSON.parse(account)));
+    const { locale } = this.props;
 
     if (typeof App !== 'undefined') {
       this.subscription = App.cable.subscriptions.create('TimelineChannel', {
 
         received (data) {
           switch(data.type) {
-            case 'update':
-              return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
-            case 'delete':
-              return store.dispatch(deleteFromTimelines(data.id));
-            case 'notification':
-              return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
+          case 'update':
+            store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
+            break;
+          case 'delete':
+            store.dispatch(deleteFromTimelines(data.id));
+            break;
+          case 'notification':
+            store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
+            break;
           }
         }
 
@@ -107,14 +105,16 @@ const Mastodon = React.createClass({
         <Provider store={store}>
           <Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
             <Route path='/' component={UI}>
-              <IndexRoute component={GettingStarted} />
+              <IndexRedirect to="/getting-started" />
 
+              <Route path='getting-started' component={GettingStarted} />
               <Route path='timelines/home' component={HomeTimeline} />
               <Route path='timelines/mentions' component={MentionsTimeline} />
               <Route path='timelines/public' component={PublicTimeline} />
               <Route path='timelines/tag/:id' component={HashtagTimeline} />
 
               <Route path='notifications' component={Notifications} />
+              <Route path='favourites' component={FavouritedStatuses} />
 
               <Route path='statuses/new' component={Compose} />
               <Route path='statuses/:statusId' component={Status} />
@@ -128,6 +128,7 @@ const Mastodon = React.createClass({
               </Route>
 
               <Route path='follow_requests' component={FollowRequests} />
+              <Route path='*' component={GenericNotFound} />
             </Route>
           </Router>
         </Provider>
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index 6a882eab4..ad2be03d1 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -15,6 +15,7 @@ import { blockAccount } from '../actions/accounts';
 import { deleteStatus } from '../actions/statuses';
 import { openMedia } from '../actions/modal';
 import { createSelector } from 'reselect'
+import { isMobile } from '../is_mobile'
 
 const mapStateToProps = (state, props) => ({
   statusBase: state.getIn(['statuses', props.id]),
@@ -86,8 +87,11 @@ const mapDispatchToProps = (dispatch) => ({
     dispatch(deleteStatus(status.get('id')));
   },
 
-  onMention (account) {
+  onMention (account, router) {
     dispatch(mentionCompose(account));
+    if (isMobile(window.innerWidth)) {
+      router.push('/statuses/new');
+    }
   },
 
   onOpenMedia (url) {
diff --git a/app/assets/javascripts/components/emoji.jsx b/app/assets/javascripts/components/emoji.jsx
index a06c75953..c93c07c74 100644
--- a/app/assets/javascripts/components/emoji.jsx
+++ b/app/assets/javascripts/components/emoji.jsx
@@ -5,5 +5,5 @@ emojione.sprites      = false;
 emojione.imagePathPNG = '/emoji/';
 
 export default function emojify(text) {
-  return emojione.unicodeToImage(text);
+  return emojione.toImage(text);
 };
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 45de75d97..ab7b08dc7 100644
--- a/app/assets/javascripts/components/features/account/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx
@@ -66,7 +66,7 @@ const ActionBar = React.createClass({
     return (
       <div style={outerStyle}>
         <div style={outerDropdownStyle}>
-          <DropdownMenu items={menu} icon='bars' size={24} />
+          <DropdownMenu items={menu} icon='bars' size={24} direction="right" />
         </div>
 
         <div style={outerLinksStyle}>
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index 6ae5ac002..dead11265 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -71,8 +71,8 @@ const Header = React.createClass({
             <span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
           </a>
 
-          <span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
-          <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
+          <span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#489fde', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
+          <div style={{ color: '#d9e1e8', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
 
           {info}
           {actionBtn}
diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx
index c2cc58bb2..3a9b48f21 100644
--- a/app/assets/javascripts/components/features/account/index.jsx
+++ b/app/assets/javascripts/components/features/account/index.jsx
@@ -20,6 +20,7 @@ import LoadingIndicator      from '../../components/loading_indicator';
 import ActionBar             from './components/action_bar';
 import Column                from '../ui/components/column';
 import ColumnBackButton      from '../../components/column_back_button';
+import { isMobile } from '../../is_mobile'
 
 const makeMapStateToProps = () => {
   const getAccount = makeGetAccount();
@@ -34,11 +35,16 @@ const makeMapStateToProps = () => {
 
 const Account = React.createClass({
 
+  contextTypes: {
+    router: React.PropTypes.object
+  },
+
   propTypes: {
     params: React.PropTypes.object.isRequired,
     dispatch: React.PropTypes.func.isRequired,
     account: ImmutablePropTypes.map,
-    me: React.PropTypes.number.isRequired
+    me: React.PropTypes.number.isRequired,
+    children: React.PropTypes.node
   },
 
   mixins: [PureRenderMixin],
@@ -71,6 +77,9 @@ const Account = React.createClass({
 
   handleMention () {
     this.props.dispatch(mentionCompose(this.props.account));
+    if (isMobile(window.innerWidth)) {
+      this.context.router.push('/statuses/new');
+    }
   },
 
   render () {
diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx
index 7a3dbe160..5c09839f7 100644
--- a/app/assets/javascripts/components/features/account_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/index.jsx
@@ -9,7 +9,8 @@ import StatusList from '../../components/status_list';
 import LoadingIndicator from '../../components/loading_indicator';
 
 const mapStateToProps = (state, props) => ({
-  statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
+  statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items']),
+  isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
   me: state.getIn(['meta', 'me'])
 });
 
@@ -18,7 +19,9 @@ const AccountTimeline = React.createClass({
   propTypes: {
     params: React.PropTypes.object.isRequired,
     dispatch: React.PropTypes.func.isRequired,
-    statusIds: ImmutablePropTypes.list
+    statusIds: ImmutablePropTypes.list,
+    isLoading: React.PropTypes.bool,
+    me: React.PropTypes.number.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -38,13 +41,13 @@ const AccountTimeline = React.createClass({
   },
 
   render () {
-    const { statusIds, me } = this.props;
+    const { statusIds, isLoading, me } = this.props;
 
     if (!statusIds) {
       return <LoadingIndicator />;
     }
 
-    return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
+    return <StatusList statusIds={statusIds} isLoading={isLoading} me={me} onScrollToBottom={this.handleScrollToBottom} />
   }
 
 });
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 55f361b0b..48363a968 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -14,6 +14,7 @@ import { Motion, spring } from 'react-motion';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' },
   publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }
 });
 
@@ -25,6 +26,8 @@ const ComposeForm = React.createClass({
     suggestion_token: React.PropTypes.string,
     suggestions: ImmutablePropTypes.list,
     sensitive: React.PropTypes.bool,
+    spoiler: React.PropTypes.bool,
+    spoiler_text: React.PropTypes.string,
     unlisted: React.PropTypes.bool,
     private: React.PropTypes.bool,
     fileDropDate: React.PropTypes.instanceOf(Date),
@@ -32,6 +35,7 @@ const ComposeForm = React.createClass({
     is_uploading: React.PropTypes.bool,
     in_reply_to: ImmutablePropTypes.map,
     media_count: React.PropTypes.number,
+    me: React.PropTypes.number,
     onChange: React.PropTypes.func.isRequired,
     onSubmit: React.PropTypes.func.isRequired,
     onCancelReply: React.PropTypes.func.isRequired,
@@ -39,6 +43,8 @@ const ComposeForm = React.createClass({
     onFetchSuggestions: React.PropTypes.func.isRequired,
     onSuggestionSelected: React.PropTypes.func.isRequired,
     onChangeSensitivity: React.PropTypes.func.isRequired,
+    onChangeSpoilerness: React.PropTypes.func.isRequired,
+    onChangeSpoilerText: React.PropTypes.func.isRequired,
     onChangeVisibility: React.PropTypes.func.isRequired,
     onChangeListability: React.PropTypes.func.isRequired,
   },
@@ -49,7 +55,7 @@ const ComposeForm = React.createClass({
     this.props.onChange(e.target.value);
   },
 
-  handleKeyUp (e) {
+  handleKeyDown (e) {
     if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
       this.props.onSubmit();
     }
@@ -76,6 +82,15 @@ const ComposeForm = React.createClass({
     this.props.onChangeSensitivity(e.target.checked);
   },
 
+  handleChangeSpoilerness (e) {
+    this.props.onChangeSpoilerness(e.target.checked);
+    this.props.onChangeSpoilerText('');
+  },
+
+  handleChangeSpoilerText (e) {
+    this.props.onChangeSpoilerText(e.target.value);
+  },
+
   handleChangeVisibility (e) {
     this.props.onChangeVisibility(e.target.checked);
   },
@@ -85,7 +100,14 @@ const ComposeForm = React.createClass({
   },
 
   componentDidUpdate (prevProps) {
-    if (prevProps.in_reply_to !== this.props.in_reply_to) {
+    if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) {
+      // If replying to zero or one users, places the cursor at the end of the textbox.
+      // If replying to more than one user, selects any usernames past the first;
+      // this provides a convenient shortcut to drop everyone else from the conversation.
+      const selectionStart = this.props.text.search(/\s/) + 1;
+      const selectionEnd   = this.props.text.length;
+
+      this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
       this.autosuggestTextarea.textarea.focus();
     }
   },
@@ -103,8 +125,18 @@ const ComposeForm = React.createClass({
       replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
     }
 
+    let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
+
     return (
       <div style={{ padding: '10px' }}>
+        <Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}>
+          {({ opacity, height }) =>
+            <div className="spoiler-input" style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
+              <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" />
+            </div>
+          }
+        </Motion>
+
         {replyArea}
 
         <AutosuggestTextarea
@@ -115,7 +147,7 @@ const ComposeForm = React.createClass({
           value={this.props.text}
           onChange={this.handleChange}
           suggestions={this.props.suggestions}
-          onKeyUp={this.handleKeyUp}
+          onKeyDown={this.handleKeyDown}
           onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
           onSuggestionsClearRequested={this.onSuggestionsClearRequested}
           onSuggestionSelected={this.onSuggestionSelected}
@@ -123,7 +155,7 @@ const ComposeForm = React.createClass({
 
         <div style={{ marginTop: '10px', overflow: 'hidden' }}>
           <div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div>
-          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
+          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
           <UploadButtonContainer style={{ paddingTop: '4px' }} />
         </div>
 
@@ -132,7 +164,12 @@ const ComposeForm = React.createClass({
           <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
         </label>
 
-        <Motion defaultStyle={{ opacity: this.props.private ? 0 : 100, height: this.props.private ? 39.5 : 0 }} style={{ opacity: spring(this.props.private ? 0 : 100), height: spring(this.props.private ? 0 : 39.5) }}>
+        <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle' }}>
+          <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
+          <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide behind content warning' /></span>
+        </label>
+
+        <Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}>
           {({ opacity, height }) =>
             <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
               <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx
index d31d0e453..d0e865d29 100644
--- a/app/assets/javascripts/components/features/compose/components/drawer.jsx
+++ b/app/assets/javascripts/components/features/compose/components/drawer.jsx
@@ -1,26 +1,75 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Link } from 'react-router';
+import { injectIntl, defineMessages } from 'react-intl';
 
-const style = {
+const messages = defineMessages({
+  start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
+});
+
+const outerStyle = {
+  boxSizing: 'border-box',
+  display: 'flex',
+  flexDirection: 'column',
+  overflowY: 'hidden'
+};
+
+const innerStyle = {
   boxSizing: 'border-box',
-  background: '#454b5e',
   padding: '0',
   display: 'flex',
   flexDirection: 'column',
-  overflowY: 'auto'
+  overflowY: 'auto',
+  flexGrow: '1'
+};
+
+const tabStyle = {
+  display: 'block',
+  flex: '1 1 auto',
+  padding: '15px',
+  paddingBottom: '13px',
+  color: '#9baec8',
+  textDecoration: 'none',
+  textAlign: 'center',
+  fontSize: '16px',
+  borderBottom: '2px solid transparent'
 };
 
-const Drawer = React.createClass({
+const tabActiveStyle = {
+  color: '#2b90d9',
+  borderBottom: '2px solid #2b90d9'
+};
 
-  mixins: [PureRenderMixin],
+const Drawer = ({ children, withHeader, intl }) => {
+  let header = '';
 
-  render () {
-    return (
-      <div className='drawer' style={style}>
-        {this.props.children}
+  if (withHeader) {
+    header = (
+      <div className='drawer__header'>
+        <Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
+        <Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
+        <a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
+        <a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
       </div>
     );
   }
 
-});
+  return (
+    <div className='drawer' style={outerStyle}>
+      {header}
+
+      <div className='drawer__inner' style={innerStyle}>
+        {children}
+      </div>
+    </div>
+  );
+};
+
+Drawer.propTypes = {
+  withHeader: React.PropTypes.bool,
+  children: React.PropTypes.node,
+  intl: React.PropTypes.object
+};
 
-export default Drawer;
+export default injectIntl(Drawer);
diff --git a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
index df94c30d2..289e2dddf 100644
--- a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
+++ b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
@@ -16,12 +16,12 @@ const NavigationBar = React.createClass({
 
   render () {
     return (
-      <div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
+      <div style={{ padding: '10px', display: 'flex', flexShrink: '0', cursor: 'default' }}>
         <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink>
 
         <div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
           <strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
-          <a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.settings' defaultMessage='Settings' /></a> · <Link to='/timelines/public' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.public_timeline' defaultMessage='Public timeline' /></Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a>
+          <a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
         </div>
       </div>
     );
diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx
index b4e618820..e4672216b 100644
--- a/app/assets/javascripts/components/features/compose/components/search.jsx
+++ b/app/assets/javascripts/components/features/compose/components/search.jsx
@@ -38,7 +38,7 @@ const inputStyle = {
   border: 'none',
   padding: '10px',
   paddingRight: '30px',
-  fontFamily: 'Roboto',
+  fontFamily: 'inherit',
   background: '#282c37',
   color: '#9baec8',
   fontSize: '14px',
diff --git a/app/assets/javascripts/components/features/compose/components/upload_button.jsx b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
index 5250ff748..4c8181aa1 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
@@ -11,7 +11,9 @@ const UploadButton = React.createClass({
   propTypes: {
     disabled: React.PropTypes.bool,
     onSelectFile: React.PropTypes.func.isRequired,
-    style: React.PropTypes.object
+    style: React.PropTypes.object,
+    resetFileKey: React.PropTypes.number,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -31,12 +33,12 @@ const UploadButton = React.createClass({
   },
 
   render () {
-    const { intl } = this.props;
+    const { intl, resetFileKey, disabled } = this.props;
 
     return (
       <div style={this.props.style}>
-        <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={this.props.disabled} onClick={this.handleClick} size={24} />
-        <input ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
+        <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
+        <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/components/upload_form.jsx b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
index ac548033c..94c94b4b7 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
@@ -12,15 +12,20 @@ const UploadForm = React.createClass({
   propTypes: {
     media: ImmutablePropTypes.list.isRequired,
     is_uploading: React.PropTypes.bool,
-    onRemoveFile: React.PropTypes.func.isRequired
+    onRemoveFile: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
 
   render () {
-    const { intl } = this.props;
+    const { intl, media } = this.props;
 
-    const uploads = this.props.media.map(attachment => (
+    if (!media.size) {
+      return null;
+    }
+
+    const uploads = media.map(attachment => (
       <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
         <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
           <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
@@ -29,7 +34,7 @@ const UploadForm = React.createClass({
     ));
 
     return (
-      <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden' }}>
+      <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
         {uploads}
       </div>
     );
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 2b6ee1ae7..8ccfce059 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
@@ -8,6 +8,8 @@ import {
   fetchComposeSuggestions,
   selectComposeSuggestion,
   changeComposeSensitivity,
+  changeComposeSpoilerness,
+  changeComposeSpoilerText,
   changeComposeVisibility,
   changeComposeListability
 } from '../../../actions/compose';
@@ -22,13 +24,16 @@ const makeMapStateToProps = () => {
       suggestion_token: state.getIn(['compose', 'suggestion_token']),
       suggestions: state.getIn(['compose', 'suggestions']),
       sensitive: state.getIn(['compose', 'sensitive']),
+      spoiler: state.getIn(['compose', 'spoiler']),
+      spoiler_text: state.getIn(['compose', 'spoiler_text']),
       unlisted: state.getIn(['compose', 'unlisted']),
       private: state.getIn(['compose', 'private']),
       fileDropDate: state.getIn(['compose', 'fileDropDate']),
       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'])),
-      media_count: state.getIn(['compose', 'media_attachments']).size
+      media_count: state.getIn(['compose', 'media_attachments']).size,
+      me: state.getIn(['compose', 'me'])
     };
   };
 
@@ -65,6 +70,14 @@ const mapDispatchToProps = function (dispatch) {
       dispatch(changeComposeSensitivity(checked));
     },
 
+    onChangeSpoilerness (checked) {
+      dispatch(changeComposeSpoilerness(checked));
+    },
+
+    onChangeSpoilerText (checked) {
+      dispatch(changeComposeSpoilerText(checked));
+    },
+
     onChangeVisibility (checked) {
       dispatch(changeComposeVisibility(checked));
     },
diff --git a/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx
index 51e2513d8..0006608da 100644
--- a/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx
@@ -1,8 +1,10 @@
 import { connect }   from 'react-redux';
 import NavigationBar from '../components/navigation_bar';
 
-const mapStateToProps = (state, props) => ({
-  account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
-});
+const mapStateToProps = (state, props) => {
+  return {
+    account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
+  };
+};
 
 export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
index 4154b0737..78e5312f5 100644
--- a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
@@ -4,6 +4,7 @@ import { uploadCompose } from '../../../actions/compose';
 
 const mapStateToProps = state => ({
   disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
+  resetFileKey: state.getIn(['compose', 'resetFileKey'])
 });
 
 const mapDispatchToProps = dispatch => ({
diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index 4017c8949..f6095c0c6 100644
--- a/app/assets/javascripts/components/features/compose/index.jsx
+++ b/app/assets/javascripts/components/features/compose/index.jsx
@@ -10,7 +10,8 @@ import { mountCompose, unmountCompose } from '../../actions/compose';
 const Compose = React.createClass({
 
   propTypes: {
-    dispatch: React.PropTypes.func.isRequired
+    dispatch: React.PropTypes.func.isRequired,
+    withHeader: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -25,7 +26,7 @@ const Compose = React.createClass({
 
   render () {
     return (
-      <Drawer>
+      <Drawer withHeader={this.props.withHeader}>
         <SearchContainer />
         <NavigationContainer />
         <ComposeFormContainer />
diff --git a/app/assets/javascripts/components/features/favourited_statuses/index.jsx b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
new file mode 100644
index 000000000..a2d521736
--- /dev/null
+++ b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
@@ -0,0 +1,63 @@
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
+import Column from '../ui/components/column';
+import StatusList from '../../components/status_list';
+import ColumnBackButton from '../public_timeline/components/column_back_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+  loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
+  me: state.getIn(['meta', 'me'])
+});
+
+const Favourites = React.createClass({
+
+  propTypes: {
+    params: React.PropTypes.object.isRequired,
+    dispatch: React.PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    loaded: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired,
+    me: React.PropTypes.number.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavouritedStatuses());
+  },
+
+  handleScrollToBottom () {
+    this.props.dispatch(expandFavouritedStatuses());
+  },
+
+  render () {
+    const { statusIds, loaded, intl, me } = this.props;
+
+    if (!loaded) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='star' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButton />
+        <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
+      </Column>
+    );
+  }
+
+});
+
+export default connect(mapStateToProps)(injectIntl(Favourites));
diff --git a/app/assets/javascripts/components/features/generic_not_found/index.jsx b/app/assets/javascripts/components/features/generic_not_found/index.jsx
new file mode 100644
index 000000000..a7afe29b0
--- /dev/null
+++ b/app/assets/javascripts/components/features/generic_not_found/index.jsx
@@ -0,0 +1,10 @@
+import Column from '../ui/components/column';
+import MissingIndicator from '../../components/missing_indicator';
+
+const GenericNotFound = () => (
+  <Column>
+    <MissingIndicator />
+  </Column>
+);
+
+export default GenericNotFound;
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index 157bdf8f2..42e0a9e24 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -8,25 +8,16 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 const messages = defineMessages({
   heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
   public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
-  settings: { id: 'navigation_bar.settings', defaultMessage: 'Settings' },
-  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+  sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }
 });
 
 const mapStateToProps = state => ({
   me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
 });
 
-const hamburgerStyle = {
-  background: '#373b4a',
-  color: '#fff',
-  fontSize: '16px',
-  padding: '15px',
-  position: 'absolute',
-  right: '0',
-  top: '-48px',
-  cursor: 'default'
-};
-
 const GettingStarted = ({ intl, me }) => {
   let followRequests = '';
 
@@ -37,19 +28,21 @@ const GettingStarted = ({ intl, me }) => {
   return (
     <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
       <div style={{ position: 'relative' }}>
-        <div style={hamburgerStyle}><i className='fa fa-bars' /></div>
         <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
-        <ColumnLink icon='cog' text={intl.formatMessage(messages.settings)} href='/settings/profile' />
+        <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
+        <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
         {followRequests}
+        <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
       </div>
 
-      <div className='static-content'>
-        <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
-        <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
-        <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
+      <div className='scrollable optionally-scrollable'>
+        <div className='static-content getting-started'>
+          <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
+          <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
+          <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
+          <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a style={{ color: '#616b86'}} href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p>
+        </div>
       </div>
-
-      <div className='getting-started__illustration' />
     </Column>
   );
 };
diff --git a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
new file mode 100644
index 000000000..714be309b
--- /dev/null
+++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
@@ -0,0 +1,68 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnCollapsable from '../../../components/column_collapsable';
+import SettingToggle from '../../notifications/components/setting_toggle';
+import SettingText from './setting_text';
+
+const messages = defineMessages({
+  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' }
+});
+
+const outerStyle = {
+  background: '#373b4a',
+  padding: '15px'
+};
+
+const sectionStyle = {
+  cursor: 'default',
+  display: 'block',
+  fontWeight: '500',
+  color: '#9baec8',
+  marginBottom: '10px'
+};
+
+const rowStyle = {
+
+};
+
+const ColumnSettings = React.createClass({
+
+  propTypes: {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: React.PropTypes.func.isRequired,
+    onSave: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { settings, onChange, onSave, intl } = this.props;
+
+    return (
+      <ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}>
+        <div style={outerStyle}>
+          <span style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
+
+          <div style={rowStyle}>
+            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} />
+          </div>
+
+          <div style={rowStyle}>
+            <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
+          </div>
+
+          <span style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
+
+          <div style={rowStyle}>
+            <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
+          </div>
+        </div>
+      </ColumnCollapsable>
+    );
+  }
+
+});
+
+export default injectIntl(ColumnSettings);
diff --git a/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx b/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx
new file mode 100644
index 000000000..79697e869
--- /dev/null
+++ b/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx
@@ -0,0 +1,41 @@
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const style = {
+  display: 'block',
+  fontFamily: 'inherit',
+  marginBottom: '10px',
+  padding: '7px 0',
+  boxSizing: 'border-box',
+  width: '100%'
+};
+
+const SettingText = React.createClass({
+
+  propTypes: {
+    settings: ImmutablePropTypes.map.isRequired,
+    settingKey: React.PropTypes.array.isRequired,
+    label: React.PropTypes.string.isRequired,
+    onChange: React.PropTypes.func.isRequired
+  },
+
+  handleChange (e) {
+    this.props.onChange(this.props.settingKey, e.target.value)
+  },
+
+  render () {
+    const { settings, settingKey, label } = this.props;
+
+    return (
+      <input
+        style={style}
+        className='setting-text'
+        value={settings.getIn(settingKey)}
+        onChange={this.handleChange}
+        placeholder={label}
+      />
+    );
+  }
+
+});
+
+export default SettingText;
diff --git a/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx b/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx
new file mode 100644
index 000000000..3b3ce19bc
--- /dev/null
+++ b/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'home'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['home', ...key], checked));
+  },
+
+  onSave () {
+    dispatch(saveSettings());
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx
index e4f4fa7c7..5d2263f15 100644
--- a/app/assets/javascripts/components/features/home_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/index.jsx
@@ -1,9 +1,8 @@
-import { connect } from 'react-redux';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../ui/components/column';
-import { refreshTimeline } from '../../actions/timelines';
 import { defineMessages, injectIntl } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
 
 const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' }
@@ -12,20 +11,17 @@ const messages = defineMessages({
 const HomeTimeline = React.createClass({
 
   propTypes: {
-    dispatch: React.PropTypes.func.isRequired
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  componentWillMount () {
-    this.props.dispatch(refreshTimeline('home'));
-  },
-
   render () {
     const { intl } = this.props;
 
     return (
       <Column icon='home' heading={intl.formatMessage(messages.title)}>
+        <ColumnSettingsContainer />
         <StatusListContainer {...this.props} type='home' />
       </Column>
     );
@@ -33,4 +29,4 @@ const HomeTimeline = React.createClass({
 
 });
 
-export default connect()(injectIntl(HomeTimeline));
+export default injectIntl(HomeTimeline);
diff --git a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
index b4035c20d..b63c1881a 100644
--- a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
@@ -1,37 +1,14 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import Toggle from 'react-toggle';
-import { Motion, spring } from 'react-motion';
 import { FormattedMessage } from 'react-intl';
+import ColumnCollapsable from '../../../components/column_collapsable';
+import SettingToggle from './setting_toggle';
 
 const outerStyle = {
   background: '#373b4a',
   padding: '15px'
 };
 
-const iconStyle = {
-  fontSize: '16px',
-  padding: '15px',
-  position: 'absolute',
-  right: '0',
-  top: '-48px',
-  cursor: 'pointer'
-};
-
-const labelStyle = {
-  display: 'block',
-  lineHeight: '24px',
-  verticalAlign: 'middle'
-};
-
-const labelSpanStyle = {
-  display: 'inline-block',
-  verticalAlign: 'middle',
-  marginBottom: '14px',
-  marginLeft: '8px',
-  color: '#9baec8'
-};
-
 const sectionStyle = {
   cursor: 'default',
   display: 'block',
@@ -48,100 +25,55 @@ const ColumnSettings = React.createClass({
 
   propTypes: {
     settings: ImmutablePropTypes.map.isRequired,
-    onChange: React.PropTypes.func.isRequired
-  },
-
-  getInitialState () {
-    return {
-      collapsed: true
-    };
+    onChange: React.PropTypes.func.isRequired,
+    onSave: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  handleToggleCollapsed () {
-    this.setState({ collapsed: !this.state.collapsed });
-  },
-
-  handleChange (key, e) {
-    this.props.onChange(key, e.target.checked);
-  },
-
   render () {
-    const { settings }  = this.props;
-    const { collapsed } = this.state;
+    const { settings, onChange, onSave } = this.props;
 
     const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
     const showStr  = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
+    const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
 
     return (
-      <div style={{ position: 'relative' }}>
-        <div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className='fa fa-sliders' /></div>
-
-        <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : 458) }}>
-          {({ opacity, height }) =>
-            <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
-              <div style={outerStyle}>
-                <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
-
-                <div style={rowStyle}>
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['alerts', 'follow'])} onChange={this.handleChange.bind(this, ['alerts', 'follow'])} />
-                    <span style={labelSpanStyle}>{alertStr}</span>
-                  </label>
-
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['shows', 'follow'])} onChange={this.handleChange.bind(this, ['shows', 'follow'])} />
-                    <span style={labelSpanStyle}>{showStr}</span>
-                  </label>
-                </div>
-
-                <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
-
-                <div style={rowStyle}>
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['alerts', 'favourite'])} onChange={this.handleChange.bind(this, ['alerts', 'favourite'])} />
-                    <span style={labelSpanStyle}>{alertStr}</span>
-                  </label>
-
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['shows', 'favourite'])} onChange={this.handleChange.bind(this, ['shows', 'favourite'])} />
-                    <span style={labelSpanStyle}>{showStr}</span>
-                  </label>
-                </div>
-
-                <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
-
-                <div style={rowStyle}>
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['alerts', 'mention'])} onChange={this.handleChange.bind(this, ['alerts', 'mention'])} />
-                    <span style={labelSpanStyle}>{alertStr}</span>
-                  </label>
-
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['shows', 'mention'])} onChange={this.handleChange.bind(this, ['shows', 'mention'])} />
-                    <span style={labelSpanStyle}>{showStr}</span>
-                  </label>
-                </div>
-
-                <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
-
-                <div style={rowStyle}>
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['alerts', 'reblog'])} onChange={this.handleChange.bind(this, ['alerts', 'reblog'])} />
-                    <span style={labelSpanStyle}>{alertStr}</span>
-                  </label>
-
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['shows', 'reblog'])} onChange={this.handleChange.bind(this, ['shows', 'reblog'])} />
-                    <span style={labelSpanStyle}>{showStr}</span>
-                  </label>
-                </div>
-              </div>
-            </div>
-          }
-        </Motion>
-      </div>
+      <ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}>
+        <div style={outerStyle}>
+          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
+
+          <div style={rowStyle}>
+            <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
+          </div>
+
+          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
+
+          <div style={rowStyle}>
+            <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
+          </div>
+
+          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
+
+          <div style={rowStyle}>
+            <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
+          </div>
+
+          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
+
+          <div style={rowStyle}>
+            <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+      </ColumnCollapsable>
     );
   }
 
diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx
index 9f4cf9e4d..140ba9134 100644
--- a/app/assets/javascripts/components/features/notifications/components/notification.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx
@@ -4,6 +4,8 @@ import StatusContainer from '../../../containers/status_container';
 import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
+import emojify from '../../../emoji';
+import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
 
 const messageStyle = {
   marginLeft: '68px',
@@ -71,7 +73,7 @@ const Notification = React.createClass({
             <i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} />
           </div>
 
-          <FormattedMessage id='notification.reblog' defaultMessage='{name} reblogged your status' values={{ name: link }} />
+          <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
         </div>
 
         <StatusContainer id={notification.get('status')} muted={true} />
@@ -83,7 +85,8 @@ const Notification = React.createClass({
     const { notification } = this.props;
     const account          = notification.get('account');
     const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
-    const link             = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`}>{displayName}</Permalink>;
+    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const link             = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
 
     switch(notification.get('type')) {
       case 'follow':
diff --git a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx
new file mode 100644
index 000000000..c2438f716
--- /dev/null
+++ b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx
@@ -0,0 +1,32 @@
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+const labelStyle = {
+  display: 'block',
+  lineHeight: '24px',
+  verticalAlign: 'middle'
+};
+
+const labelSpanStyle = {
+  display: 'inline-block',
+  verticalAlign: 'middle',
+  marginBottom: '14px',
+  marginLeft: '8px',
+  color: '#9baec8'
+};
+
+const SettingToggle = ({ settings, settingKey, label, onChange }) => (
+  <label style={labelStyle}>
+    <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} />
+    <span style={labelSpanStyle}>{label}</span>
+  </label>
+);
+
+SettingToggle.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  settingKey: React.PropTypes.array.isRequired,
+  label: React.PropTypes.node.isRequired,
+  onChange: React.PropTypes.func.isRequired
+};
+
+export default SettingToggle;
diff --git a/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx b/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx
index 6907fd351..bc24c75e0 100644
--- a/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx
+++ b/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx
@@ -1,15 +1,19 @@
 import { connect } from 'react-redux';
 import ColumnSettings from '../components/column_settings';
-import { changeNotificationsSetting } from '../../../actions/notifications';
+import { changeSetting, saveSettings } from '../../../actions/settings';
 
 const mapStateToProps = state => ({
-  settings: state.getIn(['notifications', 'settings'])
+  settings: state.getIn(['settings', 'notifications'])
 });
 
 const mapDispatchToProps = dispatch => ({
 
   onChange (key, checked) {
-    dispatch(changeNotificationsSetting(key, checked));
+    dispatch(changeSetting(['notifications', ...key], checked));
+  },
+
+  onSave () {
+    dispatch(saveSettings());
   }
 
 });
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index 7e706ad6a..b4593aaff 100644
--- a/app/assets/javascripts/components/features/notifications/index.jsx
+++ b/app/assets/javascripts/components/features/notifications/index.jsx
@@ -2,10 +2,7 @@ import { connect } from 'react-redux';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Column from '../ui/components/column';
-import {
-  refreshNotifications,
-  expandNotifications
-} from '../../actions/notifications';
+import { expandNotifications } from '../../actions/notifications';
 import NotificationContainer from './containers/notification_container';
 import { ScrollContainer } from 'react-router-scroll';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -18,12 +15,13 @@ const messages = defineMessages({
 });
 
 const getNotifications = createSelector([
-  state => Immutable.List(state.getIn(['notifications', 'settings', 'shows']).filter(item => !item).keys()),
+  state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
   state => state.getIn(['notifications', 'items'])
 ], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
 
 const mapStateToProps = state => ({
-  notifications: getNotifications(state)
+  notifications: getNotifications(state),
+  isLoading: state.getIn(['notifications', 'isLoading'], true)
 });
 
 const Notifications = React.createClass({
@@ -32,7 +30,8 @@ const Notifications = React.createClass({
     notifications: ImmutablePropTypes.list.isRequired,
     dispatch: React.PropTypes.func.isRequired,
     trackScroll: React.PropTypes.bool,
-    intl: React.PropTypes.object.isRequired
+    intl: React.PropTypes.object.isRequired,
+    isLoading: React.PropTypes.bool
   },
 
   getDefaultProps () {
@@ -43,15 +42,11 @@ const Notifications = React.createClass({
 
   mixins: [PureRenderMixin],
 
-  componentWillMount () {
-    const { dispatch } = this.props;
-    dispatch(refreshNotifications());
-  },
-
   handleScroll (e) {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
 
-    if (scrollTop === scrollHeight - clientHeight) {
+    if (250 > offset && !this.props.isLoading) {
       this.props.dispatch(expandNotifications());
     }
   },
@@ -70,6 +65,7 @@ const Notifications = React.createClass({
     if (trackScroll) {
       return (
         <Column icon='bell' heading={intl.formatMessage(messages.title)}>
+          <ColumnSettingsContainer />
           <ScrollContainer scrollKey='notifications'>
             {scrollableArea}
           </ScrollContainer>
diff --git a/app/assets/javascripts/components/features/status/components/action_bar.jsx b/app/assets/javascripts/components/features/status/components/action_bar.jsx
index 030428440..3f8a0457d 100644
--- a/app/assets/javascripts/components/features/status/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx
@@ -61,8 +61,8 @@ const ActionBar = React.createClass({
       <div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
         <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
         <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
-        <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
-        <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
+        <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div>
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx
new file mode 100644
index 000000000..ccb06dfd5
--- /dev/null
+++ b/app/assets/javascripts/components/features/status/components/card.jsx
@@ -0,0 +1,100 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const outerStyle = {
+  display: 'flex',
+  cursor: 'pointer',
+  fontSize: '14px',
+  border: '1px solid #363c4b',
+  borderRadius: '4px',
+  color: '#616b86',
+  marginTop: '14px',
+  textDecoration: 'none',
+  overflow: 'hidden'
+};
+
+const contentStyle = {
+  flex: '1 1 auto',
+  padding: '8px',
+  paddingLeft: '14px',
+  overflow: 'hidden'
+};
+
+const titleStyle = {
+  display: 'block',
+  fontWeight: '500',
+  marginBottom: '5px',
+  color: '#d9e1e8',
+  overflow: 'hidden',
+  textOverflow: 'ellipsis',
+  whiteSpace: 'nowrap'
+};
+
+const descriptionStyle = {
+  color: '#d9e1e8'
+};
+
+const imageOuterStyle = {
+  flex: '0 0 100px',
+  background: '#373b4a'
+};
+
+const imageStyle = {
+  display: 'block',
+  width: '100%',
+  height: 'auto',
+  margin: '0',
+  borderRadius: '4px 0 0 4px'
+};
+
+const hostStyle = {
+  display: 'block',
+  marginTop: '5px',
+  fontSize: '13px'
+};
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+const Card = React.createClass({
+  propTypes: {
+    card: ImmutablePropTypes.map
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { card } = this.props;
+
+    if (card === null) {
+      return null;
+    }
+
+    let image = '';
+
+    if (card.get('image')) {
+      image = (
+        <div style={imageOuterStyle}>
+          <img src={card.get('image')} alt={card.get('title')} style={imageStyle} />
+        </div>
+      );
+    }
+
+    return (
+      <a style={outerStyle} href={card.get('url')} className='status-card'>
+        {image}
+
+        <div style={contentStyle}>
+          <strong style={titleStyle} title={card.get('title')}>{card.get('title')}</strong>
+          <p style={descriptionStyle}>{card.get('description').substring(0, 50)}</p>
+          <span style={hostStyle}>{getHostname(card.get('url'))}</span>
+        </div>
+      </a>
+    );
+  }
+});
+
+export default Card;
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 b967d966f..f2d6ae48a 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -7,6 +7,7 @@ import MediaGallery from '../../../components/media_gallery';
 import VideoPlayer from '../../../components/video_player';
 import { Link } from 'react-router';
 import { FormattedDate, FormattedNumber } from 'react-intl';
+import CardContainer from '../containers/card_container';
 
 const DetailedStatus = React.createClass({
 
@@ -32,7 +33,9 @@ const DetailedStatus = React.createClass({
 
   render () {
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
-    let media    = '';
+
+    let media           = '';
+    let applicationLink = '';
 
     if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
@@ -40,6 +43,12 @@ const DetailedStatus = React.createClass({
       } else {
         media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
       }
+    } else {
+      media = <CardContainer statusId={status.get('id')} />;
+    }
+
+    if (status.get('application')) {
+      applicationLink = <span> · <a className='detailed-status__application' style={{ color: 'inherit' }} href={status.getIn(['application', 'website'])} target='_blank' rel='nooopener'>{status.getIn(['application', 'name'])}</a></span>;
     }
 
     return (
@@ -54,7 +63,7 @@ const DetailedStatus = React.createClass({
         {media}
 
         <div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
-          <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a> · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link>
+          <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link>
         </div>
       </div>
     );
diff --git a/app/assets/javascripts/components/features/status/containers/card_container.jsx b/app/assets/javascripts/components/features/status/containers/card_container.jsx
new file mode 100644
index 000000000..5c8bfeec2
--- /dev/null
+++ b/app/assets/javascripts/components/features/status/containers/card_container.jsx
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import Card from '../components/card';
+
+const mapStateToProps = (state, { statusId }) => ({
+  card: state.getIn(['cards', statusId], null)
+});
+
+export default connect(mapStateToProps)(Card);
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index 0a1528fe9..389549849 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -23,6 +23,7 @@ import { ScrollContainer }   from 'react-router-scroll';
 import ColumnBackButton      from '../../components/column_back_button';
 import StatusContainer       from '../../containers/status_container';
 import { openMedia }         from '../../actions/modal';
+import { isMobile } from '../../is_mobile'
 
 const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
@@ -47,7 +48,8 @@ const Status = React.createClass({
     dispatch: React.PropTypes.func.isRequired,
     status: ImmutablePropTypes.map,
     ancestorsIds: ImmutablePropTypes.list,
-    descendantsIds: ImmutablePropTypes.list
+    descendantsIds: ImmutablePropTypes.list,
+    me: React.PropTypes.number
   },
 
   mixins: [PureRenderMixin],
@@ -80,6 +82,10 @@ const Status = React.createClass({
 
   handleMentionClick (account) {
     this.props.dispatch(mentionCompose(account));
+
+    if (isMobile(window.innerWidth)) {
+      this.context.router.push('/statuses/new');
+    }
   },
 
   handleOpenMedia (url) {
diff --git a/app/assets/javascripts/components/features/ui/components/column_link.jsx b/app/assets/javascripts/components/features/ui/components/column_link.jsx
index a2f7c13a6..901a29f5c 100644
--- a/app/assets/javascripts/components/features/ui/components/column_link.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column_link.jsx
@@ -13,10 +13,10 @@ const iconStyle = {
   marginRight: '5px'
 };
 
-const ColumnLink = ({ icon, text, to, href }) => {
+const ColumnLink = ({ icon, text, to, href, method }) => {
   if (href) {
     return (
-      <a href={href} style={outerStyle} className='column-link'>
+      <a href={href} style={outerStyle} className='column-link' data-method={method}>
         <i className={`fa fa-fw fa-${icon}`} style={iconStyle} />
         {text}
       </a>
diff --git a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
index 219979522..2f8a28fad 100644
--- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
+++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
@@ -3,15 +3,14 @@ import { FormattedMessage } from 'react-intl';
 
 const outerStyle = {
   background: '#373b4a',
-  margin: '10px',
   flex: '0 0 auto',
-  marginBottom: '0'
+  overflowY: 'auto'
 };
 
 const tabStyle = {
   display: 'block',
   flex: '1 1 auto',
-  padding: '10px',
+  padding: '10px 5px',
   color: '#fff',
   textDecoration: 'none',
   textAlign: 'center',
@@ -31,7 +30,7 @@ const TabsBar = () => {
       <Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
       <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
       <Link style={tabStyle} activeStyle={tabActiveStyle} to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
-      <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /> <FormattedMessage id='tabs_bar.public' defaultMessage='Public' /></Link>
+      <Link style={{ ...tabStyle, flexGrow: '0', flexBasis: '30px' }} activeStyle={tabActiveStyle} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
     </div>
   );
 };
diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
index cd7d63a4a..53d162462 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -1,6 +1,9 @@
-import { connect }           from 'react-redux';
-import { closeModal }        from '../../../actions/modal';
-import Lightbox              from '../../../components/lightbox';
+import { connect } from 'react-redux';
+import { closeModal } from '../../../actions/modal';
+import Lightbox from '../../../components/lightbox';
+import ImageLoader from 'react-imageloader';
+import LoadingIndicator from '../../../components/loading_indicator';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
 
 const mapStateToProps = state => ({
   url: state.getIn(['modal', 'url']),
@@ -23,6 +26,18 @@ const imageStyle = {
   maxHeight: '80vh'
 };
 
+const loadingStyle = {
+  background: '#373b4a',
+  width: '400px',
+  paddingBottom: '120px'
+};
+
+const preloader = () => (
+  <div style={loadingStyle}>
+    <LoadingIndicator />
+  </div>
+);
+
 const Modal = React.createClass({
 
   propTypes: {
@@ -32,12 +47,18 @@ const Modal = React.createClass({
     onOverlayClicked: React.PropTypes.func
   },
 
+  mixins: [PureRenderMixin],
+
   render () {
     const { url, ...other } = this.props;
 
     return (
       <Lightbox {...other}>
-        <img src={url} style={imageStyle} />
+        <ImageLoader
+          src={url}
+          preloader={preloader}
+          imgProps={{ style: imageStyle }}
+        />
       </Lightbox>
     );
   }
diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
index 1621cec7b..8af7b0c3c 100644
--- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
@@ -2,26 +2,56 @@ import { connect } from 'react-redux';
 import StatusList from '../../../components/status_list';
 import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
 import Immutable from 'immutable';
+import { createSelector } from 'reselect';
+
+const getStatusIds = createSelector([
+  (state, { type }) => state.getIn(['settings', type], Immutable.Map()),
+  (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
+  (state)           => state.get('statuses')
+], (columnSettings, statusIds, statuses) => statusIds.filter(id => {
+  const statusForId = statuses.get(id);
+  let showStatus    = true;
+
+  if (columnSettings.getIn(['shows', 'reblog']) === false) {
+    showStatus = showStatus && statusForId.get('reblog') === null;
+  }
+
+  if (columnSettings.getIn(['shows', 'reply']) === false) {
+    showStatus = showStatus && statusForId.get('in_reply_to_id') === null;
+  }
+
+  if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) {
+    try {
+      const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i');
+      showStatus = showStatus && !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'content']) : statusForId.get('content'));
+    } catch(e) {
+      // Bad regex, don't affect filters
+    }
+  }
+
+  return showStatus;
+}));
 
 const mapStateToProps = (state, props) => ({
-  statusIds: state.getIn(['timelines', props.type, 'items'], Immutable.List())
+  statusIds: getStatusIds(state, props),
+  isLoading: state.getIn(['timelines', props.type, 'isLoading'], true)
 });
 
-const mapDispatchToProps = function (dispatch, props) {
-  return {
-    onScrollToBottom () {
-      dispatch(scrollTopTimeline(props.type, false));
-      dispatch(expandTimeline(props.type, props.id));
-    },
+const mapDispatchToProps = (dispatch, { type, id }) => ({
 
-    onScrollToTop () {
-      dispatch(scrollTopTimeline(props.type, true));
-    },
+  onScrollToBottom () {
+    dispatch(scrollTopTimeline(type, false));
+    dispatch(expandTimeline(type, id));
+  },
 
-    onScroll () {
-      dispatch(scrollTopTimeline(props.type, false));
-    }
-  };
-};
+  onScrollToTop () {
+    dispatch(scrollTopTimeline(type, true));
+  },
+
+  onScroll () {
+    dispatch(scrollTopTimeline(type, false));
+  }
+
+});
 
 export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
index 76e3dd940..003d061ad 100644
--- a/app/assets/javascripts/components/features/ui/index.jsx
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -8,12 +8,20 @@ import Compose from '../compose';
 import TabsBar from './components/tabs_bar';
 import ModalContainer from './containers/modal_container';
 import Notifications from '../notifications';
+import { connect } from 'react-redux';
+import { isMobile } from '../../is_mobile';
 import { debounce } from 'react-decoration';
 import { uploadCompose } from '../../actions/compose';
-import { connect } from 'react-redux';
+import { refreshTimeline } from '../../actions/timelines';
+import { refreshNotifications } from '../../actions/notifications';
 
 const UI = React.createClass({
 
+  propTypes: {
+    dispatch: React.PropTypes.func.isRequired,
+    children: React.PropTypes.node
+  },
+
   getInitialState () {
     return {
       width: window.innerWidth
@@ -41,7 +49,7 @@ const UI = React.createClass({
   handleDrop (e) {
     e.preventDefault();
 
-    if (e.dataTransfer) {
+    if (e.dataTransfer && e.dataTransfer.files.length === 1) {
       this.props.dispatch(uploadCompose(e.dataTransfer.files));
     }
   },
@@ -50,6 +58,9 @@ const UI = React.createClass({
     window.addEventListener('resize', this.handleResize, { passive: true });
     window.addEventListener('dragover', this.handleDragOver);
     window.addEventListener('drop', this.handleDrop);
+
+    this.props.dispatch(refreshTimeline('home'));
+    this.props.dispatch(refreshNotifications());
   },
 
   componentWillUnmount () {
@@ -59,11 +70,9 @@ const UI = React.createClass({
   },
 
   render () {
-    const layoutBreakpoint = 1024;
-
     let mountedColumns;
 
-    if (this.state.width <= layoutBreakpoint) {
+    if (isMobile(this.state.width)) {
       mountedColumns = (
         <ColumnsArea>
           {this.props.children}
@@ -72,7 +81,7 @@ const UI = React.createClass({
     } else {
       mountedColumns = (
         <ColumnsArea>
-          <Compose />
+          <Compose withHeader={true} />
           <HomeTimeline trackScroll={false} />
           <Notifications trackScroll={false} />
           {this.props.children}
diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx
new file mode 100644
index 000000000..eaa6221e4
--- /dev/null
+++ b/app/assets/javascripts/components/is_mobile.jsx
@@ -0,0 +1,5 @@
+const LAYOUT_BREAKPOINT = 1024;
+
+export function isMobile(width) {
+  return width <= LAYOUT_BREAKPOINT;
+};
diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx
index 17b74e15d..7d32824f1 100644
--- a/app/assets/javascripts/components/locales/de.jsx
+++ b/app/assets/javascripts/components/locales/de.jsx
@@ -8,6 +8,9 @@ const en = {
   "status.reblog": "Teilen",
   "status.favourite": "Favorisieren",
   "status.reblogged_by": "{name} teilte",
+  "status.sensitive_warning": "Sensible Inhalte",
+  "status.sensitive_toggle": "Klicken um zu zeigen",
+  "status.open": "Öffnen",
   "video_player.toggle_sound": "Ton umschalten",
   "account.mention": "Erwähnen",
   "account.edit_profile": "Profil bearbeiten",
@@ -19,14 +22,17 @@ const en = {
   "account.follows": "Folgt",
   "account.followers": "Folger",
   "account.follows_you": "Folgt dir",
+  "account.requested": "Warte auf Erlaubnis",
   "getting_started.heading": "Erste Schritte",
   "getting_started.about_addressing": "Du kannst Leuten folgen, falls du ihren Nutzernamen und ihre Domain kennst, in dem du eine e-mail-artige Addresse in das Suchfeld oben an der Seite eingibst.",
   "getting_started.about_shortcuts": "Falls der Zielnutzer an derselben Domain ist wie du, funktioniert der Nutzername auch alleine. Das gilt auch für Erwähnungen in Beiträgen.",
   "getting_started.about_developer": "Der Entwickler des Projekts kann unter Gargron@mastodon.social gefunden werden",
+  "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
   "column.home": "Home",
   "column.mentions": "Erwähnungen",
   "column.public": "Gesamtes Bekanntes Netz",
   "column.notifications": "Mitteilungen",
+  "column.follow_requests": "Folgeanfragen",
   "tabs_bar.compose": "Schreiben",
   "tabs_bar.home": "Home",
   "tabs_bar.mentions": "Erwähnungen",
@@ -36,9 +42,12 @@ const en = {
   "compose_form.publish": "Veröffentlichen",
   "compose_form.sensitive": "Medien als sensitiv markieren",
   "compose_form.unlisted": "Öffentlich nicht auflisten",
-  "navigation_bar.settings": "Einstellungen",
+  "compose_form.private": "Als privat markieren",
+  "navigation_bar.edit_profile": "Profil bearbeiten",
+  "navigation_bar.preferences": "Einstellungen",
   "navigation_bar.public_timeline": "Öffentlich",
   "navigation_bar.logout": "Abmelden",
+  "navigation_bar.follow_requests": "Folgeanfragen",
   "reply_indicator.cancel": "Abbrechen",
   "search.placeholder": "Suche",
   "search.account": "Konto",
@@ -48,7 +57,21 @@ const en = {
   "notification.follow": "{name} folgt dir",
   "notification.favourite": "{name} favorisierte deinen Status",
   "notification.reblog": "{name} teilte deinen Status",
-  "notification.mention": "{name} erwähnte dich"
+  "notification.mention": "{name} erwähnte dich",
+  "notifications.column_settings.alert": "Desktop-Benachrichtigunen",
+  "notifications.column_settings.show": "In der Spalte anzeigen",
+  "notifications.column_settings.follow": "Neue Folger:",
+  "notifications.column_settings.favourite": "Favorisierungen:",
+  "notifications.column_settings.mention": "Erwähnungen:",
+  "notifications.column_settings.reblog": "Geteilte Beiträge:",
+  "follow_request.authorize": "Erlauben",
+  "follow_request.reject": "Ablehnen",
+  "home.column_settings.basic": "Einfach",
+  "home.column_settings.advanced": "Fortgeschritten",
+  "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
+  "home.column_settings.show_replies": "Antworten anzeigen",
+  "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
+  "missing_indicator.label": "Nicht gefunden"
 };
 
 export default en;
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index 3d4a38919..92dcbaeb9 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -17,7 +17,6 @@ const en = {
   "account.unfollow": "Unfollow",
   "account.block": "Block",
   "account.follow": "Follow",
-  "account.block": "Block",
   "account.posts": "Posts",
   "account.follows": "Follows",
   "account.followers": "Followers",
@@ -27,6 +26,7 @@ const en = {
   "getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
   "getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
   "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
   "column.home": "Home",
   "column.mentions": "Mentions",
   "column.public": "Public",
@@ -40,7 +40,9 @@ const en = {
   "compose_form.publish": "Toot",
   "compose_form.sensitive": "Mark media as sensitive",
   "compose_form.private": "Mark as private",
-  "navigation_bar.settings": "Settings",
+  "compose_form.unlisted": "Do not display in public timeline",
+  "navigation_bar.edit_profile": "Edit profile",
+  "navigation_bar.preferences": "Preferences",
   "navigation_bar.public_timeline": "Public timeline",
   "navigation_bar.logout": "Logout",
   "reply_indicator.cancel": "Cancel",
diff --git a/app/assets/javascripts/components/locales/es.jsx b/app/assets/javascripts/components/locales/es.jsx
index 6bd9b18ed..b75fb57d9 100644
--- a/app/assets/javascripts/components/locales/es.jsx
+++ b/app/assets/javascripts/components/locales/es.jsx
@@ -37,7 +37,8 @@ const es = {
   "compose_form.publish": "Publicar",
   "compose_form.sensitive": "Marcar el contenido como sensible",
   "compose_form.unlisted": "Privado",
-  "navigation_bar.settings": "Ajustes",
+  "navigation_bar.edit_profile": "Editar perfil",
+  "navigation_bar.preferences": "Preferencias",
   "navigation_bar.public_timeline": "Público",
   "navigation_bar.logout": "Cerrar sesión",
   "reply_indicator.cancel": "Cancelar",
diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index 968c3f8c3..183e5d5b5 100644
--- a/app/assets/javascripts/components/locales/fr.jsx
+++ b/app/assets/javascripts/components/locales/fr.jsx
@@ -38,7 +38,8 @@ const fr = {
   "compose_form.publish": "Pouet",
   "compose_form.sensitive": "Marquer le contenu comme délicat",
   "compose_form.unlisted": "Ne pas apparaître dans le fil public",
-  "navigation_bar.settings": "Paramètres",
+  "navigation_bar.edit_profile": "Modifier le profil",
+  "navigation_bar.preferences": "Préférences",
   "navigation_bar.public_timeline": "Public",
   "navigation_bar.logout": "Déconnexion",
   "reply_indicator.cancel": "Annuler",
diff --git a/app/assets/javascripts/components/locales/hu.jsx b/app/assets/javascripts/components/locales/hu.jsx
index 606fc830f..9a2d14d87 100644
--- a/app/assets/javascripts/components/locales/hu.jsx
+++ b/app/assets/javascripts/components/locales/hu.jsx
@@ -38,7 +38,8 @@ const hu = {
   "compose_form.publish": "Tülk!",
   "compose_form.sensitive": "Tartalom érzékenynek jelölése",
   "compose_form.unlisted": "Listázatlan mód",
-  "navigation_bar.settings": "Beállítások",
+  "navigation_bar.edit_profile": "Profil szerkesztése",
+  "navigation_bar.preferences": "Beállítások",
   "navigation_bar.public_timeline": "Nyilvános időfolyam",
   "navigation_bar.logout": "Kijelentkezés",
   "reply_indicator.cancel": "Mégsem",
diff --git a/app/assets/javascripts/components/locales/pt.jsx b/app/assets/javascripts/components/locales/pt.jsx
index 57cbcd31b..d68724b13 100644
--- a/app/assets/javascripts/components/locales/pt.jsx
+++ b/app/assets/javascripts/components/locales/pt.jsx
@@ -36,7 +36,8 @@ const pt = {
   "compose_form.publish": "Publicar",
   "compose_form.sensitive": "Marcar conteúdo como sensível",
   "compose_form.unlisted": "Modo não-listado",
-  "navigation_bar.settings": "Configurações",
+  "navigation_bar.edit_profile": "Editar perfil",
+  "navigation_bar.preferences": "Preferências",
   "navigation_bar.public_timeline": "Timeline Pública",
   "navigation_bar.logout": "Logout",
   "reply_indicator.cancel": "Cancelar",
diff --git a/app/assets/javascripts/components/locales/uk.jsx b/app/assets/javascripts/components/locales/uk.jsx
index 53535c25a..84a348c21 100644
--- a/app/assets/javascripts/components/locales/uk.jsx
+++ b/app/assets/javascripts/components/locales/uk.jsx
@@ -38,7 +38,8 @@ const uk = {
   "compose_form.publish": "Дмухнути",
   "compose_form.sensitive": "Непристойний зміст",
   "compose_form.unlisted": "Таємний режим",
-  "navigation_bar.settings": "Налаштування",
+  "navigation_bar.edit_profile": "Редагувати профіль",
+  "navigation_bar.preferences": "Налаштування",
   "navigation_bar.public_timeline": "Публічна стіна",
   "navigation_bar.logout": "Вийти",
   "reply_indicator.cancel": "Відмінити",
diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx
index 3a1473bc1..74d77f0f9 100644
--- a/app/assets/javascripts/components/middleware/errors.jsx
+++ b/app/assets/javascripts/components/middleware/errors.jsx
@@ -23,7 +23,7 @@ export default function errorsMiddleware() {
           dispatch(showAlert(title, message));
         } else {
           console.error(action.error);
-          dispatch(showAlert('Oops!', 'An unexpected error occurred. Inspect the console for more details'));
+          dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
         }
       }
     }
diff --git a/app/assets/javascripts/components/middleware/loading_bar.jsx b/app/assets/javascripts/components/middleware/loading_bar.jsx
new file mode 100644
index 000000000..a98f1bb2b
--- /dev/null
+++ b/app/assets/javascripts/components/middleware/loading_bar.jsx
@@ -0,0 +1,25 @@
+import { showLoading, hideLoading } from 'react-redux-loading-bar';
+
+const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
+
+export default function loadingBarMiddleware(config = {}) {
+  const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
+
+  return ({ dispatch }) => next => (action) => {
+    if (action.type && !action.skipLoading) {
+      const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
+
+      const isPending = new RegExp(`${PENDING}$`, 'g');
+      const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
+      const isRejected = new RegExp(`${REJECTED}$`, 'g');
+
+      if (action.type.match(isPending)) {
+        dispatch(showLoading());
+      } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
+        dispatch(hideLoading());
+      }
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index 7f2f89d0a..409dfd663 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -1,5 +1,4 @@
 import {
-  ACCOUNT_SET_SELF,
   ACCOUNT_FETCH_SUCCESS,
   FOLLOWERS_FETCH_SUCCESS,
   FOLLOWERS_EXPAND_SUCCESS,
@@ -7,7 +6,9 @@ import {
   FOLLOWING_EXPAND_SUCCESS,
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS,
-  FOLLOW_REQUESTS_FETCH_SUCCESS
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS
 } from '../actions/accounts';
 import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
 import {
@@ -33,6 +34,11 @@ import {
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS
 } from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import { STORE_HYDRATE } from '../actions/store';
 import Immutable from 'immutable';
 
 const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account));
@@ -67,38 +73,45 @@ const initialState = Immutable.Map();
 
 export default function accounts(state = initialState, action) {
   switch(action.type) {
-    case ACCOUNT_SET_SELF:
-    case ACCOUNT_FETCH_SUCCESS:
-    case NOTIFICATIONS_UPDATE:
-      return normalizeAccount(state, action.account);
-    case FOLLOWERS_FETCH_SUCCESS:
-    case FOLLOWERS_EXPAND_SUCCESS:
-    case FOLLOWING_FETCH_SUCCESS:
-    case FOLLOWING_EXPAND_SUCCESS:
-    case REBLOGS_FETCH_SUCCESS:
-    case FAVOURITES_FETCH_SUCCESS:
-    case COMPOSE_SUGGESTIONS_READY:
-    case SEARCH_SUGGESTIONS_READY:
-    case FOLLOW_REQUESTS_FETCH_SUCCESS:
-      return normalizeAccounts(state, action.accounts);
-    case NOTIFICATIONS_REFRESH_SUCCESS:
-    case NOTIFICATIONS_EXPAND_SUCCESS:
-      return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
-    case TIMELINE_REFRESH_SUCCESS:
-    case TIMELINE_EXPAND_SUCCESS:
-    case ACCOUNT_TIMELINE_FETCH_SUCCESS:
-    case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
-    case CONTEXT_FETCH_SUCCESS:
-      return normalizeAccountsFromStatuses(state, action.statuses);
-    case REBLOG_SUCCESS:
-    case FAVOURITE_SUCCESS:
-    case UNREBLOG_SUCCESS:
-    case UNFAVOURITE_SUCCESS:
-      return normalizeAccountFromStatus(state, action.response);
-    case TIMELINE_UPDATE:
-    case STATUS_FETCH_SUCCESS:
-      return normalizeAccountFromStatus(state, action.status);
-    default:
-      return state;
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('accounts'));
+  case ACCOUNT_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeAccount(state, action.account);
+  case FOLLOWERS_FETCH_SUCCESS:
+  case FOLLOWERS_EXPAND_SUCCESS:
+  case FOLLOWING_FETCH_SUCCESS:
+  case FOLLOWING_EXPAND_SUCCESS:
+  case REBLOGS_FETCH_SUCCESS:
+  case FAVOURITES_FETCH_SUCCESS:
+  case COMPOSE_SUGGESTIONS_READY:
+  case SEARCH_SUGGESTIONS_READY:
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return normalizeAccounts(state, action.accounts);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeAccountFromStatus(state, action.response);
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+    return normalizeAccountFromStatus(state, action.status);
+  case ACCOUNT_FOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/cards.jsx b/app/assets/javascripts/components/reducers/cards.jsx
new file mode 100644
index 000000000..3c9395011
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/cards.jsx
@@ -0,0 +1,14 @@
+import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
+
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map();
+
+export default function cards(state = initialState, action) {
+  switch(action.type) {
+  case STATUS_CARD_FETCH_SUCCESS:
+    return state.set(action.id, Immutable.fromJS(action.card));
+  default:
+    return state;
+  }
+};
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index 16215684e..d3a84842f 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -17,16 +17,20 @@ import {
   COMPOSE_SUGGESTIONS_READY,
   COMPOSE_SUGGESTION_SELECT,
   COMPOSE_SENSITIVITY_CHANGE,
+  COMPOSE_SPOILERNESS_CHANGE,
+  COMPOSE_SPOILER_TEXT_CHANGE,
   COMPOSE_VISIBILITY_CHANGE,
   COMPOSE_LISTABILITY_CHANGE
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
-import { ACCOUNT_SET_SELF } from '../actions/accounts';
+import { STORE_HYDRATE } from '../actions/store';
 import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   mounted: false,
   sensitive: false,
+  spoiler: false,
+  spoiler_text: '',
   unlisted: false,
   private: false,
   text: '',
@@ -38,7 +42,8 @@ const initialState = Immutable.Map({
   media_attachments: Immutable.List(),
   suggestion_token: null,
   suggestions: Immutable.List(),
-  me: null
+  me: null,
+  resetFileKey: Math.floor((Math.random() * 0x10000))
 });
 
 function statusToTextMentions(state, status) {
@@ -55,6 +60,8 @@ function statusToTextMentions(state, status) {
 function clearAll(state) {
   return state.withMutations(map => {
     map.set('text', '');
+    map.set('spoiler', false);
+    map.set('spoiler_text', '');
     map.set('is_submitting', false);
     map.set('in_reply_to', null);
     map.update('media_attachments', list => list.clear());
@@ -65,6 +72,7 @@ function appendMedia(state, media) {
   return state.withMutations(map => {
     map.update('media_attachments', list => list.push(media));
     map.set('is_uploading', false);
+    map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
     map.update('text', oldText => `${oldText} ${media.get('text_url')}`.trim());
   });
 };
@@ -80,7 +88,7 @@ function removeMedia(state, mediaId) {
 
 const insertSuggestion = (state, position, token, completion) => {
   return state.withMutations(map => {
-    map.update('text', oldText => `${oldText.slice(0, position)}${completion}${oldText.slice(position + token.length)}`);
+    map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
     map.set('suggestion_token', null);
     map.update('suggestions', Immutable.List(), list => list.clear());
   });
@@ -88,64 +96,68 @@ const insertSuggestion = (state, position, token, completion) => {
 
 export default function compose(state = initialState, action) {
   switch(action.type) {
-    case COMPOSE_MOUNT:
-      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_VISIBILITY_CHANGE:
-      return state.set('private', action.checked);
-    case COMPOSE_LISTABILITY_CHANGE:
-      return state.set('unlisted', action.checked);      
-    case COMPOSE_CHANGE:
-      return state.set('text', action.text);
-    case COMPOSE_REPLY:
-      return state.withMutations(map => {
-        map.set('in_reply_to', action.status.get('id'));
-        map.set('text', statusToTextMentions(state, action.status));
-      });
-    case COMPOSE_REPLY_CANCEL:
-      return state.withMutations(map => {
-        map.set('in_reply_to', null);
-        map.set('text', '');
-      });
-    case COMPOSE_SUBMIT_REQUEST:
-      return state.set('is_submitting', true);
-    case COMPOSE_SUBMIT_SUCCESS:
-      return clearAll(state);
-    case COMPOSE_SUBMIT_FAIL:
-      return state.set('is_submitting', false);
-    case COMPOSE_UPLOAD_REQUEST:
-      return state.withMutations(map => {
-        map.set('is_uploading', true);
-        map.set('fileDropDate', new Date());
-      });
-    case COMPOSE_UPLOAD_SUCCESS:
-      return appendMedia(state, Immutable.fromJS(action.media));
-    case COMPOSE_UPLOAD_FAIL:
-      return state.set('is_uploading', false);
-    case COMPOSE_UPLOAD_UNDO:
-      return removeMedia(state, action.media_id);
-    case COMPOSE_UPLOAD_PROGRESS:
-      return state.set('progress', Math.round((action.loaded / action.total) * 100));
-    case COMPOSE_MENTION:
-      return state.update('text', text => `${text}@${action.account.get('acct')} `);
-    case COMPOSE_SUGGESTIONS_CLEAR:
-      return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
-    case COMPOSE_SUGGESTIONS_READY:
-      return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
-    case COMPOSE_SUGGESTION_SELECT:
-      return insertSuggestion(state, action.position, action.token, action.completion);
-    case TIMELINE_DELETE:
-      if (action.id === state.get('in_reply_to')) {
-        return state.set('in_reply_to', null);
-      } else {
-        return state;
-      }
-    case ACCOUNT_SET_SELF:
-      return state.set('me', action.account.id).set('private', action.account.locked);
-    default:
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('compose'));
+  case COMPOSE_MOUNT:
+    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_SPOILERNESS_CHANGE:
+    return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked);
+  case COMPOSE_SPOILER_TEXT_CHANGE:
+    return state.set('spoiler_text', action.text);
+  case COMPOSE_VISIBILITY_CHANGE:
+    return state.set('private', action.checked);
+  case COMPOSE_LISTABILITY_CHANGE:
+    return state.set('unlisted', action.checked);
+  case COMPOSE_CHANGE:
+    return state.set('text', action.text);
+  case COMPOSE_REPLY:
+    return state.withMutations(map => {
+      map.set('in_reply_to', action.status.get('id'));
+      map.set('text', statusToTextMentions(state, action.status));
+    });
+  case COMPOSE_REPLY_CANCEL:
+    return state.withMutations(map => {
+      map.set('in_reply_to', null);
+      map.set('text', '');
+    });
+  case COMPOSE_SUBMIT_REQUEST:
+    return state.set('is_submitting', true);
+  case COMPOSE_SUBMIT_SUCCESS:
+    return clearAll(state);
+  case COMPOSE_SUBMIT_FAIL:
+    return state.set('is_submitting', false);
+  case COMPOSE_UPLOAD_REQUEST:
+    return state.withMutations(map => {
+      map.set('is_uploading', true);
+      map.set('fileDropDate', new Date());
+    });
+  case COMPOSE_UPLOAD_SUCCESS:
+    return appendMedia(state, Immutable.fromJS(action.media));
+  case COMPOSE_UPLOAD_FAIL:
+    return state.set('is_uploading', false);
+  case COMPOSE_UPLOAD_UNDO:
+    return removeMedia(state, action.media_id);
+  case COMPOSE_UPLOAD_PROGRESS:
+    return state.set('progress', Math.round((action.loaded / action.total) * 100));
+  case COMPOSE_MENTION:
+    return state.update('text', text => `${text}@${action.account.get('acct')} `);
+  case COMPOSE_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
+  case COMPOSE_SUGGESTIONS_READY:
+    return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
+  case COMPOSE_SUGGESTION_SELECT:
+    return insertSuggestion(state, action.position, action.token, action.completion);
+  case TIMELINE_DELETE:
+    if (action.id === state.get('in_reply_to')) {
+      return state.set('in_reply_to', null);
+    } else {
       return state;
+    }
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx
index aea9239f8..0798116c4 100644
--- a/app/assets/javascripts/components/reducers/index.jsx
+++ b/app/assets/javascripts/components/reducers/index.jsx
@@ -11,6 +11,9 @@ import statuses from './statuses';
 import relationships from './relationships';
 import search from './search';
 import notifications from './notifications';
+import settings from './settings';
+import status_lists from './status_lists';
+import cards from './cards';
 
 export default combineReducers({
   timelines,
@@ -20,9 +23,12 @@ export default combineReducers({
   loadingBar: loadingBarReducer,
   modal,
   user_lists,
+  status_lists,
   accounts,
   statuses,
   relationships,
   search,
-  notifications
+  notifications,
+  settings,
+  cards
 });
diff --git a/app/assets/javascripts/components/reducers/meta.jsx b/app/assets/javascripts/components/reducers/meta.jsx
index c7222c60b..cd4b313d5 100644
--- a/app/assets/javascripts/components/reducers/meta.jsx
+++ b/app/assets/javascripts/components/reducers/meta.jsx
@@ -1,16 +1,16 @@
-import { ACCESS_TOKEN_SET } from '../actions/meta';
-import { ACCOUNT_SET_SELF } from '../actions/accounts';
+import { STORE_HYDRATE } from '../actions/store';
 import Immutable from 'immutable';
 
-const initialState = Immutable.Map();
+const initialState = Immutable.Map({
+  access_token: null,
+  me: null
+});
 
 export default function meta(state = initialState, action) {
   switch(action.type) {
-    case ACCESS_TOKEN_SET:
-      return state.set('access_token', action.token);
-    case ACCOUNT_SET_SELF:
-      return state.set('me', action.account.id);
-    default:
-      return state;
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('meta'));
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx
index b529b6aa8..ac53ea210 100644
--- a/app/assets/javascripts/components/reducers/modal.jsx
+++ b/app/assets/javascripts/components/reducers/modal.jsx
@@ -8,14 +8,14 @@ const initialState = Immutable.Map({
 
 export default function modal(state = initialState, action) {
   switch(action.type) {
-    case MEDIA_OPEN:
-      return state.withMutations(map => {
-        map.set('url', action.url);
-        map.set('open', true);
-      });
-    case MODAL_CLOSE:
-      return state.set('open', false);
-    default:
-      return state;
+  case MEDIA_OPEN:
+    return state.withMutations(map => {
+      map.set('url', action.url);
+      map.set('open', true);
+    });
+  case MODAL_CLOSE:
+    return state.set('open', false);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx
index e0d1ccf83..482093c33 100644
--- a/app/assets/javascripts/components/reducers/notifications.jsx
+++ b/app/assets/javascripts/components/reducers/notifications.jsx
@@ -2,7 +2,10 @@ import {
   NOTIFICATIONS_UPDATE,
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS,
-  NOTIFICATIONS_SETTING_CHANGE
+  NOTIFICATIONS_REFRESH_REQUEST,
+  NOTIFICATIONS_EXPAND_REQUEST,
+  NOTIFICATIONS_REFRESH_FAIL,
+  NOTIFICATIONS_EXPAND_FAIL
 } from '../actions/notifications';
 import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
 import Immutable from 'immutable';
@@ -11,22 +14,7 @@ const initialState = Immutable.Map({
   items: Immutable.List(),
   next: null,
   loaded: false,
-
-  settings: Immutable.Map({
-    alerts: Immutable.Map({
-      follow: true,
-      favourite: true,
-      reblog: true,
-      mention: true
-    }),
-
-    shows: Immutable.Map({
-      follow: true,
-      favourite: true,
-      reblog: true,
-      mention: true
-    })
-  })
+  isLoading: true
 });
 
 const notificationToMap = notification => Immutable.Map({
@@ -48,7 +36,11 @@ const normalizeNotifications = (state, notifications, next) => {
     items = items.set(i, notificationToMap(n));
   });
 
-  return state.update('items', list => loaded ? list.unshift(...items) : list.push(...items)).set('next', next).set('loaded', true);
+  return state
+    .update('items', list => loaded ? list.unshift(...items) : list.push(...items))
+    .set('next', next)
+    .set('loaded', true)
+    .set('isLoading', false);
 };
 
 const appendNormalizedNotifications = (state, notifications, next) => {
@@ -58,7 +50,10 @@ const appendNormalizedNotifications = (state, notifications, next) => {
     items = items.set(i, notificationToMap(n));
   });
 
-  return state.update('items', list => list.push(...items)).set('next', next);
+  return state
+    .update('items', list => list.push(...items))
+    .set('next', next)
+    .set('isLoading', false);
 };
 
 const filterNotifications = (state, relationship) => {
@@ -67,17 +62,20 @@ const filterNotifications = (state, relationship) => {
 
 export default function notifications(state = initialState, action) {
   switch(action.type) {
-    case NOTIFICATIONS_UPDATE:
-      return normalizeNotification(state, action.notification);
-    case NOTIFICATIONS_REFRESH_SUCCESS:
-      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);
-    case NOTIFICATIONS_SETTING_CHANGE:
-      return state.setIn(['settings', ...action.key], action.checked);
-    default:
-      return state;
+  case NOTIFICATIONS_REFRESH_REQUEST:
+  case NOTIFICATIONS_EXPAND_REQUEST:
+  case NOTIFICATIONS_REFRESH_FAIL:
+  case NOTIFICATIONS_EXPAND_FAIL:
+    return state.set('isLoading', true);
+  case NOTIFICATIONS_UPDATE:
+    return normalizeNotification(state, action.notification);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+    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/search.jsx b/app/assets/javascripts/components/reducers/search.jsx
index 9c2041863..d835ef268 100644
--- a/app/assets/javascripts/components/reducers/search.jsx
+++ b/app/assets/javascripts/components/reducers/search.jsx
@@ -23,7 +23,7 @@ const normalizeSuggestions = (state, value, accounts) => {
     }
   ];
 
-  if (value.indexOf('@') === -1) {
+  if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) {
     newSuggestions.push({
       title: 'hashtag',
       items: [
diff --git a/app/assets/javascripts/components/reducers/settings.jsx b/app/assets/javascripts/components/reducers/settings.jsx
new file mode 100644
index 000000000..8acc3faca
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/settings.jsx
@@ -0,0 +1,46 @@
+import { SETTING_CHANGE } from '../actions/settings';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  home: Immutable.Map({
+    shows: Immutable.Map({
+      reblog: true,
+      reply: true
+    })
+  }),
+
+  notifications: Immutable.Map({
+    alerts: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    }),
+
+    shows: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    }),
+
+    sounds: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    })
+  })
+});
+
+export default function settings(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.mergeDeep(action.state.get('settings'));
+  case SETTING_CHANGE:
+    return state.setIn(action.key, action.value);
+  default:
+    return state;
+  }
+};
diff --git a/app/assets/javascripts/components/reducers/status_lists.jsx b/app/assets/javascripts/components/reducers/status_lists.jsx
new file mode 100644
index 000000000..b883b1c58
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/status_lists.jsx
@@ -0,0 +1,39 @@
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  favourites: Immutable.Map({
+    next: null,
+    loaded: false,
+    items: Immutable.List()
+  })
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('loaded', true);
+    map.set('items', Immutable.List(statuses.map(item => item.id)));
+  }));
+};
+
+const appendToList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('items', map.get('items').push(...statuses.map(item => item.id)));
+  }));
+};
+
+export default function statusLists(state = initialState, action) {
+  switch(action.type) {
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'favourites', action.statuses, action.next);
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return appendToList(state, 'favourites', action.statuses, action.next);
+  default:
+    return state;
+  }
+};
diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx
index c740b6d64..084b6304c 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -28,6 +28,10 @@ import {
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS
 } from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
 import Immutable from 'immutable';
 
 const normalizeStatus = (state, status) => {
@@ -77,36 +81,38 @@ const initialState = Immutable.Map();
 
 export default function statuses(state = initialState, action) {
   switch(action.type) {
-    case TIMELINE_UPDATE:
-    case STATUS_FETCH_SUCCESS:
-    case NOTIFICATIONS_UPDATE:
-      return normalizeStatus(state, action.status);
-    case REBLOG_SUCCESS:
-    case UNREBLOG_SUCCESS:
-    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:
-    case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
-    case CONTEXT_FETCH_SUCCESS:
-    case NOTIFICATIONS_REFRESH_SUCCESS:
-    case NOTIFICATIONS_EXPAND_SUCCESS:
-      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;
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeStatus(state, action.status);
+  case REBLOG_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  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:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    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 b73c83e0f..6f2d26dcb 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -1,9 +1,12 @@
 import {
   TIMELINE_REFRESH_REQUEST,
   TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_REFRESH_FAIL,
   TIMELINE_UPDATE,
   TIMELINE_DELETE,
   TIMELINE_EXPAND_SUCCESS,
+  TIMELINE_EXPAND_REQUEST,
+  TIMELINE_EXPAND_FAIL,
   TIMELINE_SCROLL_TOP
 } from '../actions/timelines';
 import {
@@ -13,37 +16,43 @@ import {
   UNFAVOURITE_SUCCESS
 } from '../actions/interactions';
 import {
-  ACCOUNT_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_REQUEST,
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_FAIL,
+  ACCOUNT_TIMELINE_EXPAND_REQUEST,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_FAIL,
   ACCOUNT_BLOCK_SUCCESS
 } from '../actions/accounts';
 import {
-  STATUS_FETCH_SUCCESS,
   CONTEXT_FETCH_SUCCESS
 } from '../actions/statuses';
 import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   home: Immutable.Map({
+    isLoading: false,
     loaded: false,
     top: true,
     items: Immutable.List()
   }),
 
   mentions: Immutable.Map({
+    isLoading: false,
     loaded: false,
     top: true,
     items: Immutable.List()
   }),
 
   public: Immutable.Map({
+    isLoading: false,
     loaded: false,
     top: true,
     items: Immutable.List()
   }),
 
   tag: Immutable.Map({
+    isLoading: false,
     id: null,
     loaded: false,
     top: true,
@@ -82,6 +91,7 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => {
   });
 
   state = state.setIn([timeline, 'loaded'], true);
+  state = state.setIn([timeline, 'isLoading'], false);
 
   return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids));
 };
@@ -94,6 +104,8 @@ const appendNormalizedTimeline = (state, timeline, statuses) => {
     moreIds = moreIds.set(i, status.get('id'));
   });
 
+  state = state.setIn([timeline, 'isLoading'], false);
+
   return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds));
 };
 
@@ -105,7 +117,10 @@ const normalizeAccountTimeline = (state, accountId, statuses, replace = false) =
     ids   = ids.set(i, status.get('id'));
   });
 
-  return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids)));
+  return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
+    .set('isLoading', false)
+    .set('loaded', true)
+    .update('items', Immutable.List(), list => (replace ? ids : list.unshift(...ids))));
 };
 
 const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
@@ -116,7 +131,9 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
     moreIds = moreIds.set(i, status.get('id'));
   });
 
-  return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
+  return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
+    .set('isLoading', false)
+    .update('items', list => list.push(...moreIds)));
 };
 
 const updateTimeline = (state, timeline, status, references) => {
@@ -145,14 +162,19 @@ const updateTimeline = (state, timeline, status, references) => {
   return state;
 };
 
-const deleteStatus = (state, id, accountId, references) => {
+const deleteStatus = (state, id, accountId, references, reblogOf) => {
+  if (reblogOf) {
+    // If we are deleting a reblog, just replace reblog with its original
+    return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item));
+  }
+
   // Remove references from timelines
   ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
     state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
   });
 
   // Remove references from account timelines
-  state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id));
+  state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id));
 
   // Remove references from context
   state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
@@ -202,8 +224,11 @@ const resetTimeline = (state, timeline, id) => {
   if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) {
     state = state.update(timeline, map => map
         .set('id', id)
+        .set('isLoading', true)
         .set('loaded', false)
         .update('items', list => list.clear()));
+  } else {
+    state = state.setIn([timeline, 'isLoading'], true);
   }
 
   return state;
@@ -211,27 +236,37 @@ const resetTimeline = (state, timeline, id) => {
 
 export default function timelines(state = initialState, action) {
   switch(action.type) {
-    case TIMELINE_REFRESH_REQUEST:
-      return resetTimeline(state, action.timeline, action.id);
-    case TIMELINE_REFRESH_SUCCESS:
-      return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
-    case TIMELINE_EXPAND_SUCCESS:
-      return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
-    case TIMELINE_UPDATE:
-      return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
-    case TIMELINE_DELETE:
-      return deleteStatus(state, action.id, action.accountId, action.references);
-    case CONTEXT_FETCH_SUCCESS:
-      return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
-    case ACCOUNT_TIMELINE_FETCH_SUCCESS:
-      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);
-    case TIMELINE_SCROLL_TOP:
-      return state.setIn([action.timeline, 'top'], action.top);
-    default:
-      return state;
+  case TIMELINE_REFRESH_REQUEST:
+  case TIMELINE_EXPAND_REQUEST:
+    return resetTimeline(state, action.timeline, action.id);
+  case TIMELINE_REFRESH_FAIL:
+  case TIMELINE_EXPAND_FAIL:
+    return state.setIn([action.timeline, 'isLoading'], false);
+  case TIMELINE_REFRESH_SUCCESS:
+    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+  case TIMELINE_EXPAND_SUCCESS:
+    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+  case TIMELINE_UPDATE:
+    return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
+  case CONTEXT_FETCH_SUCCESS:
+    return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
+  case ACCOUNT_TIMELINE_FETCH_REQUEST:
+  case ACCOUNT_TIMELINE_EXPAND_REQUEST:
+    return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true));
+  case ACCOUNT_TIMELINE_FETCH_FAIL:
+  case ACCOUNT_TIMELINE_EXPAND_FAIL:
+    return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false));
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+    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);
+  case TIMELINE_SCROLL_TOP:
+    return state.setIn([action.timeline, 'top'], action.top);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx
index 36093663f..72922f509 100644
--- a/app/assets/javascripts/components/reducers/user_lists.jsx
+++ b/app/assets/javascripts/components/reducers/user_lists.jsx
@@ -36,24 +36,24 @@ const appendToList = (state, type, id, accounts, next) => {
 
 export default function userLists(state = initialState, action) {
   switch(action.type) {
-    case FOLLOWERS_FETCH_SUCCESS:
-      return normalizeList(state, 'followers', action.id, action.accounts, action.next);
-    case FOLLOWERS_EXPAND_SUCCESS:
-      return appendToList(state, 'followers', action.id, action.accounts, action.next);
-    case FOLLOWING_FETCH_SUCCESS:
-      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 REBLOGS_FETCH_SUCCESS:
-      return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
-    case FAVOURITES_FETCH_SUCCESS:
-      return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
-    case FOLLOW_REQUESTS_FETCH_SUCCESS:
-      return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
-    case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
-    case FOLLOW_REQUEST_REJECT_SUCCESS:
-      return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
-    default:
-      return state;
+  case FOLLOWERS_FETCH_SUCCESS:
+    return normalizeList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWERS_EXPAND_SUCCESS:
+    return appendToList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWING_FETCH_SUCCESS:
+    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 REBLOGS_FETCH_SUCCESS:
+    return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FAVOURITES_FETCH_SUCCESS:
+    return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx
index 3d03d4c19..ad0427b52 100644
--- a/app/assets/javascripts/components/store/configureStore.jsx
+++ b/app/assets/javascripts/components/store/configureStore.jsx
@@ -1,11 +1,23 @@
 import { createStore, applyMiddleware, compose } from 'redux';
-import thunk                                     from 'redux-thunk';
-import appReducer                                from '../reducers';
-import { loadingBarMiddleware }                  from 'react-redux-loading-bar';
-import errorsMiddleware                          from '../middleware/errors';
+import thunk from 'redux-thunk';
+import appReducer from '../reducers';
+import loadingBarMiddleware from '../middleware/loading_bar';
+import errorsMiddleware from '../middleware/errors';
+import soundsMiddleware from 'redux-sounds';
+import Howler from 'howler';
+import Immutable from 'immutable';
 
-export default function configureStore(initialState) {
-  return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({
-    promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
-  }), errorsMiddleware()), window.devToolsExtension ? window.devToolsExtension() : f => f));
+Howler.mobileAutoEnable = false;
+
+const soundsData = {
+  boop: '/sounds/boop.mp3'
+};
+
+export default function configureStore() {
+  return createStore(appReducer, compose(applyMiddleware(
+    thunk,
+    loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
+    errorsMiddleware(),
+    soundsMiddleware(soundsData)
+  ), window.devToolsExtension ? window.devToolsExtension() : f => f));
 };
diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx
index b9f8e6842..5738863dd 100644
--- a/app/assets/javascripts/extras.jsx
+++ b/app/assets/javascripts/extras.jsx
@@ -1,7 +1,7 @@
 import emojify from './components/emoji'
 
 $(() => {
-  $.each($('.entry .content, .entry .status__content, .status__display-name, .display-name, .name, .account__header__content'), (_, content) => {
+  $.each($('.emojify'), (_, content) => {
     const $content = $(content);
     $content.html(emojify($content.html()));
   });
@@ -19,8 +19,6 @@ $(() => {
   });
 
   $('.webapp-btn').on('click', e => {
-    console.log(e);
-
     if (e.button === 0) {
       e.preventDefault();
       window.location.href = $(e.target).attr('href');
diff --git a/app/assets/stylesheets/about.scss b/app/assets/stylesheets/about.scss
index 3681672d8..b7d903ddf 100644
--- a/app/assets/stylesheets/about.scss
+++ b/app/assets/stylesheets/about.scss
@@ -1,20 +1,21 @@
-@import url(https://fonts.googleapis.com/css?family=Montserrat);
-@import url(https://fonts.googleapis.com/css?family=Judson);
-
 .about-body {
   .wrapper {
     max-width: 600px;
     margin: 0 auto;
-    color: #9baec8;
+    color: $color3;
     padding-top: 50px;
     padding-bottom: 50px;
+
+    &.thicc {
+      max-width: 700px;
+    }
   }
 
   h1 {
-    font: 46px/52px 'Roboto', sans-serif;
+    font: 46px/52px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
     font-weight: 600;
     margin-bottom: 20px;
-    color: #2b90d9;
+    color: $color4;
     padding: 20px 0;
 
     img {
@@ -26,17 +27,21 @@
   }
 
   h2 {
-    font: 24px/28px 'Judson', sans-serif;
-    font-weight: 300;
+    font-family: 'Montserrat', sans-serif;
+    font-size: 24px;
+    line-height: 28px;
+    font-weight: 400;
     margin-bottom: 20px;
-    color: #fff;
+    color: $color5;
   }
 
   h3 {
-    font: 20px/28px 'Judson', sans-serif;
-    font-weight: 300;
+    font-family: 'Montserrat', sans-serif;
+    font-size: 20px;
+    line-height: 28px;
+    font-weight: 400;
     margin-bottom: 20px;
-    color: #d9e1e8;
+    color: $color2;
   }
 
   ul, ol {
@@ -57,12 +62,12 @@
   }
 
   p, li {
-    font: 20px/28px 'Judson', sans-serif;
-    font-weight: 300;
+    font: 16px/28px 'Montserrat', sans-serif;
+    font-weight: 400;
     margin-bottom: 26px;
 
     a {
-      color: #2b90d9;
+      color: $color4;
       text-decoration: underline;
     }
   }
@@ -70,14 +75,15 @@
   em {
     display: inline-block;
     padding: 7px 7px 5px 7px;
-    background: #9baec8;
-    color: #282c37;
+    margin: 0 2px;
+    background: $color3;
+    color: $color1;
     font: 16px/16px 'Montserrat', sans-serif;
     font-weight: 300;
   }
 
   .screenshot {
-    box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
+    box-shadow: 0 0 15px rgba($color8, 0.4);
     margin-bottom: 26px;
 
     img {
@@ -96,7 +102,7 @@
       line-height: 36px;
 
       a {
-        color: #9baec8;
+        color: $color3;
         text-decoration: underline;
       }
     }
@@ -108,3 +114,162 @@
     }
   }
 }
+
+.information-board {
+  margin: 20px 0;
+  display: flex;
+  justify-content: space-between;
+  border-top: 1px solid lighten($color1, 10%);
+  border-bottom: 1px solid lighten($color1, 10%);
+  padding-right: 14px;
+
+  .section {
+    flex: 1 0 0;
+    padding: 14px;
+    text-align: right;
+    font: 16px/28px 'Montserrat', sans-serif;
+
+    span, strong {
+      display: block;
+    }
+
+    span {
+      font-size: 16px;
+
+      &:last-child {
+        color: $color2;
+        font-size: 14px;
+      }
+    }
+
+    strong {
+      font-weight: 500;
+      font-size: 32px;
+      line-height: 48px;
+      color: $color5;
+    }
+  }
+}
+
+.owner {
+  text-align: center;
+
+  .avatar {
+    width: 80px;
+    height: 80px;
+    margin: 0 auto;
+    margin-bottom: 15px;
+
+    img {
+      display: block;
+      width: 80px;
+      height: 80px;
+      border-radius: 48px;
+    }
+  }
+
+  .name {
+    font-size: 14px;
+
+    a {
+      display: block;
+      color: $color5;
+      text-decoration: none;
+
+      &:hover {
+        .display_name {
+          text-decoration: underline;
+        }
+      }
+    }
+
+    .username {
+      display: block;
+      color: $color3;
+    }
+  }
+}
+
+.contact-email {
+  text-align: center;
+  margin: 40px 0;
+
+  strong {
+    display: block;
+    color: $color5;
+  }
+}
+
+.sidebar-layout {
+  display: flex;
+
+  .main {
+    flex: 1 1 auto;
+    padding: 14px 0;
+
+    .panel {
+      padding-right: 14px;
+    }
+  }
+
+  .sidebar {
+    border-left: 1px solid lighten($color1, 10%);
+    width: 180px;
+    flex: 0 0 auto;
+  }
+
+  .panel {
+    .panel-header {
+      background: lighten($color1, 10%);
+      padding: 7px 14px;
+      text-transform: uppercase;
+      font-size: 12px;
+      font-weight: 500;
+    }
+
+    .panel-body {
+      padding: 14px;
+    }
+
+    .panel-list {
+      ul {
+        list-style: none;
+        margin: 0;
+
+        li {
+          margin: 0;
+          font-family: inherit;
+          font-size: 13px;
+          line-height: 18px;
+
+          a {
+            display: block;
+            padding: 7px 14px;
+            color: rgba($color5, 0.7);
+            text-decoration: none;
+            transition: all 200ms linear;
+
+            i.fa {
+              margin-right: 5px;
+            }
+
+            &:hover {
+              color: $color5;
+              background-color: darken($color1, 5%);
+              transition: all 100ms linear;
+            }
+
+            &.selected {
+              color: $color5;
+              background-color: $color4;
+
+              &:hover {
+                background-color: lighten($color4, 5%);
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss
index 748bb8224..7c48c91f3 100644
--- a/app/assets/stylesheets/accounts.scss
+++ b/app/assets/stylesheets/accounts.scss
@@ -1,10 +1,10 @@
 .card {
-  background: #282c37;
+  background: $color1;
   background-size: cover;
   padding: 60px 0;
   padding-bottom: 0;
   border-radius: 4px 4px 0 0;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 0 15px rgba($color8, 0.2);
   overflow: hidden;
   position: relative;
 
@@ -14,7 +14,7 @@
   }
 
   &:after {
-    background: rgba(0, 0, 0, 0.5);
+    background: rgba($color8, 0.5);
     display: block;
     content: "";
     position: absolute;
@@ -29,7 +29,7 @@
     display: block;
     font-size: 20px;
     line-height: 18px * 1.5;
-    color: #fff;
+    color: $color5;
     font-weight: 500;
     text-align: center;
     position: relative;
@@ -38,7 +38,7 @@
     small {
       display: block;
       font-size: 14px;
-      color: #2b90d9;
+      color: $color4;
       font-weight: 400;
     }
   }
@@ -81,10 +81,10 @@
 
   .counter {
     width: 80px;
-    color: #9baec8;
+    color: $color3;
     padding: 0 10px;
     margin-bottom: 10px;
-    border-right: 1px solid #9baec8;
+    border-right: 1px solid $color3;
     cursor: default;
     position: relative;
 
@@ -99,14 +99,14 @@
       bottom: -10px;
       left: 0;
       width: 100%;
-      border-bottom: 4px solid #9baec8;
+      border-bottom: 4px solid $color3;
       opacity: 0.5;
       transition: all 0.8s ease;
     }
 
     &.active {
       &:after {
-        border-bottom: 4px solid #2b90d9;
+        border-bottom: 4px solid $color4;
         opacity: 1;
       }
     }
@@ -133,7 +133,7 @@
     .counter-number {
       font-weight: 500;
       font-size: 18px;
-      color: #fff;
+      color: $color5;
     }
   }
 
@@ -142,7 +142,7 @@
     font-size: 14px;
     line-height: 18px;
     padding: 5px 10px;
-    color: #d9e1e8;
+    color: $color2;
     order: 1;
   }
 
@@ -173,7 +173,7 @@
 
   a, .current, .next_page, .previous_page, .gap {
     font-size: 14px;
-    color: #fff;
+    color: $color5;
     font-weight: 500;
     display: inline-block;
     padding: 6px 10px;
@@ -181,9 +181,9 @@
   }
 
   .current {
-    background: #fff;
+    background: $color5;
     border-radius: 100px;
-    color: #282c37;
+    color: $color1;
     cursor: default;
   }
 
@@ -193,7 +193,7 @@
 
   .previous_page, .next_page {
     text-transform: uppercase;
-    color: #d9e1e8;
+    color: $color2;
   }
 
   .previous_page {
@@ -218,7 +218,7 @@
 
   .disabled {
     cursor: default;
-    color: lighten(#282c37, 10%);
+    color: lighten($color1, 10%);
   }
 
   @media screen and (max-width: 360px) {
@@ -236,8 +236,8 @@
 
 .accounts-grid {
   clear: both;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
-  background: #fff;
+  box-shadow: 0 0 15px rgba($color8, 0.2);
+  background: $color5;
   border-radius: 0 0 4px 4px;
   padding: 20px 10px;
   padding-bottom: 10px;
@@ -252,9 +252,9 @@
     box-sizing: border-box;
     width: 335px;
     float: left;
-    border: 1px solid #d9e1e8;
+    border: 1px solid $color2;
     border-radius: 4px;
-    color: #282c37;
+    color: $color1;
     height: 160px;
     margin-bottom: 10px;
 
@@ -265,7 +265,7 @@
     .account-grid-card__header {
       overflow: hidden;
       padding: 10px;
-      border-bottom: 1px solid #d9e1e8;
+      border-bottom: 1px solid $color2;
     }
 
     .avatar {
@@ -287,7 +287,7 @@
 
       a {
         display: block;
-        color: #282c37;
+        color: $color1;
         text-decoration: none;
 
         &:hover {
@@ -304,19 +304,19 @@
     }
 
     .username {
-      color: #2b90d9;
+      color: $color4;
     }
 
     .note {
       padding: 10px;
       padding-top: 15px;
-      color: #9baec8;
+      color: $color3;
     }
   }
 }
 
 .nothing-here {
-  color: #9baec8;
+  color: $color3;
   font-size: 14px;
   font-weight: 500;
   text-align: center;
@@ -327,10 +327,10 @@
 
 .account-card {
   padding: 14px 10px;
-  background: #fff;
+  background: $color5;
   border-radius: 4px;
   text-align: left;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 0 15px rgba($color8, 0.2);
 
   .detailed-status__display-name {
     display: block;
@@ -363,12 +363,12 @@
 
       strong {
         font-weight: 500;
-        color: #282c37;
+        color: $color1;
       }
 
       span {
         font-size: 14px;
-        color: #9baec8;
+        color: $color3;
       }
     }
 
@@ -383,6 +383,6 @@
 
   .account__header__content {
     font-size: 14px;
-    color: #282c37;
+    color: $color1;
   }
 }
diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss
index 6e4234d13..8d01ac4c4 100644
--- a/app/assets/stylesheets/admin.scss
+++ b/app/assets/stylesheets/admin.scss
@@ -2,7 +2,7 @@
   width: 100%;
   height: 100%;
   position: fixed;
-  background: #1a1c23;
+  background: darken($color1, 2%);
   overflow-y: scroll;
 
   .sidebar {
@@ -10,7 +10,7 @@
     position: fixed;
     left: 0;
     height: 100%;
-    background: #282c37;
+    background: $color1;
 
     .logo {
       display: block;
@@ -25,7 +25,7 @@
       a {
         display: block;
         padding: 15px 25px;
-        color: rgba(255, 255, 255, 0.7);
+        color: rgba($color5, 0.7);
         text-decoration: none;
         transition: all 200ms linear;
 
@@ -34,17 +34,17 @@
         }
 
         &:hover {
-          color: #fff;
-          background-color: darken(#282c37, 5%);
+          color: $color5;
+          background-color: darken($color1, 5%);
           transition: all 100ms linear;
         }
 
         &.selected {
-          color: #fff;
-          background-color: #2b90d9;
+          color: $color5;
+          background-color: $color4;
 
           &:hover {
-            background-color: lighten(#2b90d9, 5%);
+            background-color: lighten($color4, 5%);
           }
         }
       }
@@ -84,21 +84,21 @@
 
     a {
       display: inline-block;
-      color: rgba(255, 255, 255, 0.7);
+      color: rgba($color5, 0.7);
       text-decoration: none;
       text-transform: uppercase;
       font-size: 12px;
       font-weight: 500;
-      border-bottom: 2px solid #282c37;
+      border-bottom: 2px solid $color1;
 
       &:hover {
-        color: #fff;
-        border-bottom: 2px solid lighten(#282c37, 5%);
+        color: $color5;
+        border-bottom: 2px solid lighten($color1, 5%);
       }
 
       &.selected {
-        color: #2b90d9;
-        border-bottom: 2px solid #2b90d9;
+        color: $color4;
+        border-bottom: 2px solid $color4;
       }
     }
   }
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index e4c550b81..649a0148b 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -1,6 +1,8 @@
+@import 'variables';
 @import url(https://fonts.googleapis.com/css?family=Roboto:400,500,400italic);
 @import url(https://fonts.googleapis.com/css?family=Roboto+Mono:400,500);
-@import "font-awesome";
+@import url(https://fonts.googleapis.com/css?family=Montserrat);
+@import 'font-awesome';
 
 /* http://meyerweb.com/eric/tools/css/reset/
    v2.0 | 20110126
@@ -63,31 +65,31 @@ table {
 }
 
 ::-webkit-scrollbar-thumb {
-  background: #42495b;
-  border: 0px none #ffffff;
+  background: lighten($color1, 4%);
+  border: 0px none $color5;
   border-radius: 50px;
 }
 
 ::-webkit-scrollbar-thumb:hover {
-  background: #525a70;
+  background: lighten($color1, 6%);
 }
 
 ::-webkit-scrollbar-thumb:active {
-  background: #42495b;
+  background: lighten($color1, 4%);
 }
 
 ::-webkit-scrollbar-track {
-  border: 0px none #ffffff;
+  border: 0px none $color5;
   border-radius: 0;
-  background: rgba(0, 0, 0, 0.1);
+  background: rgba($color8, 0.1);
 }
 
 ::-webkit-scrollbar-track:hover {
-  background: #282c37;
+  background: $color1;
 }
 
 ::-webkit-scrollbar-track:active {
-  background: #282c37;
+  background: $color1;
 }
 
 ::-webkit-scrollbar-corner {
@@ -96,13 +98,13 @@ table {
 
 body {
   font-family: 'Roboto', sans-serif;
-  background: #282c37 image-url('background-photo.jpeg');
+  background: $color1 image-url('background-photo.jpeg');
   background-size: cover;
   background-attachment: fixed;
   font-size: 13px;
   line-height: 18px;
   font-weight: 400;
-  color: #fff;
+  color: $color5;
   padding-bottom: 140px;
   text-rendering: optimizelegibility;
   font-feature-settings: "kern";
@@ -164,7 +166,7 @@ body {
   h1 {
     display: block;
     text-align: center;
-    color: #fff;
+    color: $color5;
     font-size: 48px;
     font-weight: 500;
 
@@ -215,12 +217,10 @@ body {
   text-align: center;
   margin-top: 30px;
   font-size: 12px;
-  color: darken(#d9e1e8, 25%);
+  color: darken($color2, 25%);
 
   .domain {
-    //font-size: 12px;
     font-weight: 500;
-    //font-family: 'Roboto Mono', monospace;
 
     a {
       color: inherit;
diff --git a/app/assets/stylesheets/boost.scss b/app/assets/stylesheets/boost.scss
new file mode 100644
index 000000000..a2e6421f8
--- /dev/null
+++ b/app/assets/stylesheets/boost.scss
@@ -0,0 +1,7 @@
+@function url-friendly-colour($colour) {
+  @return '%23' + str-slice('#{$colour}', 2, -1)
+}
+
+button i.fa-retweet {
+  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{url-friendly-colour(lighten($color1, 26%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{url-friendly-colour($color4)}' stroke-width='0'/></svg>");
+}
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index acfa85c6b..6014da5b6 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -1,12 +1,12 @@
 .button {
-  background-color: #2b90d9;
-  font-family: 'Roboto';
+  background-color: darken($color4, 3%);
+  font-family: inherit;
   display: inline-block;
   position: relative;
   box-sizing: border-box;
   text-align: center;
   border: 10px none;
-  color: #fff;
+  color: $color5;
   font-size: 14px;
   font-weight: 500;
   letter-spacing: 0;
@@ -19,56 +19,69 @@
   text-decoration: none;
 
   &:hover {
-    background-color: #489fde;
+    background-color: lighten($color4, 7%);
   }
 
   &:disabled {
-    background-color: #9baec8;
+    background-color: $color3;
     cursor: default;
   }
 
   &.button-secondary {
-    background-color: #282c37;
+    background-color: $color1;
 
     &:hover {
-      background-color: #282c37;
+      background-color: $color1;
     }
 
     &:disabled {
-      background-color: #9baec8;
+      background-color: $color3;
     }
   }
 }
 
 .icon-button {
-  color: #616b86;
+  color: lighten($color1, 26%);
   border: none;
   background: transparent;
   cursor: pointer;
 
   &:hover {
-    color: #717b98;
+    color: lighten($color1, 33%);
   }
 
   &.disabled {
-    color: #454b5e;
+    color: lighten($color1, 13%);
     cursor: default;
   }
 
   &.active {
-    color: #2b90d9;
+    color: $color4;
+  }
+}
+
+.invisible {
+  font-size: 0;
+  line-height: 0;
+  display: inline-block;
+  width: 0;
+}
+
+.ellipsis {
+  &:after {
+    content: "…";
   }
 }
 
 .lightbox .icon-button {
-  color: #282c37;
+  color: $color1;
 }
 
 .compose-form__textarea, .follow-form__input {
-  background: #fff;
+  background: $color5;
 
   &:disabled {
-    background: #d9e1e8;
+    background: $color2;
   }
 }
 
@@ -107,7 +120,7 @@
   }
 
   a {
-    color: #d9e1e8;
+    color: $color2;
     text-decoration: none;
 
     &:hover {
@@ -139,11 +152,11 @@
 }
 
 .reply-indicator__content {
-  color: #282c37;
+  color: $color1;
   font-size: 14px;
 
   a {
-    color: #535b72;
+    color: lighten($color1, 20%);
   }
 }
 
@@ -183,13 +196,13 @@
   }
 }
 
-.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name {
+.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name {
   text-decoration: none;
 }
 
 .status__display-name, .account__display-name {
   strong {
-    color: #fff;
+    color: $color5;
   }
 
   &.muted {
@@ -214,7 +227,7 @@
 }
 
 .detailed-status__display-name {
-  color: #d9e1e8;
+  color: $color2;
   line-height: 24px;
 
   strong, span {
@@ -223,17 +236,17 @@
 
   strong {
     font-size: 16px;
-    color: #fff;
+    color: $color5;
   }
 }
 
 .muted {
   .status__content p, .status__content a {
-    color: #616b86;
+    color: lighten($color1, 26%);
   }
 
   .status__display-name strong {
-    color: #616b86;
+    color: lighten($color1, 26%);
   }
 
   .status__avatar {
@@ -246,7 +259,7 @@
   text-decoration: none;
 
   &:hover {
-    color: #fff;
+    color: $color5;
     text-decoration: underline;
   }
 }
@@ -282,17 +295,17 @@
     height: 0;
     border-style: solid;
     border-width: 0 4.5px 7.8px 4.5px;
-    border-color: transparent transparent #d9e1e8 transparent;
+    border-color: transparent transparent $color2 transparent;
     top: -7px;
     left: 8px;
   }
 
   ul {
     list-style: none;
-    background: #d9e1e8;
+    background: $color2;
     padding: 4px 0;
     border-radius: 4px;
-    box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
+    box-shadow: 0 0 15px rgba($color8, 0.4);
     min-width: 100px;
   }
 
@@ -302,12 +315,12 @@
     padding: 6px 16px;
     width: 100px;
     text-decoration: none;
-    background: #d9e1e8;
-    color: #282c37;
+    background: $color2;
+    color: $color1;
 
     &:hover {
-      background: #2b90d9;
-      color: #d9e1e8;
+      background: $color4;
+      color: $color2;
     }
   }
 }
@@ -315,7 +328,7 @@
 .static-content {
   padding: 10px;
   padding-top: 20px;
-  color: #616b86;
+  color: lighten($color1, 26%);
 
   h1 {
     font-size: 16px;
@@ -331,11 +344,15 @@
 }
 
 .columns-area {
-  margin: 10px;
-  margin-left: 0;
   flex-direction: row;
 }
 
+@media screen and (min-width: 360px) {
+  .columns-area {
+    margin: 10px;
+  }
+}
+
 .column {
   width: 330px;
   position: relative;
@@ -345,12 +362,43 @@
   width: 280px;
 }
 
+.drawer__inner {
+  background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65));
+}
+
+.drawer__header {
+  flex: 0 0 auto;
+  font-size: 16px;
+  background: lighten($color1, 8%);
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+
+  a {
+    transition: all 100ms ease-in;
+
+    &:hover {
+      background: lighten($color1, 3%);
+      transition: all 200ms ease-out;
+    }
+  }
+}
+
 .column, .drawer {
-  margin-left: 10px;
+  margin-left: 5px;
+  margin-right: 5px;
   flex: 0 0 auto;
   overflow: hidden;
 }
 
+.column:first-child, .drawer:first-child {
+  margin-left: 0;
+}
+
+.column:last-child, .drawer:last-child {
+  margin-right: 0;
+}
+
 @media screen and (max-width: 1024px) {
   .column, .drawer {
     width: 100%;
@@ -359,7 +407,6 @@
   }
 
   .columns-area {
-    margin: 10px;
     flex-direction: column;
   }
 }
@@ -368,6 +415,13 @@
   display: flex;
 }
 
+@media screen and (min-width: 360px) {
+  .tabs-bar {
+    margin: 10px;
+    margin-bottom: 0;
+  }
+}
+
 @media screen and (min-width: 1025px) {
   .tabs-bar {
     display: none;
@@ -383,22 +437,22 @@
   top: 100%;
   width: 100%;
   z-index: 99;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
+  box-shadow: 0 0 15px rgba($color8, 0.4);
 }
 
 .react-autosuggest__section-title {
-  background: #9baec8;
+  background: $color3;
   padding: 4px 10px;
   font-weight: 500;
   cursor: default;
-  color: #282c37;
+  color: $color1;
   text-transform: uppercase;
   font-size: 11px;
 }
 
 .react-autosuggest__suggestions-list {
-  background: #d9e1e8;
-  color: #282c37;
+  background: $color2;
+  color: $color1;
   font-size: 14px;
 }
 
@@ -408,8 +462,8 @@
 }
 
 .react-autosuggest__suggestion--focused {
-  background: #2b90d9;
-  color: #fff;
+  background: $color4;
+  color: $color5;
 }
 
 .scrollable {
@@ -417,6 +471,10 @@
   overflow-x: hidden;
   flex: 1 1 auto;
   -webkit-overflow-scrolling: touch;
+
+  &.optionally-scrollable {
+    overflow-y: auto;
+  }
 }
 
 .column-back-button {
@@ -433,7 +491,7 @@
   border: 0;
   padding: 0;
   user-select: none;
-  -webkit-tap-highlight-color: rgba(0,0,0,0);
+  -webkit-tap-highlight-color: rgba($color8, 0);
   -webkit-tap-highlight-color: transparent;
 }
 
@@ -459,20 +517,20 @@
   height: 24px;
   padding: 0;
   border-radius: 30px;
-  background-color: #282c37;
+  background-color: $color1;
   transition: all 0.2s ease;
 }
 
 .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
-  background-color: darken(#282c37, 10%);
+  background-color: darken($color1, 10%);
 }
 
 .react-toggle--checked .react-toggle-track {
-  background-color: #2b90d9;
+  background-color: $color4;
 }
 
 .react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
-  background-color: lighten(#2b90d9, 10%);
+  background-color: lighten($color4, 10%);
 }
 
 .react-toggle-track-check {
@@ -519,59 +577,62 @@
   left: 1px;
   width: 22px;
   height: 22px;
-  border: 1px solid #282c37;
+  border: 1px solid $color1;
   border-radius: 50%;
-  background-color: #FAFAFA;
+  background-color: darken($color5, 2%);
   box-sizing: border-box;
   transition: all 0.25s ease;
 }
 
 .react-toggle--checked .react-toggle-thumb {
   left: 27px;
-  border-color: #2b90d9;
+  border-color: $color4;
 }
 
 .column-link {
-  background: #373b4a;
+  background: lighten($color1, 6%);
 
   &:hover {
-    background: lighten(#373b4a, 5%);
+    background: lighten($color1, 11%);
   }
 }
 
-.autosuggest-textarea {
+.autosuggest-textarea, .spoiler-input {
   position: relative;
 }
 
-.autosuggest-textarea__textarea {
+.autosuggest-textarea__textarea, .spoiler-input__input {
   display: block;
   box-sizing: border-box;
   width: 100%;
-  height: 100px;
   resize: none;
-  color: #282c37;
+  margin: 0;
+  color: $color1;
   padding: 7px;
-  font-family: 'Roboto';
+  font-family: inherit;
   font-size: 14px;
-  margin: 0;
   resize: vertical;
 
   border: 3px dashed transparent;
   transition: border-color 0.3s ease;
 
   &.file-drop {
-    border-color: #aaa;
+    border-color: darken($color5, 33%);
   }
 }
 
+.autosuggest-textarea__textarea {
+  height: 100px;
+}
+
 .autosuggest-textarea__suggestions {
   position: absolute;
   top: 100%;
   width: 100%;
   z-index: 99;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
-  background: #d9e1e8;
-  color: #282c37;
+  box-shadow: 0 0 15px rgba($color8, 0.4);
+  background: $color2;
+  color: $color1;
   font-size: 14px;
 }
 
@@ -580,21 +641,69 @@
   cursor: pointer;
 
   &:hover {
-    background: darken(#d9e1e8, 10%);
+    background: darken($color2, 10%);
   }
 
   &.selected {
-    background: #2b90d9;
-    color: #fff;
+    background: $color4;
+    color: $color5;
   }
 }
 
-.getting-started__illustration {
-  width: 330px;
-  height: 235px;
-  background: image-url('mastodon-getting-started.png') no-repeat 0 0;
-  position: absolute;
-  pointer-events: none;
-  bottom: 0;
-  left: 0;
+.getting-started {
+  box-sizing: border-box;
+  overflow-y: auto;
+  padding-bottom: 235px;
+  background: image-url('mastodon-getting-started.png') no-repeat 0 100% local;
+  height: 100%;
+
+  p {
+    color: $color2;
+  }
+}
+
+.dropdown__content.dropdown__left {
+  transform: translateX(-108px);
+
+  &::before {
+    right: 8px !important;
+    left: initial !important;
+  }
+}
+
+.setting-text {
+  color: $color3;
+  background: transparent;
+  border: none;
+  border-bottom: 2px solid $color3;
+
+  &:focus, &:active {
+    color: $color5;
+    border-bottom-color: $color4;
+  }
+}
+
+@import 'boost';
+
+button i.fa-retweet {
+  height: 19px;
+  width: 22px;
+  background-position: 0 0;
+  transition: background-position 0.9s steps(10);
+  transition-duration: 0s;
+
+  &::before {
+    display: none !important;
+  }
+}
+
+button.active i.fa-retweet {
+  transition-duration: 0.9s;
+  background-position: 0 100%;
+}
+
+.status-card {
+  &:hover {
+    background: lighten($color1, 6%);
+  }
 }
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index e6d2e85a2..365396511 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -16,7 +16,7 @@ code {
 
   .hint {
     display: block;
-    color: rgba(255, 255, 255, 0.8);
+    color: rgba($color5, 0.8);
     font-size: 12px;
   }
 
@@ -26,9 +26,9 @@ code {
     display: flex;
 
     label {
-      font-family: 'Roboto';
+      font-family: inherit;
       font-size: 16px;
-      color: #fff;
+      color: $color5;
       width: 100px;
       display: block;
       flex: 0 0 auto;
@@ -48,7 +48,7 @@ code {
     margin-bottom: 5px;
 
     label {
-      font-family: 'Roboto';
+      font-family: inherit;
       font-size: 14px;
       color: white;
       display: block;
@@ -75,42 +75,42 @@ code {
     background: transparent;
     box-sizing: border-box;
     border: 0;
-    border-bottom: 2px solid #9baec8;
+    border-bottom: 2px solid $color3;
     border-radius: 2px 2px 0 0;
     padding: 7px 4px;
     font-size: 16px;
-    color: #fff;
+    color: $color5;
     display: block;
     width: 100%;
     outline: 0;
-    font-family: 'Roboto';
+    font-family: inherit;
 
     &:invalid {
       box-shadow: none;
     }
 
     &:focus:invalid {
-      border-bottom-color: #df405a;
+      border-bottom-color: $color6;
     }
 
     &:required:valid {
-      border-bottom-color: #79bd9a;
+      border-bottom-color: $color7;
     }
 
     &:active, &:focus {
-      border-bottom-color: #2b90d9;
-      background: rgba(0, 0, 0, 0.1);
+      border-bottom-color: $color4;
+      background: rgba($color8, 0.1);
     }
   }
 
   .input.field_with_errors {
     input[type=text], input[type=email], input[type=password] {
-      border-bottom-color: #df405a;
+      border-bottom-color: $color6;
     }
 
     .error {
       font-weight: 500;
-      color: #df405a;
+      color: $color6;
     }
   }
 
@@ -123,8 +123,8 @@ code {
     width: 100%;
     border: 0;
     border-radius: 4px;
-    background: #2b90d9;
-    color: #fff;
+    background: $color4;
+    color: $color5;
     font-size: 18px;
     padding: 10px;
     text-transform: uppercase;
@@ -134,36 +134,36 @@ code {
     margin-bottom: 10px;
 
     &:hover {
-      background-color: lighten(#2b90d9, 5%);
+      background-color: lighten($color4, 5%);
     }
 
     &:active, &:focus {
       position: relative;
       top: 1px;
-      background-color: darken(#2b90d9, 5%);
+      background-color: darken($color4, 5%);
     }
 
     &.negative {
-      background: #df405a;
+      background: $color6;
 
       &:hover {
-        background-color: lighten(#df405a, 5%);
+        background-color: lighten($color6, 5%);
       }
 
       &:active, &:focus {
-        background-color: darken(#df405a, 5%);
+        background-color: darken($color6, 5%);
       }
     }
   }
 }
 
 .flash-message {
-  background: #282c37;
-  color: #9baec8;
+  background: $color1;
+  color: $color3;
   border-radius: 4px;
   padding: 15px 10px;
   margin-bottom: 30px;
-  box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 0 5px rgba($color8, 0.2);
   text-align: center;
 
   strong {
@@ -188,7 +188,7 @@ code {
 .oauth-prompt, .follow-prompt {
   margin-bottom: 30px;
   text-align: center;
-  color: #9baec8;
+  color: $color3;
 
   h2 {
     font-size: 16px;
@@ -196,7 +196,7 @@ code {
   }
 
   strong {
-    color: #d9e1e8;
+    color: $color2;
     font-weight: 500;
   }
 }
diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss
index 7624bbdc8..2d3cb1436 100644
--- a/app/assets/stylesheets/stream_entries.scss
+++ b/app/assets/stylesheets/stream_entries.scss
@@ -1,12 +1,12 @@
 .activity-stream {
   clear: both;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 0 15px rgba($color8, 0.2);
 
   .entry {
-    background: lighten(#d9e1e8, 8%);
+    background: lighten($color2, 8%);
 
     &, .detailed-status.light {
-      border-bottom: 1px solid #d9e1e8;
+      border-bottom: 1px solid $color2;
     }
 
     &:last-child {
@@ -43,7 +43,7 @@
         font-size: 14px;
 
         .status__relative-time {
-          color: #9baec8;
+          color: $color3;
         }
       }
     }
@@ -52,7 +52,7 @@
       display: block;
       max-width: 100%;
       padding-right: 25px;
-      color: #282c37;
+      color: $color1;
     }
 
     .status__avatar {
@@ -82,20 +82,20 @@
 
       strong {
         font-weight: 500;
-        color: #282c37;
+        color: $color1;
       }
 
       span {
         font-size: 14px;
-        color: #9baec8;
+        color: $color3;
       }
     }
 
     .status__content {
-      color: #282c37;
+      color: $color1;
 
       a {
-        color: #2b90d9;
+        color: $color4;
       }
     }
 
@@ -111,7 +111,7 @@
 
   .detailed-status.light {
     padding: 14px;
-    background: #fff;
+    background: $color5;
     cursor: default;
 
     .detailed-status__display-name {
@@ -133,12 +133,12 @@
 
         strong {
           font-weight: 500;
-          color: #282c37;
+          color: $color1;
         }
 
         span {
           font-size: 14px;
-          color: #9baec8;
+          color: $color3;
         }
       }
     }
@@ -154,16 +154,16 @@
     }
 
     .status__content {
-      color: #282c37;
+      color: $color1;
 
       a {
-        color: #2b90d9;
+        color: $color4;
       }
     }
 
     .detailed-status__meta {
       margin-top: 15px;
-      color: #9baec8;
+      color: $color3;
       font-size: 14px;
       line-height: 18px;
 
@@ -248,12 +248,13 @@
       transform: translate(-50%, -50%);
       padding: 5px;
       border-radius: 100px;
-      color: rgba(255, 255, 255, 0.8);
+      color: rgba($color5, 0.8);
+      z-index: 1;
     }
   }
 
   .media-spoiler {
-    background: #9baec8;
+    background: $color3;
     width: 100%;
     height: 100%;
     cursor: pointer;
@@ -263,9 +264,10 @@
     flex-direction: column;
     text-align: center;
     transition: all 100ms linear;
+    z-index: 2;
 
     &:hover {
-      background: darken(#9baec8, 5%);
+      background: darken($color3, 5%);
     }
 
     span {
@@ -287,7 +289,7 @@
     padding-left: (48px + 14px*2);
     padding-bottom: 0;
     margin-bottom: -4px;
-    color: #9baec8;
+    color: $color3;
     font-size: 14px;
     position: relative;
 
@@ -297,7 +299,7 @@
     }
 
     .status__display-name.muted strong {
-      color: #9baec8;
+      color: $color3;
     }
   }
 }
diff --git a/app/assets/stylesheets/tables.scss b/app/assets/stylesheets/tables.scss
index a37870786..ad8050580 100644
--- a/app/assets/stylesheets/tables.scss
+++ b/app/assets/stylesheets/tables.scss
@@ -9,13 +9,13 @@
     padding: 8px;
     line-height: 18px;
     vertical-align: top;
-    border-top: 1px solid #282c37;
+    border-top: 1px solid $color1;
     text-align: left;
   }
 
   & > thead > tr > th {
     vertical-align: bottom;
-    border-bottom: 2px solid #282c37;
+    border-bottom: 2px solid $color1;
     border-top: 0;
     font-weight: 500;
   }
@@ -25,17 +25,21 @@
   }
 
   & > tbody > tr:nth-child(odd) > td, & > tbody > tr:nth-child(odd) > th {
-    background: lighten(#1a1c23, 2%);
+    background: $color1;
   }
 
   a {
-    color: #2b90d9;
+    color: $color4;
     text-decoration: underline;
 
     &:hover {
       text-decoration: none;
     }
   }
+
+  strong {
+    font-weight: 500;
+  }
 }
 
 samp {
@@ -47,11 +51,11 @@ a.table-action-link {
   display: inline-block;
   margin-right: 5px;
   padding: 0 10px;
-  color: rgba(255, 255, 255, 0.7);
+  color: rgba($color5, 0.7);
   font-weight: 500;
 
   &:hover {
-    color: #fff;
+    color: $color5;
   }
 
   i.fa {
diff --git a/app/assets/stylesheets/variables.scss b/app/assets/stylesheets/variables.scss
new file mode 100644
index 000000000..de4157af8
--- /dev/null
+++ b/app/assets/stylesheets/variables.scss
@@ -0,0 +1,8 @@
+$color1: #282c37; // darkest
+$color2: #d9e1e8; // lightest
+$color3: #9baec8; // lighter
+$color4: #2b90d9; // vibrant
+$color5: #fff; // white
+$color6: #df405a; // error red
+$color7: #79bd9a; // succ green
+$color8: #000; // black
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 7df58444f..491036db2 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -4,11 +4,21 @@ class AboutController < ApplicationController
   before_action :set_body_classes
 
   def index
+    @description = Setting.site_description
   end
 
-  def terms
+  def more
+    @description          = Setting.site_description
+    @extended_description = Setting.site_extended_description
+    @contact_account      = Account.find_local(Setting.site_contact_username)
+    @contact_email        = Setting.site_contact_email
+    @user_count           = Rails.cache.fetch('user_count')            { User.count }
+    @status_count         = Rails.cache.fetch('local_status_count')    { Status.local.count }
+    @domain_count         = Rails.cache.fetch('distinct_domain_count') { Account.distinct.count(:domain) }
   end
 
+  def terms; end
+
   private
 
   def set_body_classes
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
new file mode 100644
index 000000000..af0be8823
--- /dev/null
+++ b/app/controllers/admin/settings_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Admin::SettingsController < ApplicationController
+  before_action :require_admin!
+
+  layout 'admin'
+
+  def index
+    @settings = Setting.all_as_records
+  end
+
+  def update
+    @setting = Setting.where(var: params[:id]).first_or_initialize(var: params[:id])
+
+    if @setting.value != params[:setting][:value]
+      @setting.value = params[:setting][:value]
+      @setting.save
+    end
+
+    respond_to do |format|
+      format.html { redirect_to admin_settings_path }
+      format.json { respond_with_bip(@setting) }
+    end
+  end
+end
diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb
index 2360061ff..379e910e6 100644
--- a/app/controllers/api/oembed_controller.rb
+++ b/app/controllers/api/oembed_controller.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class Api::OembedController < ApiController
+class Api::OEmbedController < ApiController
   respond_to :json
 
   def show
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 05ff806c5..d97010c0e 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -16,13 +16,13 @@ class Api::V1::AccountsController < ApiController
   end
 
   def following
-    results   = Follow.where(account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = Follow.where(account: @account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     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
+    next_path = following_api_v1_account_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = following_api_v1_account_url(since_id: results.first.id) unless results.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -31,13 +31,13 @@ class Api::V1::AccountsController < ApiController
   end
 
   def followers
-    results   = Follow.where(target_account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = Follow.where(target_account: @account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     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
+    next_path = followers_api_v1_account_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = followers_api_v1_account_url(since_id: results.first.id) unless results.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -46,13 +46,13 @@ class Api::V1::AccountsController < ApiController
   end
 
   def statuses
-    @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @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
+    next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -66,7 +66,12 @@ class Api::V1::AccountsController < ApiController
 
   def block
     BlockService.new.call(current_user.account, @account)
-    set_relationship
+
+    @following   = { @account.id => false }
+    @followed_by = { @account.id => false }
+    @blocking    = { @account.id => true }
+    @requested   = { @account.id => false }
+
     render action: :relationship
   end
 
@@ -93,10 +98,9 @@ class Api::V1::AccountsController < ApiController
   end
 
   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')
+    @accounts = SearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true')
 
-    set_account_counters_maps(@accounts)
+    set_account_counters_maps(@accounts) unless @accounts.nil?
 
     render action: :index
   end
diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb
index 1b33770f4..ca9dd0b7e 100644
--- a/app/controllers/api/v1/apps_controller.rb
+++ b/app/controllers/api/v1/apps_controller.rb
@@ -4,6 +4,6 @@ class Api::V1::AppsController < ApiController
   respond_to :json
 
   def create
-    @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes))
+    @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website])
   end
 end
diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb
index 8629242ab..b9816e052 100644
--- a/app/controllers/api/v1/blocks_controller.rb
+++ b/app/controllers/api/v1/blocks_controller.rb
@@ -7,13 +7,13 @@ class Api::V1::BlocksController < ApiController
   respond_to :json
 
   def index
-    results   = Block.where(account: current_account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = Block.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     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 = api_v1_blocks_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
+    next_path = api_v1_blocks_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = api_v1_blocks_url(since_id: results.first.id) unless results.empty?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index a71592acd..ef0a4854a 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -7,13 +7,13 @@ class Api::V1::FavouritesController < ApiController
   respond_to :json
 
   def index
-    results   = Favourite.where(account: current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    results   = Favourite.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @statuses = cache_collection(Status.where(id: results.map(&:status_id)), Status)
 
     set_maps(@statuses)
     set_counters_maps(@statuses)
 
-    next_path = api_v1_favourites_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
+    next_path = api_v1_favourites_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_favourites_url(since_id: results.first.id) unless results.empty?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index c8f162cb0..877356a75 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -6,8 +6,10 @@ class Api::V1::NotificationsController < ApiController
 
   respond_to :json
 
+  DEFAULT_NOTIFICATIONS_LIMIT = 15
+
   def index
-    @notifications = Notification.where(account: current_account).browserable.paginate_by_max_id(20, params[:max_id], params[:since_id])
+    @notifications = Notification.where(account: current_account).browserable.paginate_by_max_id(limit_param(DEFAULT_NOTIFICATIONS_LIMIT), params[:max_id], params[:since_id])
     @notifications = cache_collection(@notifications, Notification)
     statuses       = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status)
 
@@ -15,9 +17,18 @@ class Api::V1::NotificationsController < ApiController
     set_counters_maps(statuses)
     set_account_counters_maps(@notifications.map(&:from_account))
 
-    next_path = api_v1_notifications_url(max_id: @notifications.last.id)    if @notifications.size == 20
+    next_path = api_v1_notifications_url(max_id: @notifications.last.id)    if @notifications.size == limit_param(DEFAULT_NOTIFICATIONS_LIMIT)
     prev_path = api_v1_notifications_url(since_id: @notifications.first.id) unless @notifications.empty?
 
     set_pagination_headers(next_path, prev_path)
   end
+
+  def show
+    @notification = Notification.where(account: current_account).find(params[:id])
+  end
+
+  def clear
+    Notification.where(account: current_account).delete_all
+    render_empty
+  end
 end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index f7b4ed610..4b095a570 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -3,8 +3,8 @@
 class Api::V1::StatusesController < ApiController
   before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
   before_action -> { doorkeeper_authorize! :write }, only:  [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
-  before_action :require_user!, except: [:show, :context, :reblogged_by, :favourited_by]
-  before_action :set_status, only:      [:show, :context, :reblogged_by, :favourited_by]
+  before_action :require_user!, except: [:show, :context, :card, :reblogged_by, :favourited_by]
+  before_action :set_status, only:      [:show, :context, :card, :reblogged_by, :favourited_by]
 
   respond_to :json
 
@@ -14,21 +14,26 @@ class Api::V1::StatusesController < ApiController
   end
 
   def context
-    @context = OpenStruct.new(ancestors: @status.ancestors(current_account), descendants: @status.descendants(current_account))
+    @context = OpenStruct.new(ancestors: @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account), descendants: @status.descendants(current_account))
     statuses = [@status] + @context[:ancestors] + @context[:descendants]
 
     set_maps(statuses)
     set_counters_maps(statuses)
   end
 
+  def card
+    @card = PreviewCard.find_by(status: @status)
+    render_empty if @card.nil?
+  end
+
   def reblogged_by
-    results   = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = @status.reblogs.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     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
+    next_path = reblogged_by_api_v1_status_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = reblogged_by_api_v1_status_url(since_id: results.first.id) unless results.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -37,13 +42,13 @@ class Api::V1::StatusesController < ApiController
   end
 
   def favourited_by
-    results   = @status.favourites.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = @status.favourites.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     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
+    next_path = favourited_by_api_v1_status_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = favourited_by_api_v1_status_url(since_id: results.first.id) unless results.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -52,7 +57,12 @@ 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]), media_ids: params[:media_ids], sensitive: params[:sensitive], visibility: params[:visibility])
+    @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],
+                                                                                                                                                             spoiler_text: params[:spoiler_text],
+                                                                                                                                                             visibility: params[:visibility],
+                                                                                                                                                             application: doorkeeper_token.application)
+
     render action: :show
   end
 
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index 9727797e5..5042550db 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -7,14 +7,14 @@ class Api::V1::TimelinesController < ApiController
   respond_to :json
 
   def home
-    @statuses = Feed.new(:home, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    @statuses = Feed.new(:home, current_account).get(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @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
+    next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -23,14 +23,14 @@ class Api::V1::TimelinesController < ApiController
   end
 
   def mentions
-    @statuses = Feed.new(:mentions, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    @statuses = Feed.new(:mentions, current_account).get(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @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
+    next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_mentions_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -39,14 +39,14 @@ class Api::V1::TimelinesController < ApiController
   end
 
   def public
-    @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @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
+    next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -56,14 +56,14 @@ 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])
+    @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @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
+    next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/controllers/api/web/settings_controller.rb b/app/controllers/api/web/settings_controller.rb
new file mode 100644
index 000000000..c00e016a4
--- /dev/null
+++ b/app/controllers/api/web/settings_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Api::Web::SettingsController < ApiController
+  respond_to :json
+
+  before_action :require_user!
+
+  def update
+    setting      = ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user)
+    setting.data = params[:data]
+    setting.save!
+
+    render_empty
+  end
+end
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index 8f1c8ac8a..5d2bd9a22 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -62,6 +62,11 @@ class ApiController < ApplicationController
     response.headers['Link'] = LinkHeader.new(links)
   end
 
+  def limit_param(default_limit)
+    return default_limit unless params[:limit]
+    [params[:limit].to_i.abs, default_limit * 2].min
+  end
+
   def current_resource_owner
     @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
   end
@@ -89,19 +94,19 @@ class ApiController < ApplicationController
       return
     end
 
-    status_ids      = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact.uniq
+    status_ids      = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
     @reblogs_map    = Status.reblogs_map(status_ids, current_account)
     @favourites_map = Status.favourites_map(status_ids, current_account)
   end
 
   def set_counters_maps(statuses) # rubocop:disable Style/AccessorMethodName
-    status_ids             = statuses.map { |s| s.reblog? ? s.reblog_of_id : s.id }.uniq
+    status_ids             = statuses.compact.map { |s| s.reblog? ? s.reblog_of_id : s.id }.uniq
     @favourites_counts_map = Favourite.select('status_id, COUNT(id) AS favourites_count').group('status_id').where(status_id: status_ids).map { |f| [f.status_id, f.favourites_count] }.to_h
     @reblogs_counts_map    = Status.select('statuses.id, COUNT(reblogs.id) AS reblogs_count').joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.id = reblogs.reblog_of_id').where(id: status_ids).group('statuses.id').map { |r| [r.id, r.reblogs_count] }.to_h
   end
 
   def set_account_counters_maps(accounts) # rubocop:disable Style/AccessorMethodName
-    account_ids = accounts.map(&:id)
+    account_ids = accounts.compact.map(&:id).uniq
     @followers_counts_map = Follow.unscoped.select('target_account_id, COUNT(account_id) AS followers_count').group('target_account_id').where(target_account_id: account_ids).map { |f| [f.target_account_id, f.followers_count] }.to_h
     @following_counts_map = Follow.unscoped.select('account_id, COUNT(target_account_id) AS following_count').group('account_id').where(account_id: account_ids).map { |f| [f.account_id, f.following_count] }.to_h
     @statuses_counts_map  = Status.unscoped.select('account_id, COUNT(id) AS statuses_count').group('account_id').where(account_id: account_ids).map { |s| [s.account_id, s.statuses_count] }.to_h
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 0a6b50a29..e4b6d0faf 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
 
   rescue_from ActionController::RoutingError, with: :not_found
   rescue_from ActiveRecord::RecordNotFound, with: :not_found
+  rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
   before_action :set_locale
@@ -50,12 +51,21 @@ class ApplicationController < ActionController::Base
   def not_found
     respond_to do |format|
       format.any  { head 404 }
+      format.html { render 'errors/404', layout: 'error' }
     end
   end
 
   def gone
     respond_to do |format|
       format.any  { head 410 }
+      format.html { render 'errors/410', layout: 'error' }
+    end
+  end
+
+  def unprocessable_entity
+    respond_to do |format|
+      format.any  { head 422 }
+      format.html { render 'errors/422', layout: 'error' }
     end
   end
 
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 60eb9905a..6ce4984bb 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -23,6 +23,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     new_user_session_path
   end
 
+  def after_inactive_sign_up_path_for(_resource)
+    new_user_session_path
+  end
+
   def check_single_user_mode
     redirect_to root_path if Rails.configuration.x.single_user_mode
   end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index a25fe77da..814b1f758 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -6,6 +6,7 @@ class HomeController < ApplicationController
   def index
     @body_classes = 'app-body'
     @token        = find_or_create_access_token.token
+    @web_settings = Web::Setting.find_by(user: current_user)&.data || {}
   end
 
   private
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index 6f1f7ec48..488c4f944 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -10,6 +10,7 @@ class MediaController < ApplicationController
   private
 
   def set_media_attachment
-    @media_attachment = MediaAttachment.where.not(status_id: nil).find(params[:id])
+    @media_attachment = MediaAttachment.where.not(status_id: nil).find_by!(shortcode: params[:id])
+    raise ActiveRecord::RecordNotFound unless @media_attachment.status.permitted?(current_account)
   end
 end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 3b6d109a6..f273b5f21 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -8,14 +8,18 @@ class Settings::PreferencesController < ApplicationController
   def show; end
 
   def update
-    current_user.settings(:notification_emails).follow         = user_params[:notification_emails][:follow]         == '1'
-    current_user.settings(:notification_emails).follow_request = user_params[:notification_emails][:follow_request] == '1'
-    current_user.settings(:notification_emails).reblog         = user_params[:notification_emails][:reblog]         == '1'
-    current_user.settings(:notification_emails).favourite      = user_params[:notification_emails][:favourite]      == '1'
-    current_user.settings(:notification_emails).mention        = user_params[:notification_emails][:mention]        == '1'
-
-    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'
+    current_user.settings['notification_emails'] = {
+      follow:         user_params[:notification_emails][:follow]         == '1',
+      follow_request: user_params[:notification_emails][:follow_request] == '1',
+      reblog:         user_params[:notification_emails][:reblog]         == '1',
+      favourite:      user_params[:notification_emails][:favourite]      == '1',
+      mention:        user_params[:notification_emails][:mention]        == '1',
+    }
+
+    current_user.settings['interactions'] = {
+      must_be_follower:  user_params[:interactions][:must_be_follower]  == '1',
+      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')
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index 3f60bb0c4..5701b2efa 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -46,7 +46,7 @@ class StreamEntriesController < ApplicationController
     @stream_entry = @account.stream_entries.find(params[:id])
     @type         = @stream_entry.activity_type.downcase
 
-    raise ActiveRecord::RecordNotFound if @stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account)))
+    raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? || (@stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account))))
   end
 
   def check_account_suspension
diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb
index 036a72166..c08d80ea0 100644
--- a/app/helpers/atom_builder_helper.rb
+++ b/app/helpers/atom_builder_helper.rb
@@ -41,7 +41,8 @@ module AtomBuilderHelper
     xml['activity'].send('verb', TagManager::VERBS[verb])
   end
 
-  def content(xml, content)
+  def content(xml, content, warning = nil)
+    xml.summary(warning) unless warning.blank?
     xml.content({ type: 'html' }, content) unless content.blank?
   end
 
@@ -153,12 +154,20 @@ module AtomBuilderHelper
     portable_contact xml, account
   end
 
+  def rich_content(xml, activity)
+    if activity.is_a?(Status)
+      content xml, conditionally_formatted(activity), activity.spoiler_text
+    else
+      content xml, conditionally_formatted(activity)
+    end
+  end
+
   def include_entry(xml, stream_entry)
     unique_id      xml, stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type
     published_at   xml, stream_entry.created_at
     updated_at     xml, stream_entry.updated_at
     title          xml, stream_entry.title
-    content        xml, conditionally_formatted(stream_entry.activity)
+    rich_content   xml, stream_entry.activity
     verb           xml, stream_entry.verb
     link_self      xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom')
     link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry)
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
index 6f87c7b72..d3c6b13a6 100644
--- a/app/helpers/home_helper.rb
+++ b/app/helpers/home_helper.rb
@@ -3,8 +3,6 @@
 module HomeHelper
   def default_props
     {
-      token: @token,
-      account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json),
       locale: I18n.locale,
     }
   end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index fa569e73a..aed8770c8 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -14,4 +14,8 @@ module SettingsHelper
   def human_locale(locale)
     HUMAN_LOCALES[locale]
   end
+
+  def hash_to_object(hash)
+    HashObject.new(hash)
+  end
 end
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index ae2f575b5..15601a079 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -15,10 +15,10 @@ module StreamEntriesHelper
 
   def entry_classes(status, is_predecessor, is_successor, include_threads)
     classes = ['entry']
-    classes << 'entry-reblog' if status.reblog?
-    classes << 'entry-predecessor' if is_predecessor
-    classes << 'entry-successor' if is_successor
-    classes << 'entry-center' if include_threads
+    classes << 'entry-reblog u-repost-of h-cite' if status.reblog?
+    classes << 'entry-predecessor u-in-reply-to h-cite' if is_predecessor
+    classes << 'entry-successor u-comment h-cite' if is_successor
+    classes << 'entry-center h-entry' if include_threads
     classes.join(' ')
   end
 
diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb
new file mode 100644
index 000000000..93c0f42f0
--- /dev/null
+++ b/app/lib/application_extension.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module ApplicationExtension
+  extend ActiveSupport::Concern
+
+  included do
+    validates :website, url: true, unless: 'website.blank?'
+  end
+end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 0056321fa..cdd26e69c 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -43,12 +43,22 @@ class FeedManager
     timeline_key = key(:home, into_account.id)
 
     from_account.statuses.limit(MAX_ITEMS).each do |status|
+      next if filter?(:home, status, into_account)
       redis.zadd(timeline_key, status.id, status.id)
     end
 
     trim(:home, into_account.id)
   end
 
+  def unmerge_from_timeline(from_account, into_account)
+    timeline_key = key(:home, into_account.id)
+
+    from_account.statuses.select('id').find_each do |status|
+      redis.zrem(timeline_key, status.id)
+      redis.zremrangebyscore(timeline_key, status.id, status.id)
+    end
+  end
+
   def inline_render(target_account, template, object)
     rabl_scope = Class.new do
       include RoutingHelper
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 04386d295..ff2a16f1b 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -14,7 +14,7 @@ class Formatter
 
     html = status.text
     html = encode(html)
-    html = simple_format(html, sanitize: false)
+    html = simple_format(html, {}, sanitize: false)
     html = html.gsub(/\n/, '')
     html = link_urls(html)
     html = link_mentions(html, status.mentions)
@@ -32,6 +32,7 @@ class Formatter
 
     html = encode(account.note)
     html = link_urls(html)
+    html = link_hashtags(html)
 
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
@@ -43,8 +44,8 @@ class Formatter
   end
 
   def link_urls(html)
-    auto_link(html, link: :urls, html: { rel: 'nofollow noopener', target: '_blank' }) do |text|
-      truncate(text.gsub(/\Ahttps?:\/\/(www\.)?/, ''), length: 30)
+    html.gsub(URI.regexp(%w(http https))) do |match|
+      link_html(match)
     end
   end
 
@@ -63,6 +64,14 @@ class Formatter
     end
   end
 
+  def link_html(url)
+    prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s
+    text   = url[prefix.length, 30]
+    suffix = url[prefix.length + 30..-1]
+
+    "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"ellipsis\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>"
+  end
+
   def hashtag_html(match)
     prefix, affix = match.split('#')
     "#{prefix}<a href=\"#{tag_url(affix.downcase)}\" class=\"mention hashtag\">#<span>#{affix}</span></a>"
diff --git a/app/lib/hash_object.rb b/app/lib/hash_object.rb
new file mode 100644
index 000000000..274c020ad
--- /dev/null
+++ b/app/lib/hash_object.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class HashObject
+  def initialize(hash)
+    hash.each do |k, v|
+      instance_variable_set("@#{k}", v)
+      self.class.send(:define_method, k, proc { instance_variable_get("@#{k}") })
+    end
+  end
+end
diff --git a/app/lib/settings/extend.rb b/app/lib/settings/extend.rb
new file mode 100644
index 000000000..407c3480f
--- /dev/null
+++ b/app/lib/settings/extend.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Settings
+  module Extend
+    extend ActiveSupport::Concern
+
+    def settings
+      ScopedSettings.for_thing(self)
+    end
+  end
+end
diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb
new file mode 100644
index 000000000..82b70d128
--- /dev/null
+++ b/app/lib/settings/scoped_settings.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Settings
+  class ScopedSettings < ::Setting
+    def self.for_thing(object)
+      @object = object
+      self
+    end
+
+    def self.thing_scoped
+      unscoped.where(thing_type: @object.class.base_class.to_s, thing_id: @object.id)
+    end
+  end
+end
diff --git a/app/lib/status_length_validator.rb b/app/lib/status_length_validator.rb
new file mode 100644
index 000000000..55135a598
--- /dev/null
+++ b/app/lib/status_length_validator.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class StatusLengthValidator < ActiveModel::Validator
+  MAX_CHARS = 500
+
+  def validate(status)
+    return unless status.local? && !status.reblog?
+    status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if [status.text, status.spoiler_text].join.length > MAX_CHARS
+  end
+end
diff --git a/app/lib/url_validator.rb b/app/lib/url_validator.rb
new file mode 100644
index 000000000..4a5c4ef3f
--- /dev/null
+++ b/app/lib/url_validator.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class UrlValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    record.errors.add(attribute, I18n.t('applications.invalid_url')) unless compliant?(value)
+  end
+
+  private
+
+  def compliant?(url)
+    parsed_url = Addressable::URI.parse(url)
+    !parsed_url.nil? && %w(http https).include?(parsed_url.scheme) && parsed_url.host
+  end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 5c1f6e7c1..c2a41c4c6 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -62,8 +62,8 @@ class Account < ApplicationRecord
   scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
   scope :silenced, -> { where(silenced: true) }
   scope :suspended, -> { where(suspended: true) }
-  scope :recent, -> { reorder('id desc') }
-  scope :alphabetic, -> { order('domain ASC, username ASC') }
+  scope :recent, -> { reorder(id: :desc) }
+  scope :alphabetic, -> { order(domain: :asc, username: :asc) }
 
   def follow!(other_account)
     active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
@@ -104,7 +104,7 @@ class Account < ApplicationRecord
   end
 
   def subscribed?
-    !subscription_expires_at.nil?
+    !subscription_expires_at.blank?
   end
 
   def favourited?(status)
@@ -125,13 +125,10 @@ class Account < ApplicationRecord
 
   def save_with_optional_avatar!
     save!
-  rescue ActiveRecord::RecordInvalid => invalid
-    if invalid.record.errors[:avatar_file_size] || invalid[:avatar_content_type]
-      self.avatar = nil
-      retry
-    end
-
-    raise invalid
+  rescue ActiveRecord::RecordInvalid
+    self.avatar              = nil
+    self[:avatar_remote_url] = ''
+    save!
   end
 
   def avatar_remote_url=(url)
@@ -159,6 +156,7 @@ class Account < ApplicationRecord
     end
 
     def find_remote!(username, domain)
+      return if username.blank?
       where(arel_table[:username].matches(username.gsub(/[%_]/, '\\\\\0'))).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain.gsub(/[%_]/, '\\\\\0'))).take!
     end
 
@@ -175,19 +173,25 @@ class Account < ApplicationRecord
     end
 
     def following_map(target_account_ids, account_id)
-      Follow.where(target_account_id: target_account_ids).where(account_id: account_id).map { |f| [f.target_account_id, true] }.to_h
+      follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
 
     def followed_by_map(target_account_ids, account_id)
-      Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h
+      follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
     end
 
     def blocking_map(target_account_ids, account_id)
-      Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h
+      follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
 
     def requested_map(target_account_ids, account_id)
-      FollowRequest.where(target_account_id: target_account_ids).where(account_id: account_id).map { |r| [r.target_account_id, true] }.to_h
+      follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+    end
+
+    private
+
+    def follow_mapping(query, field)
+      query.pluck(field).inject({}) { |mapping, id| mapping[id] = true; mapping }
     end
   end
 
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 9075b90a0..b4606da60 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class DomainBlock < ApplicationRecord
+  enum severity: [:silence, :suspend]
+
   validates :domain, presence: true, uniqueness: true
 
   def self.blocked?(domain)
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 8eef3abf4..936ad0691 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -13,7 +13,7 @@ class FollowRequest < ApplicationRecord
 
   def authorize!
     account.follow!(target_account)
-    FeedManager.instance.merge_into_timeline(target_account, account)
+    MergeWorker.perform_async(target_account.id, account.id)
     destroy!
   end
 
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 2a5d23739..ecbed03e3 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -16,6 +16,7 @@ class MediaAttachment < ApplicationRecord
 
   validates :account, presence: true
 
+  scope :local, -> { where(remote_url: '') }
   default_scope { order('id asc') }
 
   def local?
@@ -38,6 +39,12 @@ class MediaAttachment < ApplicationRecord
     image? ? 'image' : 'video'
   end
 
+  def to_param
+    shortcode
+  end
+
+  before_create :set_shortcode
+
   class << self
     private
 
@@ -62,4 +69,15 @@ class MediaAttachment < ApplicationRecord
       end
     end
   end
+
+  private
+
+  def set_shortcode
+    return unless local?
+
+    loop do
+      self.shortcode = SecureRandom.urlsafe_base64(14)
+      break if MediaAttachment.find_by(shortcode: shortcode).nil?
+    end
+  end
 end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index c0b5c45a8..b7e8c9e71 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -39,9 +39,9 @@ class Notification < ApplicationRecord
   def target_status
     case type
     when :reblog
-      activity.reblog
+      activity&.reblog
     when :favourite, :mention
-      activity.status
+      activity&.status
     end
   end
 
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
new file mode 100644
index 000000000..e59b05eb8
--- /dev/null
+++ b/app/models/preview_card.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class PreviewCard < ApplicationRecord
+  IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
+
+  belongs_to :status
+
+  has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
+
+  validates :url, presence: true
+  validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
+  validates_attachment_size :image, less_than: 1.megabytes
+
+  def save_with_optional_image!
+    save!
+  rescue ActiveRecord::RecordInvalid
+    self.image = nil
+    save!
+  end
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
new file mode 100644
index 000000000..3796253d4
--- /dev/null
+++ b/app/models/setting.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+class Setting < RailsSettings::Base
+  source Rails.root.join('config/settings.yml')
+  namespace Rails.env
+
+  def to_param
+    var
+  end
+
+  class << self
+    def [](key)
+      return super(key) unless rails_initialized?
+
+      val = Rails.cache.fetch(cache_key(key, @object)) do
+        db_val = object(key)
+
+        if db_val
+          default_value = default_settings[key]
+
+          return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash)
+          db_val.value
+        else
+          default_settings[key]
+        end
+      end
+
+      val
+    end
+
+    def all_as_records
+      vars    = thing_scoped
+      records = vars.map { |r| [r.var, r] }.to_h
+
+      default_settings.each do |key, default_value|
+        next if records.key?(key) || default_value.is_a?(Hash)
+        records[key] = Setting.new(var: key, value: default_value)
+      end
+
+      records
+    end
+
+    private
+
+    def default_settings
+      return {} unless RailsSettings::Default.enabled?
+      RailsSettings::Default.instance
+    end
+  end
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index bc595c93b..651d0dbc9 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -1,12 +1,15 @@
 # frozen_string_literal: true
 
 class Status < ApplicationRecord
+  include ActiveModel::Validations
   include Paginable
   include Streamable
   include Cacheable
 
   enum visibility: [:public, :unlisted, :private], _suffix: :visibility
 
+  belongs_to :application, class_name: 'Doorkeeper::Application'
+
   belongs_to :account, inverse_of: :statuses
   belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account'
 
@@ -21,11 +24,12 @@ class Status < ApplicationRecord
   has_and_belongs_to_many :tags
 
   has_one :notification, as: :activity, dependent: :destroy
+  has_one :preview_card, dependent: :destroy
 
   validates :account, presence: true
   validates :uri, uniqueness: true, unless: 'local?'
-  validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? }
-  validates :text, presence: true, if: proc { |s| !s.local? && !s.reblog? }
+  validates :text, presence: true, unless: 'reblog?'
+  validates_with StatusLengthValidator
   validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?'
 
   default_scope { order('id desc') }
@@ -33,7 +37,7 @@ class Status < ApplicationRecord
   scope :remote, -> { where.not(uri: nil) }
   scope :local, -> { where(uri: nil) }
 
-  cache_associated :account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
+  cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
 
   def local?
     uri.nil?
@@ -171,6 +175,7 @@ class Status < ApplicationRecord
 
   before_validation do
     text.strip!
+    spoiler_text&.strip!
 
     self.reblog                 = reblog.reblog if reblog? && reblog.reblog?
     self.in_reply_to_account_id = (thread.account_id == account_id && thread.reply? ? thread.in_reply_to_account_id : thread.account_id) if reply?
diff --git a/app/models/user.rb b/app/models/user.rb
index d5a52da06..71d3ee0b8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class User < ApplicationRecord
+  include Settings::Extend
+
   devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable
 
   belongs_to :account, inverse_of: :user
@@ -14,11 +16,6 @@ class User < ApplicationRecord
   scope :recent,   -> { order('id desc') }
   scope :admins,   -> { where(admin: true) }
 
-  has_settings do |s|
-    s.key :notification_emails, defaults: { follow: false, reblog: false, favourite: false, mention: false, follow_request: true }
-    s.key :interactions, defaults: { must_be_follower: false, must_be_following: false }
-  end
-
   def send_devise_notification(notification, *args)
     devise_mailer.send(notification, self, *args).deliver_later
   end
diff --git a/app/models/web.rb b/app/models/web.rb
new file mode 100644
index 000000000..58654fd77
--- /dev/null
+++ b/app/models/web.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Web
+  def self.table_name_prefix
+    'web_'
+  end
+end
diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb
new file mode 100644
index 000000000..3d601189b
--- /dev/null
+++ b/app/models/web/setting.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Web::Setting < ApplicationRecord
+  belongs_to :user
+
+  validates :user, uniqueness: true
+end
diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb
new file mode 100644
index 000000000..8c6197f2c
--- /dev/null
+++ b/app/services/after_block_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class AfterBlockService < BaseService
+  def call(account, target_account)
+    clear_timelines(account, target_account)
+    clear_notifications(account, target_account)
+  end
+
+  private
+
+  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(mentions_key, status.id)
+      redis.zrem(home_key, status.id)
+    end
+  end
+
+  def clear_notifications(account, target_account)
+    Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all
+    Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all
+    Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all
+    Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all
+  end
+
+  def redis
+    Redis.current
+  end
+end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index a8fafe412..9518b1fcf 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -1,15 +1,16 @@
 # frozen_string_literal: true
 
 class BlockDomainService < BaseService
-  def call(domain)
-    DomainBlock.find_or_create_by!(domain: domain)
+  def call(domain, severity)
+    DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity)
 
-    Account.where(domain: domain).find_each do |account|
-      if account.subscribed?
-        account.subscription(api_subscription_url(account.id)).unsubscribe
+    if severity == :silence
+      Account.where(domain: domain).update_all(silenced: true)
+    else
+      Account.where(domain: domain).find_each do |account|
+        account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
+        SuspendAccountService.new.call(account)
       end
-
-      account.destroy!
     end
   end
 end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index b08cf8ca8..e04b6cc39 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -9,32 +9,7 @@ class BlockService < BaseService
 
     block = account.block!(target_account)
 
-    clear_timelines(account, target_account)
-    clear_notifications(account, target_account)
-
+    BlockWorker.perform_async(account.id, target_account.id)
     NotificationWorker.perform_async(block.stream_entry.id, target_account.id) unless target_account.local?
   end
-
-  private
-
-  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(mentions_key, status.id)
-      redis.zrem(home_key, status.id)
-    end
-  end
-
-  def clear_notifications(account, target_account)
-    Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all
-    Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all
-    Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all
-    Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all
-  end
-
-  def redis
-    Redis.current
-  end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
new file mode 100644
index 000000000..005e5acea
--- /dev/null
+++ b/app/services/fetch_link_card_service.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class FetchLinkCardService < BaseService
+  def call(status)
+    # Get first URL
+    url = URI.extract(status.text).reject { |uri| (uri =~ /\Ahttps?:\/\//).nil? }.first
+
+    return if url.nil?
+
+    response = http_client.get(url)
+
+    return if response.code != 200 || response.mime_type != 'text/html'
+
+    page = Nokogiri::HTML(response.to_s)
+    card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
+
+    card.title       = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
+    card.description = meta_property(page, 'og:description') || meta_property(page, 'description')
+    card.image       = URI.parse(meta_property(page, 'og:image')) if meta_property(page, 'og:image')
+
+    return if card.title.blank?
+
+    card.save_with_optional_image!
+  end
+
+  private
+
+  def http_client
+    HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow
+  end
+
+  def meta_property(html, property)
+    html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
+  end
+end
diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb
index f640222b0..b39eafc70 100644
--- a/app/services/follow_remote_account_service.rb
+++ b/app/services/follow_remote_account_service.rb
@@ -14,7 +14,6 @@ class FollowRemoteAccountService < BaseService
     username, domain = uri.split('@')
 
     return Account.find_local(username) if TagManager.instance.local_domain?(domain)
-    return nil if DomainBlock.blocked?(domain)
 
     account = Account.find_remote(username, domain)
     return account unless account.nil?
@@ -36,11 +35,15 @@ class FollowRemoteAccountService < BaseService
 
     Rails.logger.debug "Creating new remote account for #{uri}"
 
+    domain_block = DomainBlock.find_by(domain: domain)
+
     account.remote_url  = data.link('http://schemas.google.com/g/2010#updates-from').href
     account.salmon_url  = data.link('salmon').href
     account.url         = data.link('http://webfinger.net/rel/profile-page').href
     account.public_key  = magic_key_to_pem(data.link('magic-public-key').href)
     account.private_key = nil
+    account.suspended   = true if domain_block && domain_block.suspend?
+    account.silenced    = true if domain_block && domain_block.silence?
 
     xml  = get_feed(account.remote_url)
     hubs = get_hubs(xml)
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 555f01b6d..87c16a621 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -38,7 +38,7 @@ class FollowService < BaseService
       NotificationWorker.perform_async(follow.stream_entry.id, target_account.id)
     end
 
-    FeedManager.instance.merge_into_timeline(target_account, source_account)
+    MergeWorker.perform_async(target_account.id, source_account.id)
     Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id)
 
     follow
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 2fb1d3919..1ec36637c 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -6,7 +6,7 @@ class NotifyService < BaseService
     @activity     = activity
     @notification = Notification.new(account: @recipient, activity: @activity)
 
-    return if blocked?
+    return if blocked? || recipient.user.nil?
 
     create_notification
     send_email if email_enabled?
@@ -37,13 +37,13 @@ class NotifyService < BaseService
   end
 
   def blocked?
-    blocked   = @recipient.suspended?                                                                                             # Skip if the recipient account is suspended anyway
-    blocked ||= @recipient.id == @notification.from_account.id                                                                    # Skip for interactions with self
-    blocked ||= @recipient.blocking?(@notification.from_account)                                                                  # Skip for blocked accounts
-    blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account))                      # Hellban
-    blocked ||= (@recipient.user.settings(:interactions).must_be_follower  && !@notification.from_account.following?(@recipient)) # Options
-    blocked ||= (@recipient.user.settings(:interactions).must_be_following && !@recipient.following?(@notification.from_account)) # Options
-    blocked ||= send("blocked_#{@notification.type}?")                                                                            # Type-dependent filters
+    blocked   = @recipient.suspended?                                                                                              # Skip if the recipient account is suspended anyway
+    blocked ||= @recipient.id == @notification.from_account.id                                                                     # Skip for interactions with self
+    blocked ||= @recipient.blocking?(@notification.from_account)                                                                   # Skip for blocked accounts
+    blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account))                       # Hellban
+    blocked ||= (@recipient.user.settings.interactions['must_be_follower']  && !@notification.from_account.following?(@recipient)) # Options
+    blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account)) # Options
+    blocked ||= send("blocked_#{@notification.type}?")                                                                             # Type-dependent filters
     blocked
   end
 
@@ -58,6 +58,6 @@ class NotifyService < BaseService
   end
 
   def email_enabled?
-    @recipient.user.settings(:notification_emails).send(@notification.type)
+    @recipient.user.settings.notification_emails[@notification.type]
   end
 end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 55405c0db..979941c84 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -7,14 +7,24 @@ class PostStatusService < BaseService
   # @param [Status] in_reply_to Optional status to reply to
   # @param [Hash] options
   # @option [Boolean] :sensitive
+  # @option [String] :visibility
+  # @option [String] :spoiler_text
   # @option [Enumerable] :media_ids Optional array of media IDs to attach
+  # @option [Doorkeeper::Application] :application
   # @return [Status]
   def call(account, text, in_reply_to = nil, options = {})
-    status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive], visibility: options[:visibility])
+    status = account.statuses.create!(text: text,
+                                      thread: in_reply_to,
+                                      sensitive: options[:sensitive],
+                                      spoiler_text: options[:spoiler_text] || '',
+                                      visibility: options[:visibility],
+                                      application: options[:application])
+
     attach_media(status, options[:media_ids])
     process_mentions_service.call(status)
     process_hashtags_service.call(status)
 
+    LinkCrawlWorker.perform_async(status.id)
     DistributionWorker.perform_async(status.id)
     Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
 
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 3860a3504..626534176 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -44,6 +44,8 @@ class ProcessFeedService < BaseService
       Rails.logger.debug "Creating remote status #{id}"
       status = status_from_xml(@xml)
 
+      return if status.nil?
+
       if verb == :share
         original_status = status_from_xml(@xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS))
         status.reblog   = original_status
@@ -59,6 +61,7 @@ class ProcessFeedService < BaseService
       status.save!
 
       NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local?
+      # LinkCrawlWorker.perform_async(status.reblog? ? status.reblog_of_id : status.id)
       Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
       DistributionWorker.perform_async(status.id)
       status
@@ -100,6 +103,7 @@ class ProcessFeedService < BaseService
         url: url(entry),
         account: account,
         text: content(entry),
+        spoiler_text: content_warning(entry),
         created_at: published(entry)
       )
 
@@ -177,6 +181,8 @@ class ProcessFeedService < BaseService
     end
 
     def media_from_xml(parent, xml)
+      return if DomainBlock.find_by(domain: parent.account.domain)&.reject_media?
+
       xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: TagManager::XMLNS).each do |link|
         next unless link['href']
 
@@ -218,6 +224,10 @@ class ProcessFeedService < BaseService
       xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content
     end
 
+    def content_warning(xml = @xml)
+      xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || ''
+    end
+
     def published(xml = @xml)
       xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content
     end
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index ddcc64aa5..617a38159 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 { |str| str.mb_chars.downcase }.uniq.each do |tag|
+    tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |tag|
       status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
     end
 
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index 11ec0d2dd..5f91e3127 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -17,8 +17,6 @@ class ProcessInteractionService < BaseService
     domain   = Addressable::URI.parse(url).host
     account  = Account.find_by(username: username, domain: domain)
 
-    return if DomainBlock.blocked?(domain)
-
     if account.nil?
       account = follow_remote_account_service.call("#{username}@#{domain}")
     end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index ee42a5df2..72568e702 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -28,7 +28,7 @@ class ProcessMentionsService < BaseService
     status.mentions.each do |mention|
       mentioned_account = mention.account
 
-      next if status.private_visibility? && !mentioned_account.following?(status.account)
+      next if status.private_visibility? && (!mentioned_account.following?(status.account) || !mentioned_account.local?)
 
       if mentioned_account.local?
         NotifyService.new.call(mentioned_account, mention)
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 0cb51eecd..4ea0dbf6c 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -6,7 +6,9 @@ class ReblogService < BaseService
   # @param [Status] reblogged_status Status to be reblogged
   # @return [Status]
   def call(account, reblogged_status)
-    raise Mastodon::NotPermitted if reblogged_status.private_visibility?
+    reblogged_status = reblogged_status.reblog if reblogged_status.reblog?
+
+    raise Mastodon::NotPermitted if reblogged_status.private_visibility? || !reblogged_status.permitted?(account)
 
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
 
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 836b8fdc5..7aca24d12 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -53,7 +53,12 @@ class RemoveStatusService < BaseService
   end
 
   def unpush(type, receiver, status)
-    redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id)
+    if status.reblog?
+      redis.zadd(FeedManager.instance.key(type, receiver.id), status.reblog_of_id, status.reblog_of_id)
+    else
+      redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id)
+    end
+
     FeedManager.instance.broadcast(receiver.id, type: 'delete', id: status.id)
   end
 
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 04a086613..8528ef62a 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -18,7 +18,6 @@ class SuspendAccountService < BaseService
 
     @account.media_attachments.destroy_all
     @account.stream_entries.destroy_all
-    @account.mentions.destroy_all
     @account.notifications.destroy_all
     @account.favourites.destroy_all
     @account.active_relationships.destroy_all
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 7973a3611..f469793c1 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -7,21 +7,6 @@ class UnfollowService < BaseService
   def call(source_account, target_account)
     follow = source_account.unfollow!(target_account)
     NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) unless target_account.local?
-    unmerge_from_timeline(target_account, source_account)
-  end
-
-  private
-
-  def unmerge_from_timeline(from_account, into_account)
-    timeline_key = FeedManager.instance.key(:home, into_account.id)
-
-    from_account.statuses.select('id').find_each do |status|
-      redis.zrem(timeline_key, status.id)
-      redis.zremrangebyscore(timeline_key, status.id, status.id)
-    end
-  end
-
-  def redis
-    Redis.current
+    UnmergeWorker.perform_async(target_account.id, source_account.id)
   end
 end
diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb
index d961eda39..ad9c56540 100644
--- a/app/services/update_remote_profile_service.rb
+++ b/app/services/update_remote_profile_service.rb
@@ -8,9 +8,12 @@ class UpdateRemoteProfileService < BaseService
     hub_link   = xml.at_xpath('./xmlns:link[@rel="hub"]', xmlns: TagManager::XMLNS)
 
     unless author_xml.nil?
-      account.display_name      = author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).nil?
-      account.note              = author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).nil?
-      account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank?
+      account.display_name = author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).nil?
+      account.note         = author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).nil?
+
+      unless account.suspended? || DomainBlock.find_by(domain: account.domain)&.reject_media?
+        account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank?
+      end
     end
 
     old_hub_url     = account.hub_url
diff --git a/app/views/about/index.html.haml b/app/views/about/index.html.haml
index 6dd182205..88bfe3d61 100644
--- a/app/views/about/index.html.haml
+++ b/app/views/about/index.html.haml
@@ -1,3 +1,6 @@
+- content_for :header_tags do
+  = javascript_include_tag 'application_public'
+
 - content_for :page_title do
   = Rails.configuration.x.local_domain
 
@@ -5,10 +8,11 @@
   %meta{ property: 'og:site_name', content: 'Mastodon' }/
   %meta{ property: 'og:type', content: 'website' }/
   %meta{ property: 'og:title', content: Rails.configuration.x.local_domain }/
-  %meta{ property: 'og:description', content: "Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly" }/
+  %meta{ property: 'og:description', content: @description.blank? ? "Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly" : strip_tags(@description) }/
   %meta{ property: 'og:image', content: asset_url('mastodon_small.jpg') }/
   %meta{ property: 'og:image:width', content: '400' }/
   %meta{ property: 'og:image:height', content: '400' }/
+  %meta{ property: 'twitter:card', content: 'summary' }/
 
 .wrapper
   %h1
@@ -20,10 +24,14 @@
 
   .screenshot= image_tag 'screenshot.png'
 
+  - unless @description.blank?
+    %p= @description.html_safe
+
   .actions
     .info
+      = link_to t('about.learn_more'), about_more_path
       = link_to t('about.terms'), terms_path
-      = link_to t('about.source_code'), 'https://github.com/Gargron/mastodon'
+      = link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'
 
     = link_to t('about.get_started'), new_user_registration_path, class: 'button webapp-btn'
     = link_to t('auth.login'), new_user_session_path, class: 'button webapp-btn'
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
new file mode 100644
index 000000000..2de3bf986
--- /dev/null
+++ b/app/views/about/more.html.haml
@@ -0,0 +1,56 @@
+- content_for :page_title do
+  #{Rails.configuration.x.local_domain}
+
+.wrapper.thicc
+  .sidebar-layout
+    .main
+      .panel
+        %h2= Rails.configuration.x.local_domain
+
+        - unless @description.blank?
+          %p= @description.html_safe
+
+      .information-board
+        .section
+          %span= t 'about.user_count_before'
+          %strong= number_with_delimiter @user_count
+          %span= t 'about.user_count_after'
+        .section
+          %span= t 'about.status_count_before'
+          %strong= number_with_delimiter @status_count
+          %span= t 'about.status_count_after'
+        .section
+          %span= t 'about.domain_count_before'
+          %strong= number_with_delimiter @domain_count
+          %span= t 'about.domain_count_after'
+
+      - unless @extended_description.blank?
+        .panel= @extended_description.html_safe
+
+    .sidebar
+      .panel
+        .panel-header= t 'about.contact'
+        .panel-body
+          - if @contact_account
+            .owner
+              .avatar= image_tag @contact_account.avatar.url
+              .name
+                = link_to TagManager.instance.url_for(@contact_account) do
+                  %span.display_name.emojify= display_name(@contact_account)
+                  %span.username= "@#{@contact_account.acct}"
+
+          - unless @contact_email.blank?
+            .contact-email
+              = t 'about.business_email'
+              %strong= @contact_email
+      .panel
+        .panel-header= t 'about.links'
+        .panel-list
+          %ul
+            - if user_signed_in?
+              %li= link_to t('about.get_started'), root_path
+            - else
+              %li= link_to t('about.get_started'), new_user_registration_path
+              %li= link_to t('auth.login'), new_user_session_path
+            %li= link_to t('about.terms'), terms_path
+            %li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'
diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml
index dfdb23161..d5418fca5 100644
--- a/app/views/accounts/_grid_card.html.haml
+++ b/app/views/accounts/_grid_card.html.haml
@@ -3,6 +3,6 @@
     .avatar= image_tag account.avatar.url(:original)
     .name
       = link_to TagManager.instance.url_for(account) do
-        %span.display_name= display_name(account)
+        %span.display_name.emojify= display_name(account)
         %span.username= "@#{account.acct}"
-  %p.note= truncate(strip_tags(account.note), length: 150)
+  %p.note.emojify= truncate(strip_tags(account.note), length: 150)
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index 1c6b5f0f6..f575e855e 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -1,27 +1,27 @@
-.card{ style: "background-image: url(#{@account.header.url( :original)})" }
+.card.h-card.p-author{ style: "background-image: url(#{@account.header.url( :original)})" }
   - if user_signed_in? && current_account.id != @account.id && !current_account.requested?(@account)
     .controls
       - if current_account.following?(@account)
         = link_to t('accounts.unfollow'), unfollow_account_path(@account), data: { method: :post }, class: 'button'
       - else
         = link_to t('accounts.follow'), follow_account_path(@account), data: { method: :post }, class: 'button'
-  - else
+  - elsif !user_signed_in?
     .controls
       .remote-follow
         = link_to t('accounts.remote_follow'), account_remote_follow_path(@account), class: 'button'
-  .avatar= image_tag @account.avatar.url(:original)
+  .avatar= image_tag @account.avatar.url(:original), class: 'u-photo'
   %h1.name
-    = display_name(@account)
+    %span.p-name.emojify= display_name(@account)
     %small
-      = "@#{@account.username}"
+      %span.p-nickname= "@#{@account.username}"
       = fa_icon('lock') if @account.locked?
   .details
     .bio
-      .account__header__content= Formatter.instance.simplified_format(@account)
+      .account__header__content.p-note.emojify= Formatter.instance.simplified_format(@account)
 
     .details-counters
       .counter{ class: active_nav_class(account_url(@account)) }
-        = link_to account_url(@account) do
+        = link_to account_url(@account), class: 'u-url u-uid' do
           %span.counter-label= t('accounts.posts')
           %span.counter-number= number_with_delimiter @account.statuses.count
       .counter{ class: active_nav_class(following_account_url(@account)) }
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index 7afeb68a9..c194ce33d 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -12,16 +12,20 @@
   %meta{ property: 'og:image', content: full_asset_url(@account.avatar.url(:original)) }/
   %meta{ property: 'og:image:width', content: '120' }/
   %meta{ property: 'og:image:height', content: '120' }/
+  %meta{ property: 'twitter:card', content: 'summary' }/
 
-= render partial: 'header'
+.h-feed
+  %data.p-name{ value: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/
 
-- if @statuses.empty?
-  .accounts-grid
-    = render partial: 'nothing_here'
-- else
-  .activity-stream
-    = render partial: 'stream_entries/status', collection: @statuses, as: :status
+  = render partial: 'header'
 
-.pagination
-  - if @statuses.size == 20
-    = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next'
+  - if @statuses.empty?
+    .accounts-grid
+      = render partial: 'nothing_here'
+  - else
+    .activity-stream
+      = render partial: 'stream_entries/status', collection: @statuses, as: :status
+
+  .pagination
+    - if @statuses.size == 20
+      = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next'
diff --git a/app/views/admin/domain_blocks/index.html.haml b/app/views/admin/domain_blocks/index.html.haml
index aedf163f7..dbaeb4716 100644
--- a/app/views/admin/domain_blocks/index.html.haml
+++ b/app/views/admin/domain_blocks/index.html.haml
@@ -5,10 +5,12 @@
   %thead
     %tr
       %th Domain
+      %th Severity
   %tbody
     - @blocks.each do |block|
       %tr
         %td
           %samp= block.domain
+        %td= block.severity
 
 = will_paginate @blocks, pagination_options
diff --git a/app/views/admin/settings/index.html.haml b/app/views/admin/settings/index.html.haml
new file mode 100644
index 000000000..5b482213b
--- /dev/null
+++ b/app/views/admin/settings/index.html.haml
@@ -0,0 +1,36 @@
+- content_for :page_title do
+  Site Settings
+
+%table.table
+  %colgroup
+    %col{ width: '35%' }/
+  %thead
+    %tr
+      %th Setting
+      %th Click to edit
+  %tbody
+    %tr
+      %td{ rowspan: 2 }
+        %strong Contact information
+      %td= best_in_place @settings['site_contact_username'], :value, url: admin_setting_path(@settings['site_contact_username']), place_holder: 'Enter a username'
+    %tr
+      %td= best_in_place @settings['site_contact_email'], :value, url: admin_setting_path(@settings['site_contact_email']), place_holder: 'Enter a public e-mail address'
+    %tr
+      %td
+        %strong Site description
+        %br/
+        Displayed as a paragraph on the frontpage and used as a meta tag.
+        %br/
+        You can use HTML tags, in particular
+        %code= '<a>'
+        and
+        %code= '<em>'
+      %td= best_in_place @settings['site_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_description'])
+    %tr
+      %td
+        %strong Extended site description
+        %br/
+        Displayed on extended information page
+        %br/
+        You can use HTML tags
+      %td= best_in_place @settings['site_extended_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_extended_description'])
\ No newline at end of file
diff --git a/app/views/api/v1/apps/show.rabl b/app/views/api/v1/apps/show.rabl
new file mode 100644
index 000000000..6d9e607db
--- /dev/null
+++ b/app/views/api/v1/apps/show.rabl
@@ -0,0 +1,3 @@
+object @application
+
+attributes :name, :website
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index a3391a67e..7309a78b8 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, :sensitive, :visibility
+attributes :id, :created_at, :in_reply_to_id, :sensitive, :spoiler_text, :visibility
 
 node(:uri)              { |status| TagManager.instance.uri_for(status) }
 node(:content)          { |status| Formatter.instance.format(status) }
@@ -6,6 +6,10 @@ node(:url)              { |status| TagManager.instance.url_for(status) }
 node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : status.reblogs.count }
 node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites.count }
 
+child :application do
+  extends 'api/v1/apps/show'
+end
+
 child :account do
   extends 'api/v1/accounts/show'
 end
diff --git a/app/views/api/v1/statuses/card.rabl b/app/views/api/v1/statuses/card.rabl
new file mode 100644
index 000000000..8ba8dcbb1
--- /dev/null
+++ b/app/views/api/v1/statuses/card.rabl
@@ -0,0 +1,5 @@
+object @card
+
+attributes :url, :title, :description
+
+node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil }
diff --git a/app/views/authorize_follow/_card.html.haml b/app/views/authorize_follow/_card.html.haml
index a9b02c746..eef0bec07 100644
--- a/app/views/authorize_follow/_card.html.haml
+++ b/app/views/authorize_follow/_card.html.haml
@@ -4,8 +4,8 @@
       = image_tag account.avatar.url(:original), alt: '', width: 48, height: 48, class: 'avatar'
 
     %span.display-name
-      %strong= display_name(account)
+      %strong.emojify= display_name(account)
       %span= "@#{account.acct}"
 
   - unless account.note.blank?
-    .account__header__content= Formatter.instance.simplified_format(account)
+    .account__header__content.emojify= Formatter.instance.simplified_format(account)
diff --git a/app/views/errors/404.html.haml b/app/views/errors/404.html.haml
new file mode 100644
index 000000000..ba1d5f72d
--- /dev/null
+++ b/app/views/errors/404.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  The page you were looking for doesn't exist
+
+- content_for :content do
+  The page you were looking for doesn't exist
diff --git a/app/views/errors/410.html.haml b/app/views/errors/410.html.haml
new file mode 100644
index 000000000..07cf3742f
--- /dev/null
+++ b/app/views/errors/410.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  The page you were looking for doesn't exist anymore
+
+- content_for :content do
+  The page you were looking for doesn't exist anymore
diff --git a/app/views/errors/422.html.haml b/app/views/errors/422.html.haml
new file mode 100644
index 000000000..e369cded6
--- /dev/null
+++ b/app/views/errors/422.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  Security verification failed
+
+- content_for :content do
+  Security verification failed. Are you blocking cookies?
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index 498fae105..0147f4064 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -1,4 +1,7 @@
 - content_for :header_tags do
+  :javascript
+    window.INITIAL_STATE = #{json_escape(render(file: 'home/initial_state', formats: :json))}
+
   = javascript_include_tag 'application'
 
 = react_component 'Mastodon', default_props, class: 'app-holder', prerender: false
diff --git a/app/views/home/initial_state.json.rabl b/app/views/home/initial_state.json.rabl
new file mode 100644
index 000000000..0e9736f5f
--- /dev/null
+++ b/app/views/home/initial_state.json.rabl
@@ -0,0 +1,24 @@
+object false
+
+node(:meta) {
+  {
+    access_token: @token,
+    locale: I18n.locale,
+    me: current_account.id,
+  }
+}
+
+node(:compose) {
+  {
+    me: current_account.id,
+    private: current_account.locked?,
+  }
+}
+
+node(:accounts) {
+  {
+    current_account.id => partial('api/v1/accounts/show', object: current_account),
+  }
+}
+
+node(:settings) { @web_settings }
diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml
new file mode 100644
index 000000000..54563f7d8
--- /dev/null
+++ b/app/views/layouts/error.html.haml
@@ -0,0 +1,36 @@
+!!!
+%html{:lang => "en"}
+  %head
+    %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
+    %meta{:charset => "utf-8"}/
+    %title= yield :page_title
+    %meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/
+    %link{:href => "https://fonts.googleapis.com/css?family=Roboto:400", :rel => "stylesheet"}/
+    :css
+      body {
+        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+        background: #282c37;
+        color: #9baec8;
+        text-align: center;
+        margin: 0;
+        padding: 20px;
+      }
+
+      .dialog img {
+        display: block;
+        margin: 20px auto;
+        margin-top: 50px;
+        max-width: 600px;
+        width: 100%;
+        height: auto;
+      }
+
+      .dialog h1 {
+        font: 20px/28px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+        font-weight: 400;
+      }
+  %body
+    .dialog
+      %img{:alt => "Mastodon", :src => "/oops.png"}/
+      %div
+        %h1= yield :content
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index e6de7d017..808fb0a0e 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -6,6 +6,6 @@
   .footer
     %span.domain= link_to Rails.configuration.x.local_domain, root_path
     %span.powered-by
-      = t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/Gargron/mastodon')).html_safe
+      = t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/tootsuite/mastodon')).html_safe
 
 = render template: "layouts/application"
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index a0860c94b..a9a1d21ac 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -6,14 +6,14 @@
 
   = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }
 
-  = f.simple_fields_for :notification_emails, current_user.settings(:notification_emails) do |ff|
+  = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
     = ff.input :follow, as: :boolean, wrapper: :with_label
     = ff.input :follow_request, as: :boolean, wrapper: :with_label
     = ff.input :reblog, as: :boolean, wrapper: :with_label
     = 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|
+  = f.simple_fields_for :interactions, hash_to_object(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
 
diff --git a/app/views/settings/shared/_links.html.haml b/app/views/settings/shared/_links.html.haml
index 44f097950..a6e90f457 100644
--- a/app/views/settings/shared/_links.html.haml
+++ b/app/views/settings/shared/_links.html.haml
@@ -5,3 +5,4 @@
     %li= link_to t('settings.preferences'), settings_preferences_path
   - if controller_name != 'registrations'
     %li= link_to t('auth.change_password'), edit_user_registration_path
+  %li= link_to t('settings.back'), root_path
\ No newline at end of file
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 94451d3bd..6ee8c9e5b 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -1,36 +1,46 @@
 .detailed-status.light
-  = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do
+  = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name p-author h-card', target: @external_links ? '_blank' : nil, rel: 'noopener' do
     %div
       %div.avatar
-        = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: ''
+        = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: '', class: 'u-photo'
     %span.display-name
-      %strong= display_name(status.account)
-      %span= acct(status.account)
+      %strong.p-name.emojify= display_name(status.account)
+      %span.p-nickname= acct(status.account)
 
-  .status__content= Formatter.instance.format(status)
+  .status__content.e-content.p-name.emojify<
+    - unless status.spoiler_text.blank?
+      %p= status.spoiler_text
+    = Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     - if status.media_attachments.first.video?
       .video-player
         - if status.sensitive?
           = render partial: 'stream_entries/content_spoiler'
-        %video{ src: status.media_attachments.first.file.url(:original), loop: true }
+        %video{ src: status.media_attachments.first.file.url(:original), loop: true, class: 'u-video' }
     - else
       .detailed-status__attachments
         - if status.sensitive?
           = render partial: 'stream_entries/content_spoiler'
         - status.media_attachments.each do |media|
           .media-item
-            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener'
+            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
 
   %div.detailed-status__meta
-    = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: @external_links ? '_blank' : nil, rel: 'noopener' do
+    %data.dt-published{ value: status.created_at.to_time.iso8601 }
+    = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: @external_links ? '_blank' : nil, rel: 'noopener' do
       %span= l(status.created_at)
     ·
-    %span
+    - if status.application
+      - if status.application.website.blank?
+        %strong.detailed-status__application= status.application.name
+      - else
+        = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener'
+      ·
+    %span<
       = fa_icon('retweet')
       %span= status.reblogs.count
     ·
-    %span
+    %span<
       = fa_icon('star')
       %span= status.favourites.count
diff --git a/app/views/stream_entries/_favourite.html.haml b/app/views/stream_entries/_favourite.html.haml
index aac90dcdf..ea4879328 100644
--- a/app/views/stream_entries/_favourite.html.haml
+++ b/app/views/stream_entries/_favourite.html.haml
@@ -1,5 +1,5 @@
 .entry.entry-favourite
-  .content
+  .content.emojify
     %strong= favourite.account.acct
     = t('stream_entries.favourited')
     %strong= favourite.status.account.acct
diff --git a/app/views/stream_entries/_follow.html.haml b/app/views/stream_entries/_follow.html.haml
index 1a2e2c554..da6d062f0 100644
--- a/app/views/stream_entries/_follow.html.haml
+++ b/app/views/stream_entries/_follow.html.haml
@@ -1,5 +1,5 @@
 .entry.entry-follow
-  .content
+  .content.emojify
     %strong= link_to follow.account.acct, account_path(follow.account)
     = t('stream_entries.is_now_following')
     %strong= link_to follow.target_account.acct, TagManager.instance.url_for(follow.target_account)
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index da3bc0ccb..95f90abd9 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -1,17 +1,21 @@
 .status.light
   .status__header
     .status__meta
-      = link_to time_ago_in_words(status.created_at), TagManager.instance.url_for(status), class: 'status__relative-time', title: l(status.created_at), target: @external_links ? '_blank' : nil, rel: 'noopener'
+      = link_to time_ago_in_words(status.created_at), TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', title: l(status.created_at), target: @external_links ? '_blank' : nil, rel: 'noopener'
+      %data.dt-published{ value: status.created_at.to_time.iso8601 }
 
-    = link_to TagManager.instance.url_for(status.account), class: 'status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do
+    = link_to TagManager.instance.url_for(status.account), class: 'status__display-name p-author h-card', target: @external_links ? '_blank' : nil, rel: 'noopener' do
       .status__avatar
         %div
-          = image_tag status.account.avatar(:original), width: 48, height: 48, alt: ''
+          = image_tag status.account.avatar(:original), width: 48, height: 48, alt: '', class: 'u-photo'
       %span.display-name
-        %strong= display_name(status.account)
-        %span= acct(status.account)
+        %strong.p-name.emojify= display_name(status.account)
+        %span.p-nickname= acct(status.account)
 
-  .status__content= Formatter.instance.format(status)
+  .status__content.e-content.p-name.emojify<
+    - unless status.spoiler_text.blank?
+      %p= status.spoiler_text
+    = Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     .status__attachments
@@ -19,10 +23,10 @@
         = render partial: 'stream_entries/content_spoiler'
       - if status.media_attachments.first.video?
         .video-item
-          = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener' do
+          = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
             .video-item__play
               = fa_icon('play')
       - else
         - status.media_attachments.each do |media|
           .media-item
-            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener'
+            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index 43935da60..6bad45705 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -5,7 +5,11 @@
   %meta{ property: 'og:site_name', content: 'Mastodon' }/
   %meta{ property: 'og:type', content: 'article' }/
   %meta{ property: 'og:title', content: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/
-  %meta{ property: 'og:description', content: @stream_entry.activity.content }/
+
+  - if @stream_entry.activity.is_a?(Status) && !@stream_entry.activity.spoiler_text.blank?
+    %meta{ property: 'og:description', content: @stream_entry.activity.spoiler_text }/
+  - else
+    %meta{ property: 'og:description', content: @stream_entry.activity.content }/
 
   - if @stream_entry.activity.is_a?(Status) && @stream_entry.activity.media_attachments.size > 0
     %meta{ property: 'og:image', content: full_asset_url(@stream_entry.activity.media_attachments.first.file.url(:small)) }/
@@ -14,5 +18,7 @@
     %meta{ property: 'og:image:width', content: '120' }/
     %meta{ property: 'og:image:height', content: '120' }/
 
+  %meta{ property: 'twitter:card', content: 'summary' }/
+
 .activity-stream.activity-stream-headless
   = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true }
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index dd42fe22c..412ec4fa5 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -2,7 +2,7 @@
   .accounts-grid
     = render partial: 'accounts/nothing_here'
 - else
-  .activity-stream
+  .activity-stream.h-feed
     = render partial: 'stream_entries/status', collection: @statuses, as: :status, cached: true
 
 .pagination
diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb
new file mode 100644
index 000000000..0820490d3
--- /dev/null
+++ b/app/workers/block_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class BlockWorker
+  include Sidekiq::Worker
+
+  def perform(account_id, target_account_id)
+    AfterBlockService.new.call(Account.find(account_id), Account.find(target_account_id))
+  end
+end
diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb
new file mode 100644
index 000000000..af3394b8b
--- /dev/null
+++ b/app/workers/link_crawl_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class LinkCrawlWorker
+  include Sidekiq::Worker
+
+  sidekiq_options retry: false
+
+  def perform(status_id)
+    FetchLinkCardService.new.call(Status.find(status_id))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
new file mode 100644
index 000000000..0f288f43f
--- /dev/null
+++ b/app/workers/merge_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class MergeWorker
+  include Sidekiq::Worker
+
+  def perform(from_account_id, into_account_id)
+    FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id))
+  end
+end
diff --git a/app/workers/notification_worker.rb b/app/workers/notification_worker.rb
index 386e94111..e4c38d384 100644
--- a/app/workers/notification_worker.rb
+++ b/app/workers/notification_worker.rb
@@ -3,6 +3,8 @@
 class NotificationWorker
   include Sidekiq::Worker
 
+  sidekiq_options retry: 5
+
   def perform(stream_entry_id, target_account_id)
     SendInteractionService.new.call(StreamEntry.find(stream_entry_id), Account.find(target_account_id))
   end
diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb
index 489bd8359..868fd9f97 100644
--- a/app/workers/pubsubhubbub/confirmation_worker.rb
+++ b/app/workers/pubsubhubbub/confirmation_worker.rb
@@ -4,7 +4,7 @@ class Pubsubhubbub::ConfirmationWorker
   include Sidekiq::Worker
   include RoutingHelper
 
-  sidekiq_options queue: 'push'
+  sidekiq_options queue: 'push', retry: false
 
   def perform(subscription_id, mode, secret = nil, lease_seconds = nil)
     subscription = Subscription.find(subscription_id)
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index 35bf7b2f0..15005bc80 100644
--- a/app/workers/pubsubhubbub/delivery_worker.rb
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -4,7 +4,11 @@ class Pubsubhubbub::DeliveryWorker
   include Sidekiq::Worker
   include RoutingHelper
 
-  sidekiq_options queue: 'push', retry: 5
+  sidekiq_options queue: 'push', retry: 3, dead: false
+
+  sidekiq_retry_in do |count|
+    5 * (count + 1)
+  end
 
   def perform(subscription_id, payload)
     subscription = Subscription.find(subscription_id)
diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb
index 84eae73be..593edd032 100644
--- a/app/workers/thread_resolve_worker.rb
+++ b/app/workers/thread_resolve_worker.rb
@@ -3,6 +3,8 @@
 class ThreadResolveWorker
   include Sidekiq::Worker
 
+  sidekiq_options retry: false
+
   def perform(child_status_id, parent_url)
     child_status  = Status.find(child_status_id)
     parent_status = FetchRemoteStatusService.new.call(parent_url)
diff --git a/app/workers/unfavourite_worker.rb b/app/workers/unfavourite_worker.rb
index a14c82d6f..cce07e486 100644
--- a/app/workers/unfavourite_worker.rb
+++ b/app/workers/unfavourite_worker.rb
@@ -5,5 +5,7 @@ class UnfavouriteWorker
 
   def perform(account_id, status_id)
     UnfavouriteService.new.call(Account.find(account_id), Status.find(status_id))
+  rescue ActiveRecord::RecordNotFound
+    true
   end
 end
diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb
new file mode 100644
index 000000000..dbf7243de
--- /dev/null
+++ b/app/workers/unmerge_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class UnmergeWorker
+  include Sidekiq::Worker
+
+  def perform(from_account_id, into_account_id)
+    FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id))
+  end
+end
diff --git a/config/application.rb b/config/application.rb
index 79ace8521..d0b06bf95 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -3,6 +3,7 @@ require_relative 'boot'
 require 'rails/all'
 
 require_relative '../app/lib/exceptions'
+require_relative '../lib/statsd_monitor'
 
 # Require the gems listed in Gemfile, including any gems
 # you've limited to :test, :development, or :production.
@@ -30,6 +31,8 @@ module Mastodon
 
     config.active_job.queue_adapter = :sidekiq
 
+    config.middleware.insert(0, ::StatsDMonitor)
+
     config.middleware.insert_before 0, Rack::Cors do
       allow do
         origins  '*'
@@ -46,6 +49,7 @@ module Mastodon
 
     config.to_prepare do
       Doorkeeper::AuthorizationsController.layout 'public'
+      Doorkeeper::Application.send :include, ApplicationExtension
     end
 
     config.action_dispatch.default_headers = {
diff --git a/config/cable.yml b/config/cable.yml
index 978f721af..34759a772 100644
--- a/config/cable.yml
+++ b/config/cable.yml
@@ -7,4 +7,4 @@ test:
 
 production:
   adapter: redis
-  url: redis://<%= ENV['REDIS_HOST'] || 'localhost' %>:<%= ENV['REDIS_PORT'] || 6379 %>/1
+  url: redis://<%= ENV['REDIS_PASSWORD'] ? ':' + ENV['REDIS_PASSWORD'] + '@' : '' %><%= ENV['REDIS_HOST'] || 'localhost' %>:<%= ENV['REDIS_PORT'] || 6379 %>/1
diff --git a/config/database.yml b/config/database.yml
index 52c26f599..5ec342f93 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -1,6 +1,6 @@
 default: &default
   adapter: postgresql
-  pool: <%= ENV["DB_POOL"] || ENV['RAILS_MAX_THREADS'] || 5 %>
+  pool: <%= ENV["DB_POOL"] || ENV['MAX_THREADS'] || 5 %>
   timeout: 5000
   encoding: unicode
 
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 829edcf04..3f44d861e 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -62,7 +62,10 @@ Rails.application.configure do
   # routes, locales, etc. This feature depends on the listen gem.
   # config.file_watcher = ActiveSupport::EventedFileUpdateChecker
 
-  config.action_mailer.delivery_method = :letter_opener
+  # If using a Heroku, Vagrant or generic remote development environment,
+  # use letter_opener_web, accessible at  /letter_opener.
+  # Otherwise, use letter_opener, which launches a browser window to view sent mail.
+  config.action_mailer.delivery_method = (ENV['HEROKU'] || ENV['VAGRANT'] || ENV['REMOTE_DEV']) ? :letter_opener_web : :letter_opener
 
   config.after_initialize do
     Bullet.enable        = true
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 9254d494c..d2dfa4274 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -32,6 +32,9 @@ Rails.application.configure do
   # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
   config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
 
+  # Allow to specify public IP of reverse proxy if it's needed
+  config.action_dispatch.trusted_proxies = [IPAddr.new(ENV['TRUSTED_PROXY_IP'])] unless ENV['TRUSTED_PROXY_IP'].blank?
+
   # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
   config.force_ssl = false
 
@@ -45,10 +48,20 @@ Rails.application.configure do
   # Use a different logger for distributed setups.
   # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
 
+  # Parse and split the REDIS_URL if passed (used with hosting platforms such as Heroku).
+  # Set ENV variables because they are used elsewhere.
+  if ENV['REDIS_URL']
+    redis_url = URI.parse(ENV['REDIS_URL'])
+    ENV['REDIS_HOST'] = redis_url.host
+    ENV['REDIS_PORT'] = redis_url.port.to_s
+    ENV['REDIS_PASSWORD'] = redis_url.password
+  end
+
   # Use a different cache store in production.
   config.cache_store = :redis_store, {
     host: ENV.fetch('REDIS_HOST') { 'localhost' },
     port: ENV.fetch('REDIS_PORT') { 6379 },
+    password: ENV.fetch('REDIS_PASSWORD') { false },
     db: 0,
     namespace: 'cache',
     expires_in: 20.minutes
@@ -85,7 +98,7 @@ Rails.application.configure do
     :address        => ENV['SMTP_SERVER'],
     :user_name      => ENV['SMTP_LOGIN'],
     :password       => ENV['SMTP_PASSWORD'],
-    :domain         => config.x.local_domain,
+    :domain         => ENV['SMTP_DOMAIN'] || config.x.local_domain,
     :authentication => :plain,
   }
 
@@ -94,4 +107,8 @@ Rails.application.configure do
   config.react.variant = :production
 
   config.active_record.logger = nil
+
+  config.to_prepare do
+    StatsD.backend = StatsD::Instrument::Backends::NullBackend.new if ENV['STATSD_ADDR'].blank?
+  end
 end
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index ac033bf9d..b5e43e705 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -10,7 +10,7 @@
 #   inflect.uncountable %w( fish sheep )
 # end
 
-# These inflection rules are supported but not enabled by default:
-# ActiveSupport::Inflector.inflections(:en) do |inflect|
-#   inflect.acronym 'RESTful'
-# end
+ActiveSupport::Inflector.inflections(:en) do |inflect|
+  inflect.acronym 'StatsD'
+  inflect.acronym 'OEmbed'
+end
diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb
index cb7ed4487..71a7b514e 100644
--- a/config/initializers/paperclip.rb
+++ b/config/initializers/paperclip.rb
@@ -1,5 +1,7 @@
 # frozen_string_literal: true
 
+Paperclip.options[:read_timeout] = 60
+
 if ENV['S3_ENABLED'] == 'true'
   Aws.eager_autoload!(services: %w(S3))
 
@@ -9,7 +11,7 @@ if ENV['S3_ENABLED'] == 'true'
   Paperclip::Attachment.default_options[:s3_host_name]   = ENV.fetch('S3_HOSTNAME') { "s3-#{ENV.fetch('S3_REGION')}.amazonaws.com" }
   Paperclip::Attachment.default_options[:path]           = '/:class/:attachment/:id_partition/:style/:filename'
   Paperclip::Attachment.default_options[:s3_headers]     = { 'Cache-Control' => 'max-age=315576000', 'Expires' => 10.years.from_now.httpdate }
-  Paperclip::Attachment.default_options[:s3_permissions] = 'public'
+  Paperclip::Attachment.default_options[:s3_permissions] = 'public-read'
   Paperclip::Attachment.default_options[:s3_region]      = ENV.fetch('S3_REGION') { 'us-east-1' }
 
   Paperclip::Attachment.default_options[:s3_credentials] = {
diff --git a/config/initializers/redis.rb b/config/initializers/redis.rb
index 3825710b8..3660c4a9b 100644
--- a/config/initializers/redis.rb
+++ b/config/initializers/redis.rb
@@ -3,5 +3,6 @@
 Redis.current = Redis.new(
   host: ENV.fetch('REDIS_HOST') { 'localhost' },
   port: ENV.fetch('REDIS_PORT') { 6379 },
+  password: ENV.fetch('REDIS_PASSWORD') { false },
   driver: :hiredis
 )
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 63fdb3f16..ecdd07b08 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -1,10 +1,11 @@
 host = ENV.fetch('REDIS_HOST') { 'localhost' }
 port = ENV.fetch('REDIS_PORT') { 6379 }
+password = ENV.fetch('REDIS_PASSWORD') { false }
 
 Sidekiq.configure_server do |config|
-  config.redis = { host: host, port: port }
+  config.redis = { host: host, port: port, password: password}
 end
 
 Sidekiq.configure_client do |config|
-  config.redis = { host: host, port: port }
+  config.redis = { host: host, port: port, password: password }
 end
diff --git a/config/initializers/statsd.rb b/config/initializers/statsd.rb
new file mode 100644
index 000000000..c9c754e7f
--- /dev/null
+++ b/config/initializers/statsd.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+StatsD.prefix              = 'mastodon'
+StatsD.default_sample_rate = 1
+
+StatsDMonitor.extend(StatsD::Instrument)
+StatsDMonitor.statsd_measure(:call, 'request.duration')
+
+STATSD_REQUEST_METRICS = {
+  'request.status.success'               => 200,
+  'request.status.not_found'             => 404,
+  'request.status.too_many_requests'     => 429,
+  'request.status.internal_server_error' => 500,
+}.freeze
+
+STATSD_REQUEST_METRICS.each do |name, code|
+  StatsDMonitor.statsd_count_if(:call, name) do |status, _env, _body|
+    status.to_i == code
+  end
+end
diff --git a/config/initializers/trusted_proxies.rb b/config/initializers/trusted_proxies.rb
new file mode 100644
index 000000000..3c2afd8cd
--- /dev/null
+++ b/config/initializers/trusted_proxies.rb
@@ -0,0 +1,11 @@
+module Rack
+  class Request
+    def trusted_proxy?(ip)
+      if Rails.application.config.action_dispatch.trusted_proxies.nil?
+        super
+      else
+        Rails.application.config.action_dispatch.trusted_proxies.any? { |proxy| proxy === ip }
+      end
+    end
+  end
+end
diff --git a/config/locales/de.yml b/config/locales/de.yml
index ead3ae514..f36cc64c8 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -14,6 +14,7 @@ de:
     people_followed_by: Nutzer, denen %{name} folgt
     people_who_follow: Nutzer, die %{name} folgen
     posts: Beiträge
+    remote_follow: Folgen
     unfollow: Entfolgen
   application_mailer:
     signature: Mastodon-Benachrichtigungen von %{instance}
@@ -26,6 +27,25 @@ de:
     resend_confirmation: Bestätigung nochmal versenden
     reset_password: Passwort zurücksetzen
     set_new_password: Neues Passwort setzen
+  authorize_follow:
+    error: Das entfernte Profil konnte nicht geladen werden
+    follow: Folgen
+    prompt_html: 'Du (<strong>%{self}</strong>) möchtest dieser Person folgen:'
+    title: "%{acct} folgen"
+  datetime:
+    distance_in_words:
+      about_x_hours: "%{count}h"
+      about_x_months: "%{count}mo"
+      about_x_years: "%{count}y"
+      almost_x_years: "%{count}y"
+      half_a_minute: Gerade eben
+      less_than_x_minutes: "%{count}m"
+      less_than_x_seconds: Gerade eben
+      over_x_years: "%{count}y"
+      x_days: "%{count}d"
+      x_minutes: "%{count}m"
+      x_months: "%{count}mo"
+      x_seconds: "%{count}s"
   generic:
     changes_saved_msg: Änderungen gespeichert!
     powered_by: angetrieben von %{link}
@@ -40,6 +60,9 @@ de:
     follow:
       body: "%{name} folgt dir jetzt!"
       subject: "%{name} folgt dir nun"
+    follow_request:
+      body: "%{name} möchte dir folgen:"
+      subject: "%{name} möchte dir folgen"
     mention:
       body: "%{name} hat dich erwähnt:"
       subject: "%{name} hat dich erwähnt"
@@ -49,13 +72,23 @@ de:
   pagination:
     next: Vorwärts
     prev: Zurück
+  remote_follow:
+    acct: Dein Nutzername@Domain, von dem du dieser Person folgen möchtest
+    missing_resource: Die erforderliche Weiterleitungs-URL konnte leider in deinem Profil nicht gefunden werden
+    proceed: Weiter
+    prompt: 'Du wirst dieser Person folgen:'
   settings:
     edit_profile: Profil bearbeiten
     preferences: Einstellungen
   stream_entries:
+    click_to_show: Klicken um zu zeigen
     favourited: favorisierte einen Beitrag von
     is_now_following: folgt nun
     reblogged: teilte
+    sensitive_content: Sensible Inhalte
+  time:
+    formats:
+      default: "%d.%m.%Y %H:%M"
   users:
     invalid_email: Inkorrekte E-mail-Addresse
   will_paginate:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e166fc717..831fdbc7a 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -3,9 +3,19 @@ en:
   about:
     about_instance: "<em>%{instance}</em> is a Mastodon instance."
     about_mastodon: Mastodon is a <em>free, open-source</em> social network server. A <em>decentralized</em> alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the <em>social network</em> seamlessly.
+    business_email: 'Business e-mail:'
+    contact: Contact
+    domain_count_after: other instances
+    domain_count_before: Connected to
     get_started: Get started
+    learn_more: Learn more
+    links: Links
     source_code: Source code
+    status_count_after: statuses
+    status_count_before: Who authored
     terms: Terms
+    user_count_after: users
+    user_count_before: Home to
   accounts:
     follow: Follow
     followers: Followers
@@ -18,6 +28,8 @@ en:
     unfollow: Unfollow
   application_mailer:
     signature: Mastodon notifications from %{instance}
+  applications:
+    invalid_url: The provided URL is invalid
   auth:
     change_password: Change password
     didnt_get_confirmation: Didn't receive confirmation instructions?
@@ -67,8 +79,8 @@ en:
       body: 'You were mentioned by %{name} in:'
       subject: You were mentioned by %{name}
     reblog:
-      body: 'Your status was reblogged by %{name}:'
-      subject: "%{name} reblogged your status"
+      body: 'Your status was boosted by %{name}:'
+      subject: "%{name} boosted your status"
   pagination:
     next: Next
     prev: Prev
@@ -78,8 +90,11 @@ en:
     proceed: Proceed to follow
     prompt: 'You are going to follow:'
   settings:
+    back: Back to Mastodon
     edit_profile: Edit profile
     preferences: Preferences
+  statuses:
+    over_character_limit: character limit of %{max} exceeded
   stream_entries:
     click_to_show: Click to show
     favourited: favourited a post by
diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml
index d0aed9d0e..614cd4911 100644
--- a/config/locales/simple_form.de.yml
+++ b/config/locales/simple_form.de.yml
@@ -1,6 +1,9 @@
 ---
 de:
   simple_form:
+    hints:
+      defaults:
+        locked: Erlaubt dir, Folger zu überprüfen, bevor sie dir folgen können
     labels:
       defaults:
         avatar: Avatar
@@ -11,6 +14,7 @@ de:
         email: E-mail-Addresse
         header: Kopfbild
         locale: Sprache
+        locked: Gesperrter Profil
         new_password: Neues Passwort
         note: Über mich
         password: Passwort
@@ -21,6 +25,7 @@ de:
       notification_emails:
         favourite: E-mail senden, wenn jemand meinen Beitrag favorisiert
         follow: E-mail senden, wenn mir jemand folgt
+        follow_request: E-mail senden, wenn mir jemand folgen möchte
         mention: E-mail senden, wenn mich jemand erwähnt
         reblog: E-mail senden, wenn jemand meinen Beitrag teilt
     'no': Nein
diff --git a/config/navigation.rb b/config/navigation.rb
index 1b6615ed0..9aaa12b0b 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -7,5 +7,6 @@ SimpleNavigation::Configuration.run do |navigation|
     primary.item :domain_blocks, safe_join([fa_icon('lock fw'), 'Domain Blocks']), admin_domain_blocks_url
     primary.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url
     primary.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url
+    primary.item :settings, safe_join([fa_icon('cogs fw'), 'Site Settings']), admin_settings_url
   end
 end
diff --git a/config/puma.rb b/config/puma.rb
index ad2dbfffd..550129bdc 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -1,47 +1,18 @@
-# Puma can serve each request in a thread from an internal thread pool.
-# The `threads` method setting takes two numbers a minimum and maximum.
-# Any libraries that use thread pools should be configured to match
-# the maximum value specified for Puma. Default is set to 5 threads for minimum
-# and maximum, this matches the default thread size of Active Record.
-#
-threads_count = ENV.fetch("MAX_THREADS") { 5 }.to_i
+threads_count = ENV.fetch('MAX_THREADS') { 5 }.to_i
 threads threads_count, threads_count
 
-# Specifies the `port` that Puma will listen on to receive requests, default is 3000.
-#
-port        ENV.fetch("PORT") { 3000 }
+port        ENV.fetch('PORT') { 3000 }
+environment ENV.fetch('RAILS_ENV') { 'development' }
+workers     ENV.fetch('WEB_CONCURRENCY') { 2 }
 
-# Specifies the `environment` that Puma will run in.
-#
-environment ENV.fetch("RAILS_ENV") { "development" }
-
-# Specifies the number of `workers` to boot in clustered mode.
-# Workers are forked webserver processes. If using threads and workers together
-# the concurrency of the application would be max `threads` * `workers`.
-# Workers do not work on JRuby or Windows (both of which do not support
-# processes).
-#
-workers ENV.fetch("WEB_CONCURRENCY") { 2 }
-
-# Use the `preload_app!` method when specifying a `workers` number.
-# This directive tells Puma to first boot the application and load code
-# before forking the application. This takes advantage of Copy On Write
-# process behavior so workers use less memory. If you use this option
-# you need to make sure to reconnect any threads in the `on_worker_boot`
-# block.
-#
 preload_app!
 
-# The code in the `on_worker_boot` will be called if you are using
-# clustered mode by specifying a number of `workers`. After each worker
-# process is booted this block will be run, if you are using `preload_app!`
-# option you will want to use this block to reconnect to any threads
-# or connections that may have been created at application boot, Ruby
-# cannot share connections between processes.
-#
 on_worker_boot do
+  if ENV['HEROKU'] # Spawn the workers from Puma, to only use one dyno
+    @sidekiq_pid ||= spawn('bundle exec sidekiq -q default -q mailers -q push')
+  end
+
   ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
 end
 
-# Allow puma to be restarted by `rails restart` command.
 plugin :tmp_restart
diff --git a/config/routes.rb b/config/routes.rb
index 18c239c48..15fb924f1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -3,6 +3,7 @@
 require 'sidekiq/web'
 
 Rails.application.routes.draw do
+  mount LetterOpenerWeb::Engine, at: 'letter_opener' if Rails.env.development?
   mount ActionCable.server, at: 'cable'
 
   authenticate :user, lambda { |u| u.admin? } do
@@ -58,6 +59,7 @@ Rails.application.routes.draw do
   namespace :admin do
     resources :pubsubhubbub, only: [:index]
     resources :domain_blocks, only: [:index, :create]
+    resources :settings, only: [:index, :update]
 
     resources :accounts, only: [:index, :show, :update] do
       member do
@@ -85,6 +87,7 @@ Rails.application.routes.draw do
       resources :statuses, only: [:create, :show, :destroy] do
         member do
           get :context
+          get :card
           get :reblogged_by
           get :favourited_by
 
@@ -100,10 +103,11 @@ Rails.application.routes.draw do
       get '/timelines/public',   to: 'timelines#public', as: :public_timeline
       get '/timelines/tag/:id',  to: 'timelines#tag', as: :hashtag_timeline
 
-      resources :follows,  only: [:create]
-      resources :media,    only: [:create]
-      resources :apps,     only: [:create]
-      resources :blocks,   only: [:index]
+      resources :follows,    only: [:create]
+      resources :media,      only: [:create]
+      resources :apps,       only: [:create]
+      resources :blocks,     only: [:index]
+      resources :favourites, only: [:index]
 
       resources :follow_requests, only: [:index] do
         member do
@@ -112,8 +116,11 @@ Rails.application.routes.draw do
         end
       end
 
-      resources :notifications, only: [:index]
-      resources :favourites,    only: [:index]
+      resources :notifications, only: [:index, :show] do
+        collection do
+          post :clear
+        end
+      end
 
       resources :accounts, only: [:show] do
         collection do
@@ -134,12 +141,17 @@ Rails.application.routes.draw do
         end
       end
     end
+
+    namespace :web do
+      resource :settings, only: [:update]
+    end
   end
 
-  get '/web/*any', to: 'home#index', as: :web
+  get '/web/(*any)', to: 'home#index', as: :web
 
-  get :about, to: 'about#index'
-  get :terms, to: 'about#terms'
+  get '/about',      to: 'about#index'
+  get '/about/more', to: 'about#more'
+  get '/terms',      to: 'about#terms'
 
   root 'home#index'
 
diff --git a/config/settings.yml b/config/settings.yml
new file mode 100644
index 000000000..a78bd067d
--- /dev/null
+++ b/config/settings.yml
@@ -0,0 +1,23 @@
+# config/app.yml for rails-settings-cached
+defaults: &defaults
+  site_description: ''
+  site_extended_description: ''
+  site_contact_username: ''
+  site_contact_email: ''
+  notification_emails:
+    follow: false
+    reblog: false
+    favourite: false
+    mention: false
+    follow_request: true
+  interactions:
+    must_be_follower: false
+    must_be_following: false
+development:
+  <<: *defaults
+
+test:
+  <<: *defaults
+
+production:
+  <<: *defaults
diff --git a/db/migrate/20170105224407_add_shortcode_to_media_attachments.rb b/db/migrate/20170105224407_add_shortcode_to_media_attachments.rb
new file mode 100644
index 000000000..2685ae150
--- /dev/null
+++ b/db/migrate/20170105224407_add_shortcode_to_media_attachments.rb
@@ -0,0 +1,14 @@
+class AddShortcodeToMediaAttachments < ActiveRecord::Migration[5.0]
+  def up
+    add_column :media_attachments, :shortcode, :string, null: true, default: nil
+    add_index :media_attachments, :shortcode, unique: true
+
+    # Migrate old links
+    MediaAttachment.local.update_all('shortcode = id')
+  end
+
+  def down
+  	remove_index :media_attachments, :shortcode
+  	remove_column :media_attachments, :shortcode
+  end
+end
diff --git a/db/migrate/20170109120109_create_web_settings.rb b/db/migrate/20170109120109_create_web_settings.rb
new file mode 100644
index 000000000..2aeae1f91
--- /dev/null
+++ b/db/migrate/20170109120109_create_web_settings.rb
@@ -0,0 +1,12 @@
+class CreateWebSettings < ActiveRecord::Migration[5.0]
+  def change
+    create_table :web_settings do |t|
+      t.integer :user_id
+      t.json :data
+
+      t.timestamps
+    end
+
+    add_index :web_settings, :user_id, unique: true
+  end
+end
diff --git a/db/migrate/20170112154826_migrate_settings.rb b/db/migrate/20170112154826_migrate_settings.rb
new file mode 100644
index 000000000..f6f6ed531
--- /dev/null
+++ b/db/migrate/20170112154826_migrate_settings.rb
@@ -0,0 +1,19 @@
+class MigrateSettings < ActiveRecord::Migration
+  def up
+    remove_index :settings, [:target_type, :target_id, :var]
+    rename_column :settings, :target_id, :thing_id
+    rename_column :settings, :target_type, :thing_type
+    change_column :settings, :thing_id, :integer, null: true, default: nil
+    change_column :settings, :thing_type, :string, null: true, default: nil
+    add_index :settings, [:thing_type, :thing_id, :var], unique: true
+  end
+
+  def down
+    remove_index :settings, [:thing_type, :thing_id, :var]
+    rename_column :settings, :thing_id, :target_id
+    rename_column :settings, :thing_type, :target_type
+    change_column :settings, :target_id, :integer, null: false
+    change_column :settings, :target_type, :string, null: false, default: ''
+    add_index :settings, [:target_type, :target_id, :var], unique: true
+  end
+end
diff --git a/db/migrate/20170114194937_add_application_to_statuses.rb b/db/migrate/20170114194937_add_application_to_statuses.rb
new file mode 100644
index 000000000..b699db2ac
--- /dev/null
+++ b/db/migrate/20170114194937_add_application_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddApplicationToStatuses < ActiveRecord::Migration[5.0]
+  def change
+    add_column :statuses, :application_id, :int
+  end
+end
diff --git a/db/migrate/20170114203041_add_website_to_oauth_application.rb b/db/migrate/20170114203041_add_website_to_oauth_application.rb
new file mode 100644
index 000000000..ee674be72
--- /dev/null
+++ b/db/migrate/20170114203041_add_website_to_oauth_application.rb
@@ -0,0 +1,5 @@
+class AddWebsiteToOauthApplication < ActiveRecord::Migration[5.0]
+  def change
+    add_column :oauth_applications, :website, :string
+  end
+end
diff --git a/db/migrate/20170119214911_create_preview_cards.rb b/db/migrate/20170119214911_create_preview_cards.rb
new file mode 100644
index 000000000..70ed91bbd
--- /dev/null
+++ b/db/migrate/20170119214911_create_preview_cards.rb
@@ -0,0 +1,17 @@
+class CreatePreviewCards < ActiveRecord::Migration[5.0]
+  def change
+    create_table :preview_cards do |t|
+      t.integer :status_id
+      t.string :url, null: false, default: ''
+
+      # OpenGraph
+      t.string :title, null: true
+      t.string :description, null: true
+      t.attachment :image
+
+      t.timestamps
+    end
+
+    add_index :preview_cards, :status_id, unique: true
+  end
+end
diff --git a/db/migrate/20170123162658_add_severity_to_domain_blocks.rb b/db/migrate/20170123162658_add_severity_to_domain_blocks.rb
new file mode 100644
index 000000000..dcbc32a1a
--- /dev/null
+++ b/db/migrate/20170123162658_add_severity_to_domain_blocks.rb
@@ -0,0 +1,5 @@
+class AddSeverityToDomainBlocks < ActiveRecord::Migration[5.0]
+  def change
+    add_column :domain_blocks, :severity, :integer, default: 0
+  end
+end
diff --git a/db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb b/db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb
new file mode 100644
index 000000000..999fccda0
--- /dev/null
+++ b/db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb
@@ -0,0 +1,5 @@
+class AddRejectMediaToDomainBlocks < ActiveRecord::Migration[5.0]
+  def change
+    add_column :domain_blocks, :reject_media, :boolean
+  end
+end
diff --git a/db/migrate/20170125145934_add_spoiler_text_to_statuses.rb b/db/migrate/20170125145934_add_spoiler_text_to_statuses.rb
new file mode 100644
index 000000000..2c43210ba
--- /dev/null
+++ b/db/migrate/20170125145934_add_spoiler_text_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddSpoilerTextToStatuses < ActiveRecord::Migration[5.0]
+  def change
+    add_column :statuses, :spoiler_text, :text, default: "", null: false
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index b9236d42f..72ce63133 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: 20161222204147) do
+ActiveRecord::Schema.define(version: 20170125145934) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -55,9 +55,11 @@ ActiveRecord::Schema.define(version: 20161222204147) do
   end
 
   create_table "domain_blocks", force: :cascade do |t|
-    t.string   "domain",     default: "", null: false
-    t.datetime "created_at",              null: false
-    t.datetime "updated_at",              null: false
+    t.string   "domain",       default: "", null: false
+    t.datetime "created_at",                null: false
+    t.datetime "updated_at",                null: false
+    t.integer  "severity",     default: 0
+    t.boolean  "reject_media"
     t.index ["domain"], name: "index_domain_blocks_on_domain", unique: true, using: :btree
   end
 
@@ -95,6 +97,8 @@ ActiveRecord::Schema.define(version: 20161222204147) do
     t.integer  "account_id"
     t.datetime "created_at",                     null: false
     t.datetime "updated_at",                     null: false
+    t.string   "shortcode"
+    t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree
     t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree
   end
 
@@ -151,30 +155,32 @@ ActiveRecord::Schema.define(version: 20161222204147) do
     t.datetime "created_at"
     t.datetime "updated_at"
     t.boolean  "superapp",     default: false, null: false
+    t.string   "website"
     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
+  create_table "preview_cards", force: :cascade do |t|
+    t.integer  "status_id"
+    t.string   "url",                default: "", null: false
+    t.string   "title"
+    t.string   "description"
+    t.string   "image_file_name"
+    t.string   "image_content_type"
+    t.integer  "image_file_size"
+    t.datetime "image_updated_at"
+    t.datetime "created_at",                      null: false
+    t.datetime "updated_at",                      null: false
+    t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true, using: :btree
   end
 
   create_table "settings", force: :cascade do |t|
-    t.string   "var",         null: false
+    t.string   "var",        null: false
     t.text     "value"
-    t.string   "target_type", null: false
-    t.integer  "target_id",   null: false
+    t.string   "thing_type"
+    t.integer  "thing_id"
     t.datetime "created_at"
     t.datetime "updated_at"
-    t.index ["target_type", "target_id", "var"], name: "index_settings_on_target_type_and_target_id_and_var", unique: true, using: :btree
+    t.index ["thing_type", "thing_id", "var"], name: "index_settings_on_thing_type_and_thing_id_and_var", unique: true, using: :btree
   end
 
   create_table "statuses", force: :cascade do |t|
@@ -189,7 +195,8 @@ ActiveRecord::Schema.define(version: 20161222204147) do
     t.boolean  "sensitive",              default: false
     t.integer  "visibility",             default: 0,     null: false
     t.integer  "in_reply_to_account_id"
-    t.string   "conversation_uri"
+    t.integer  "application_id"
+    t.text     "spoiler_text",           default: "",    null: 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
@@ -258,4 +265,12 @@ ActiveRecord::Schema.define(version: 20161222204147) do
     t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree
   end
 
+  create_table "web_settings", force: :cascade do |t|
+    t.integer  "user_id"
+    t.json     "data"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true, using: :btree
+  end
+
 end
diff --git a/docs/Contributing-to-Mastodon/Sponsors.md b/docs/Contributing-to-Mastodon/Sponsors.md
new file mode 100644
index 000000000..5916ceb45
--- /dev/null
+++ b/docs/Contributing-to-Mastodon/Sponsors.md
@@ -0,0 +1,31 @@
+Sponsors
+========
+
+These people make the development of Mastodon possible through [Patreon](https://www.patreon.com/user?u=619786):
+
+**Extra special Patrons**
+
+- [World'sTallestLadder](https://mastodon.social/users/carcinoGeneticist)
+- [glocal](https://mastodon.social/users/glocal)
+- [Jimmy Tidey](https://mastodon.social/users/jimmytidey)
+- [Kurtis Rainbolt-Greene](https://mastodon.social/users/krainboltgreene)
+- [Kit Redgrave](https://socially.constructed.space/users/KitRedgrave)
+
+**Thank you to the following people**
+
+- [Sophia Park](https://mastodon.social/users/sophia)
+- [WelshPixie](https://mastodon.social/users/WelshPixie)
+- [John Parker](https://mastodon.social/users/Middaparka)
+- [Christina Hendricks](https://mastodon.social/users/clhendricksbc)
+- [Jelle](http://jelv.nl)
+- [Harris Bomberguy](https://mastodon.social/users/Hbomberguy)
+- [Martin Tithonium](https://mastodon.social/users/tithonium)
+- [Edward Saperia](https://nwspk.com)
+- [Yoz Grahame](http://yoz.com/)
+- [Jenn Kaplan](https://gay.crime.team/users/jkap)
+- [Natalie Weizenbaum](https://mastodon.social/users/nex3)
+- [Matteo De Micheli](http://matteodem.ch/)
+- [BirdMachine](https://mastodon.social/users/BirdMachine)
+- [Jessica Hayley](https://mastodon.social/users/jayhay)
+- [Niels Roesen Abildgaard](http://hypesystem.dk/)
+- [Zatnosk](https://github.com/Zatnosk)
diff --git a/docs/Contributing-to-Mastodon/Translating.md b/docs/Contributing-to-Mastodon/Translating.md
new file mode 100644
index 000000000..d47e83e7e
--- /dev/null
+++ b/docs/Contributing-to-Mastodon/Translating.md
@@ -0,0 +1,48 @@
+Translating
+===========
+
+If you want to localise Mastodon into your language, here is how.
+
+There are two parts to Mastodon, the server and the web client. The translations for the web client are in `app/assets/javascripts/components/locales`. For the server-side, the translations live in `config/locales` and are divided into different files. Here are all the files you’ll need to translate:
+
+| Original file (English) | Location | Description |
+|---|---|---|
+| [`en.jsx`](/Gargron/mastodon/tree/master/app/assets/javascripts/components/locales/en.jsx) | `app/assets/javascripts/components/locales/en.jsx` | Strings for the web client |
+| [`en.yml`](/Gargron/mastodon/tree/master/config/locales/en.yml) | `config/locales/en.yml` | Strings for general use |
+| [`simple_form.en.yml`](/Gargron/mastodon/tree/master/config/locales/simple_form.en.yml) | `config/locales/simple_form.en.yml` | Strings for the settings area |
+| [`devise.en.yml`](/Gargron/mastodon/tree/master/config/locales/devise.en.yml) | `config/locales/devise.en.yml` | Generic strings for Devise |
+| [`doorkeeper.en.yml`](/Gargron/mastodon/tree/master/config/locales/doorkeeper.en.yml) | `config/locales/doorkeeper.en.yml` | Generic strings for Doorkeeper |
+
+## Translating
+
+If you use Github, first clone the Mastodon repository to your account.
+
+1. Duplicate the files in their folder and replace `en` in the filenames by your language’s standard two-letters code ([ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)).
+   For instance `simple_form.en.yml` becomes `simple_form.es.yml` in the Spanish translation.
+2. Also replace the language code in the first lines of all the files, and the last line of the `.jsx` file.
+3. Translate the right-side values from English to your language. Keep the indentation and punctuation.
+
+Since Devise and Doorkeeper are popular libraries, there may already be translation files for your language available on the Internet.
+
+## Declaring the language
+
+The locales are mentioned in several other files. To activate your translation, add your language code to the different lists present in these files:
+
+| File | Location | Comment |
+|---|---|---|
+| [`index.jsx`](/Gargron/mastodon/tree/master/app/assets/javascripts/components/locales/index.jsx) | `app/assets/javascripts/components/locales/index.jsx` | 2 lines to add |
+|[`mastodon.jsx`](/Gargron/mastodon/tree/master/app/assets/javascripts/components/containers/mastodon.jsx) | `app/assets/javascripts/components/containers/mastodon.jsx` | 1 line to add + 1 list to complete |
+| [`settings_helper.rb`](/Gargron/mastodon/tree/master/app/helpers/settings_helper.rb) | `app/helpers/settings_helper.rb` | 1 line to add + your language’s name |
+| [`application.rb`](/Gargron/mastodon/tree/master/config/application.rb) | `config/application.rb` | 1 list to complete |
+
+## Sending the translation
+
+You can then push the files to git and submit a pull request.
+
+## Testing the translation
+
+Once the pull request is accepted, wait for the code to be deployed on a Mastodon instance. Log-in with your account there, and change the locale in the settings. Browse and use the website. See if everything makes sense in context and if anything seems out of place or breaks the layout. Invite other Mastodon users speaking your language to try it and give feedback. Make changes accordingly and update the translation.
+
+## Updating the translation
+
+Keep an eye on the original English files in `app/assets/javascripts/components/locales` and `config/locales`. When they are updated, pass on the changes to your language files. For new strings, add the new lines to the same position and translate them. Once you’re finished with the updates, you can submit a new pull request.
diff --git a/docs/Extensions.md b/docs/Extensions.md
new file mode 100644
index 000000000..a3d64ebf1
--- /dev/null
+++ b/docs/Extensions.md
@@ -0,0 +1,15 @@
+Protocol extensions
+===================
+
+Some functionality in Mastodon required some additions to the protocols to enable seamless federation of those features:
+
+1. ActivityStreams was lacking verbs for block/unblock. Mastodon creates Salmon slaps for block and unblock events, which are not part of a user's public feed, but are nevertheless delivered to the target user. The intent of these Salmon slaps is not to notify the target user, but to notify the target user's server, so that it can perform any number of UX-related tasks such as removing the target user as a follower of the blocker, and/or displaying a message to the target user such as "You can't follow this person because you've been blocked"
+
+  The Salmon slaps have the exact same structure as standard follow/unfollow slaps, the verbs are namespaced:
+
+  - `http://mastodon.social/schema/1.0/block`
+  - `http://mastodon.social/schema/1.0/unblock`
+
+2. Statuses can be marked as containing sensitive (or not safe for work) media. This is symbolized by a `<category term="nsfw" />` on the Atom entry
+
+3. Statuses that are intended to be listed publicly on e.g. "whole known network" or "public" timelines contain a `<link rel="mentioned" href="http://activityschema.org/collection/public" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection"/>`. Conversely, statuses which do not contain that, are intended to be low key, unlisted
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 000000000..000593761
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,24 @@
+Index
+=====
+
+**Mastodon** is a free, open-source GNU social-compatible social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
+
+### Using Mastodon
+- [Frequently Asked Questions](Using-Mastodon/FAQ.md)
+- [List of Mastodon instances](Using-Mastodon/List-of-Mastodon-instances.md)
+- [Apps](Using-Mastodon/Apps.md)
+
+### Using the API
+- [API documentation](Using-the-API/API.md)
+- [Testing the API with cURL](Using-the-API/Testing-with-cURL.md)
+- [OAuth details](Using-the-API/OAuth-details.md)
+- [Tips for app developers](Using-the-API/Tips-for-app-developers.md)
+
+### Running Mastodon
+- [Production guide](Running-Mastodon/Production-guide.md)
+- [Development guide](Running-Mastodon/Development-guide.md)
+
+### Contributing to Mastodon
+- [Sponsors](Contributing-to-Mastodon/Sponsors.md)
+- [Translate Mastodon in your language](Contributing-to-Mastodon/Translating.md)
+- [Report bugs and submit ideas](https://github.com/tootsuite/mastodon/issues)
diff --git a/docs/Running-Mastodon/Administration-guide.md b/docs/Running-Mastodon/Administration-guide.md
new file mode 100644
index 000000000..1b9dc8630
--- /dev/null
+++ b/docs/Running-Mastodon/Administration-guide.md
@@ -0,0 +1,28 @@
+Administration guide
+=================
+
+So, you have a working Mastodon instance... now what?
+
+## Administration web interface
+
+A user that is designated as `admin = TRUE` in the database is able to access a suite of administration tools:
+
+* View, edit, silence, or suspend users - https://yourmastodon.instance/admin/accounts
+* View PubSubHubbub subscriptions - https://yourmastodon.instance/admin/pubsubhubbub
+* View domain blocks - https://yourmastodon.instance/admin/domain_blocks
+* Sidekiq dashboard - https://yourmastodon.instance/sidekiq
+* PGHero dashboard for PostgreSQL - https://yourmastodon.instance/pghero
+* Edit site settings - https://yourmastodon.instance/admin/settings
+
+## Site settings
+
+Your site settings are stored in the `settings` database table, and editable through the admin interface at https://yourmastodon.instance/admin/settings.
+
+You are able to set the following settings:
+
+- Contact username
+- Contact email
+- Site description
+- Site extended description
+
+You may wish to use the extended description (shown at https://yourmastodon.instance/about/more ) to display content guidelines or a user agreement (see https://mastodon.social/about/more for an example).
\ No newline at end of file
diff --git a/docs/Running-Mastodon/Development-guide.md b/docs/Running-Mastodon/Development-guide.md
new file mode 100644
index 000000000..80e6e2f94
--- /dev/null
+++ b/docs/Running-Mastodon/Development-guide.md
@@ -0,0 +1,48 @@
+Development guide
+=================
+
+**Don't use Docker to do development**. It's a quick way to get Mastodon running in production, it's **really really inconvenient for development**. Normally in Rails development environment you get hot reloading of backend code and on-the-fly compilation of assets like JS and CSS, but you lose those benefits by compiling a Docker image. If you want to contribute to Mastodon, it is worth it to simply set up a proper development environment.
+
+In fact, all you need is described in the [production guide](Production-guide.md), **with the following exceptions**. You **don't** need:
+
+- Nginx
+- SystemD
+- An `.env.production` file. If you need to set any environment variables, you can use an `.env` file
+- To prefix any commands with `RAILS_ENV=production` since the default environment is "development" anyway
+- Any cronjobs
+
+The command to install project dependencies does not require any flags, i.e. simply
+
+    bundle install
+
+By default the development environment wants to connect to a `mastodon_development` database on localhost using your user/ident to login to Postgres (i.e. not a md5 password)
+
+You can run Mastodon with:
+
+    rails s
+
+And open `http://localhost:3000` in your browser. Background jobs run inline (aka synchronously) in the development environment, so you don't need to run a Sidekiq process.
+
+You can run tests with:
+
+    rspec
+
+You can check localization status with:
+
+    i18n-tasks health
+
+You can check code quality with:
+
+    rubocop
+
+## Development tips
+
+You can use a localhost->world tunneling service like ngrok if you want to test federation, **however** that should not be your primary mode of operation. If you want to have a permanently federating server, set up a proper instance on a VPS with a domain name, and simply keep it up to date with your own fork of the project while doing development on localhost.
+
+Ngrok and similar services give you a random domain on each start up. This is good enough to test how the code you're working on handles real-world situations. But as soon as your domain changes, for everybody else concerned you're a different instance than before.
+
+Generally, federation bits are tricky to work on for exactly this reason - it's hard to test. And when you are testing with a disposable instance you are polluting the databases of the real servers you're testing against, usually not a big deal but can be annoying. The way I have handled this so far was thus: I have used ngrok for one session, and recorded the exchanges from its web interface to create fixtures and test suites. From then on I've been working with those rather than live servers.
+
+I advise to study the existing code and the RFCs before trying to implement any federation-related changes. It's not *that* difficult, but I think "here be dragons" applies because it's easy to break.
+
+If your development environment is running remotely (e.g. on a VPS or virtual machine), setting the `REMOTE_DEV` environment variable will swap your instance from using "letter opener" (which launches a local browser) to "letter opener web" (which collects emails and displays them at /letter_opener ).
\ No newline at end of file
diff --git a/docs/Running-Mastodon/Heroku-guide.md b/docs/Running-Mastodon/Heroku-guide.md
new file mode 100644
index 000000000..6aa8be774
--- /dev/null
+++ b/docs/Running-Mastodon/Heroku-guide.md
@@ -0,0 +1,13 @@
+Heroku guide
+============
+
+[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
+
+Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. It should be noted this has limited testing and could have unpredictable results.
+
+1. Click the above button.
+2. Fill in the options requested.
+  * You can use a .herokuapp.com domain, which will be simple to set up, or you can use a custom domain. If you want a custom domain and HTTPS, you will need to upgrade to a paid plan (to use Heroku's SSL features), or set up [CloudFlare](https://cloudflare.com) who offer free "Flexible SSL" (note: CloudFlare have some undefined limits on WebSockets. So far, no one has reported hitting concurrent connection limits).
+  * You will want Amazon S3 for file storage. The only exception is for development purposes, where you may not care if files are not saaved. Follow a guide online for creating a free Amazon S3 bucket and Access Key, then enter the details.
+  * If you want your Mastodon to be able to send emails, configure SMTP settings here (or later). Consider using [Mailgun](https://mailgun.com) or similar, who offer free plans that should suit your interests.
+3. Deploy! The app should be set up, with a working web interface and database. You can change settings and manage versions from the Heroku dashboard.
\ No newline at end of file
diff --git a/docs/Running-Mastodon/Production-guide.md b/docs/Running-Mastodon/Production-guide.md
new file mode 100644
index 000000000..76964d995
--- /dev/null
+++ b/docs/Running-Mastodon/Production-guide.md
@@ -0,0 +1,188 @@
+Production guide
+================
+
+## Nginx
+
+Regardless of whether you go with the Docker approach or not, here is an example Nginx server configuration:
+
+```nginx
+map $http_upgrade $connection_upgrade {
+  default upgrade;
+  ''      close;
+}
+
+server {
+  listen 443 ssl;
+  server_name example.com;
+
+  ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
+  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+  keepalive_timeout    70;
+  sendfile             on;
+  client_max_body_size 0;
+  gzip off;
+
+  root /home/mastodon/live/public;
+
+  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
+
+  location / {
+    try_files $uri @proxy;
+  }
+
+  location @proxy {
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto https;
+
+    proxy_pass_header Server;
+
+    proxy_pass http://localhost:3000;
+    proxy_buffering off;
+    proxy_redirect off;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection $connection_upgrade;
+
+    tcp_nodelay on;
+  }
+
+  error_page 500 501 502 503 504 /500.html;
+}
+```
+
+## Running in production without Docker
+
+It is recommended to create a special user for mastodon on the server (you could call the user `mastodon`), though remember to disable outside login for it. You should only be able to get into that user through `sudo su - mastodon`.
+
+## General dependencies
+
+    curl -sL https://deb.nodesource.com/setup_4.x | sudo bash -
+    sudo apt-get install imagemagick ffmpeg libpq-dev libxml2-dev libxslt1-dev nodejs
+    sudo npm install -g yarn
+
+## Redis
+
+    sudo apt-get install redis-server redis-tools
+
+## Postgres
+
+    sudo apt-get install postgresql postgresql-contrib
+
+## Rbenv
+
+It is recommended to use rbenv (exclusively from the `mastodon` user) to install the desired Ruby version. Follow the guides to [install rbenv][1] and [rbenv-build][2] (I recommend checking the [prerequisites][3] for your system on the rbenv-build project and installing them beforehand, obviously outside the unprivileged `mastodon` user)
+
+[1]: https://github.com/rbenv/rbenv#installation
+[2]: https://github.com/rbenv/ruby-build#installation
+[3]: https://github.com/rbenv/ruby-build/wiki#suggested-build-environment
+
+Then once `rbenv` is ready, run `rbenv install 2.3.1` to install the Ruby version for Mastodon.
+
+## Git
+
+You need the `git-core` package installed on your system. If it is so, from the `mastodon` user:
+
+    cd ~
+    git clone https://github.com/Gargron/mastodon.git live
+    cd live
+
+Then you can proceed to install project dependencies:
+
+    gem install bundler
+    bundle install --deployment --without development test
+    yarn install
+
+## Configuration
+
+Then you have to configure your instance:
+
+    cp .env.production.sample .env.production
+    nano .env.production
+
+Fill in the important data, like host/port of the redis database, host/port/username/password of the postgres database, your domain name, SMTP details (e.g. from Mailgun or equivalent transactional e-mail service, many have free tiers), whether you intend to use SSL, etc. If you need to generate secrets, you can use:
+
+    rake secret
+
+To get a random string.
+
+## Setup
+
+And setup the database for the first time, this will create the tables and basic data:
+
+    RAILS_ENV=production bundle exec rails db:setup
+
+Finally, pre-compile all CSS and JavaScript files:
+
+    RAILS_ENV=production bundle exec rails assets:precompile
+
+## Systemd
+
+Example systemd configuration for the web workers, to be placed in `/etc/systemd/system/mastodon-web.service`:
+
+```systemd
+[Unit]
+Description=mastodon-web
+After=network.target
+
+[Service]
+Type=simple
+User=mastodon
+WorkingDirectory=/home/mastodon/live
+Environment="RAILS_ENV=production"
+Environment="PORT=3000"
+ExecStart=/home/mastodon/.rbenv/shims/bundle exec puma -C config/puma.rb
+TimeoutSec=15
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Example systemd configuration for the background workers, to be placed in `/etc/systemd/system/mastodon-sidekiq.service`:
+
+```systemd
+[Unit]
+Description=mastodon-sidekiq
+After=network.target
+
+[Service]
+Type=simple
+User=mastodon
+WorkingDirectory=/home/mastodon/live
+Environment="RAILS_ENV=production"
+Environment="DB_POOL=5"
+ExecStart=/home/mastodon/.rbenv/shims/bundle exec sidekiq -c 5 -q default -q mailers -q push
+TimeoutSec=15
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+```
+
+This allows you to `sudo systemctl enable mastodon-*.service` and `sudo systemctl start mastodon-*.service` to get things going.
+
+## Cronjobs
+
+I recommend creating a couple cronjobs for the following tasks:
+
+- `RAILS_ENV=production bundle exec rake mastodon:media:clear`
+- `RAILS_ENV=production bundle exec rake mastodon:push:refresh`
+- `RAILS_ENV=production bundle exec rake mastodon:feeds:clear`
+
+You may want to run `which bundle` first and copypaste that full path instead of simply `bundle` in the above commands because cronjobs usually don't have all the paths set. The time and intervals of when to run these jobs are up to you, but once every day should be enough for all.
+
+You can edit the cronjob file for the `mastodon` user by running `sudo crontab -e mastodon` (outside of the mastodon user).
+
+## Things to look out for when upgrading Mastodon
+
+You can upgrade Mastodon with a `git pull` from the repository directory. You may need to run:
+
+- `RAILS_ENV=production bundle exec rails db:migrate`
+- `RAILS_ENV=production bundle exec rails assets:precompile`
+
+Depending on which files changed, e.g. if anything in the `/db/` or `/app/assets` directory changed, respectively. Also, Mastodon runs in memory, so you need to restart it before you see any changes. If you're using systemd, that would be:
+
+    sudo systemctl restart mastodon-*.service
diff --git a/docs/Running-Mastodon/Vagrant-guide.md b/docs/Running-Mastodon/Vagrant-guide.md
new file mode 100644
index 000000000..a94478392
--- /dev/null
+++ b/docs/Running-Mastodon/Vagrant-guide.md
@@ -0,0 +1,64 @@
+Vagrant guide
+=============
+
+A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed.
+
+## Basic setup
+
+Install the latest versions of Vagrant and VirtualBox for your operating systems, and then run:
+
+    vagrant plugin install vagrant-hostsupdater
+
+This is optional, but will update your 'hosts' file when you start the virtual machine, allowing you to access the site at http://mastodon.dev (instead of http://localhost:3000).
+
+To create and provision a new virtual machine for Mastodon development:
+
+    git clone git@github.com:tootsuite/mastodon.git
+    cd mastodon
+    vagrant up
+
+Running `vagrant up` for the first time will run provisioning, which will:
+
+- Download the Ubuntu 14.04 base image, if there isn't already a copy on your machine
+- Create a new VirtualBox virtual machine from that image
+- Run the provisioning script (located inside the Vagrantfile), which installs the system packages, Ruby gems, and JS modules required for Mastodon
+- Run the startup script
+
+## Starting the server
+
+The Vagrant box will automatically start after provisioning. It can be started in future with `vagrant up` from the mastodon directory.
+
+Once the Ubuntu virtual machine has booted, it will run the startup script, which loads the environment variables from `.env.vagrant` and then runs `rails s -d -b 0.0.0.0`. This will start a Rails server. You can then access your development site at http://mastodon.dev (or at http://localhost:3000 if you haven't installed vagrants-hostupdater).
+
+To stop the server, simply run `vagrant halt`.
+
+## Using the server
+
+You should now have a working Mastodon instance, although it will not federate, as it is not publicly accessible. Should you need temporary federation for development and testing, see the Ngrok information in the [Development Guide](Development-guide.md).
+
+By default, your instance's ActionMailer will use "Letter Opener Web" for email. This means that any email that would normally be sent, will instead be stored, and accessible at http://mastodon.dev/letter_opener - you can use this to verify a registered user account.
+
+## Making changes/developing
+
+You are able to set environment variables, which are used for Mastodon configuration, by editing the `.env.vagrant` file. Any changes you make will take effect after a Vagrant restart.
+
+Vagrant has mounted your mastodon folder inside the virtual machine. This means that any change to the files in the folder(e.g. the Rails controllers or the React components in /app) should immediately take effect on the live server. This allows you to make and test changes, and create new commits, without ever needing to access the virtual machine.
+
+Should you need to access the virtual machine (for example, to manually restart the Rails process without restarting the box), run `vagrant ssh` from the mastodon folder. You will now be logged in as the `vagrant` user on the VirtualBox Ubuntu VM. You will want to `cd /vagrant` to see the app folder.
+
+## Debugging
+
+You can find the Rails server logs in in the `log` folder, which will often have the information you need.
+
+If your Mastodon instance or Vagrant box are really not behaving, you can re-run the provisioning process. Stop the box with `vagrant halt`, and then run `vagrant destroy` - this will delete the virtual machine. You may then run `vagrant up` to create a new box, and re-run provisioning.
+
+## Testing
+
+To run the `rspec` tests and `rubocop` style checker, you may either:
+
+* Install the relevant gems locally, or
+* SSH into the virtual machine, `cd /vagrant`, and then run the commands
+
+## Support/help
+
+If you are confused, or having any issues with the above, the Mastodon IRC channel ( irc.freenode.net #mastodon ) is a good place to find assistance.
\ No newline at end of file
diff --git a/docs/Specs-and-RFCs-used.md b/docs/Specs-and-RFCs-used.md
new file mode 100644
index 000000000..9bb1bb622
--- /dev/null
+++ b/docs/Specs-and-RFCs-used.md
@@ -0,0 +1,12 @@
+Specs and RFCs used
+===================
+
+* [OStatus](https://www.w3.org/community/ostatus/wiki/images/9/93/OStatus_1.0_Draft_2.pdf)
+* [Salmon](http://www.salmon-protocol.org/salmon-protocol-summary)
+* [Portable Contacts](https://web.archive.org/web/20160305010620/http://portablecontacts.net/draft-spec.html)
+* [Atom](https://tools.ietf.org/html/rfc4287)
+* [Atom ActivityStreams](http://activitystrea.ms/specs/atom/1.0/)
+* [Atom Threading](https://tools.ietf.org/html/rfc4685)
+* [PubSubHubbub](https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html)
+* [Webfinger](https://tools.ietf.org/html/rfc7033)
+* [Link-based Resource Descriptor Discovery](https://tools.ietf.org/html/rfc6415)
diff --git a/docs/Using-Mastodon/Apps.md b/docs/Using-Mastodon/Apps.md
new file mode 100644
index 000000000..e0a2730c1
--- /dev/null
+++ b/docs/Using-Mastodon/Apps.md
@@ -0,0 +1,15 @@
+List of apps
+============
+
+Some people have started working on apps for the Mastodon API. Here is a list of them:
+
+|App|Platform|Link|Developer(s)|
+|---|--------|----|------------|
+|Matodor|iOS/Android|<https://github.com/jeroensmeets/mastodon-app>|[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)|
+|Tusky|Android|<https://github.com/Vavassor/Tusky>|[@Vavassor@mastodon.social](https://mastodon.social/users/Vavassor)|
+|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)|
+|tootstream|command-line|<https://github.com/magicalraccoon/tootstream>|[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
+|mastodroid|Android|<https://github.com/alin-rautoiu/mastodroid>||
+|Tooter|Chrome extension|<https://github.com/ineffyble/tooter>|[@effy@mastodon.social](https://mastodon.social/users/effy)|
+
+If you have a project like this, let me know so I can add it to the list!
diff --git a/docs/Using-Mastodon/FAQ.md b/docs/Using-Mastodon/FAQ.md
new file mode 100644
index 000000000..daedcbdd8
--- /dev/null
+++ b/docs/Using-Mastodon/FAQ.md
@@ -0,0 +1,43 @@
+Frequently Asked Questions
+==========================
+
+#### What is a Mastodon?
+
+A prehistoric animal, predecessor of the mammoth.
+
+#### Why the name Mastodon?
+
+There's a progressive metal band with the same name that I'm a fan of that brought the animal to my attention. I thought it's a pretty cool name/animal.
+
+#### How exactly is it decentralized?
+
+There are different ways in which something can be decentralized; in this case, Mastodon is the "federated" kind. Think e-mail, not BitTorrent. There are different servers (instances), users have an account on one of them, but can interact and follow each other regardless of where their account is.
+
+#### Technically, how does the federation work?
+
+We are using the OStatus suite of protocols:
+
+1. Webfinger for user-on-domain lookup
+2. Atom feeds with ActivityStreams, Portable Contacts, Threads extensions for the actual content
+3. PubSubHubbub for subscribing to Atom feeds
+4. Salmon for delivering certain items from the Atom feeds to interested parties such as the mentioned user, author of the status being replied to, person being followed, etc
+
+#### What is mastodon.social?
+
+The "flagship" instance of Mastodon, aka the server I run myself with the latest code. It's not supposed to be the only instance in the end.
+
+#### What else is part of the federated network?
+
+Let's call it the "fediverse". It has existed for a longer while, populated by GNU social servers, Friendica, Hubzilla, Diaspora etc. Not every one of those servers is fully compatible with every other. Mastodon strives to be fully standards-compliant and compatibility with GNU social is higher in priority than the others.
+
+#### I tried logging into a GNU social client app with Mastodon and it didn't work, why?
+
+While Mastodon is compatible with GNU social in terms of server to server communication, the client to server API (aka how you access Mastodon) is different. Therefore, client apps that were made for specifically GNU social will not work with Mastodon. The reason for this is half technical, half ideological.
+
+Because Mastodon has been created from a blank slate, it is much simpler to have the API mirror internal structures as closely as possible, rather than build an emulation layer. Secondly, the GNU social client API is actually a half-way implementation of the legacy Twitter API - that's the reason why it works with some older Twitter client apps. However, many of those apps are not maintained anymore, the GNU social API does not actually keep up with the real Twitter API and never fully implemented all its features; at the same time, the Twitter API was never meant for a federated service and so obscures some of the functionality.
+
+#### How is Mastodon funded?
+
+Development of Mastodon and hosting of mastodon.social is funded through my [Patreon (also BTC/PayPal donations)](https://www.patreon.com/user?u=619786). Beyond that, I am not interested in VC funding, monetizing, advertising, or anything of that sort. I could offer setup/maintenance services on demand.
+
+The software is free and open source and communities should host their own servers if they can, that way the costs are more or less distributed. Obviously it'd be hard for me to pay the bills if literally everyone decided to use the mastodon.social instance only.
\ No newline at end of file
diff --git a/docs/Using-Mastodon/List-of-Mastodon-instances.md b/docs/Using-Mastodon/List-of-Mastodon-instances.md
new file mode 100644
index 000000000..2f15df083
--- /dev/null
+++ b/docs/Using-Mastodon/List-of-Mastodon-instances.md
@@ -0,0 +1,12 @@
+List of Mastodon instances
+==========================
+
+* [mastodon.social](https://mastodon.social)
+* [social.tchncs.de](https://social.tchncs.de)
+* [on.vu](https://on.vu)
+* [animalliberation.social](https://animalliberation.social)
+* [socially.constructed.space](https://socially.constructed.space)
+* [epiktistes.com](https://epiktistes.com)
+* [toot.zone](https://toot.zone)
+
+Let me know if you start running one so I can add it to the list!
diff --git a/docs/Using-the-API/API.md b/docs/Using-the-API/API.md
new file mode 100644
index 000000000..9f5280870
--- /dev/null
+++ b/docs/Using-the-API/API.md
@@ -0,0 +1,280 @@
+API overview
+============
+
+## Contents
+
+- [Available libraries](#available-libraries)
+- [Notes](#notes)
+- [Methods](#methods)
+  - Posting a status
+  - Uploading media
+  - Retrieving a timeline
+  - Retrieving notifications
+  - Following a remote user
+  - Fetching data
+  - Deleting a status
+  - Reblogging a status
+  - Favouriting a status
+  - Threads (status context)
+  - Who reblogged/favourited a status
+  - Following/unfollowing accounts
+  - Blocking/unblocking accounts
+  - Creating OAuth apps
+- [Entities](#entities)
+  - Status
+  - Account
+- [Pagination](#pagination)
+
+## Available libraries
+
+- [For Ruby](https://github.com/tootsuite/mastodon-api)
+- [For Python](https://github.com/halcy/Mastodon.py)
+- [For JavaScript](https://github.com/Zatnosk/libodonjs)
+- [For JavaScript (Node.js)](https://github.com/jessicahayley/node-mastodon)
+
+## Notes
+
+When an array parameter is mentioned, the Rails convention of specifying array parameters in query strings is meant. For example, a ruby array like `foo = [1, 2, 3]` can be encoded in the params as `foo[]=1&foo[]=2&foo[]=3`. Square brackets can be indexed but can also be empty.
+
+When a file parameter is mentioned, a form-encoded upload is expected.
+
+## Methods
+### Posting a new status
+
+**POST /api/v1/statuses**
+
+Form data:
+
+- `status`: The text of the status
+- `in_reply_to_id` (optional): local ID of the status you want to reply to
+- `media_ids` (optional): array of media IDs to attach to the status (maximum 4)
+- `sensitive` (optional): set this to mark the media of the status as NSFW
+- `visibility` (optional): either `private`, `unlisted` or `public`
+
+Returns the new status.
+
+**POST /api/v1/media**
+
+Form data:
+
+- `file`: Image to be uploaded
+
+Returns a media object with an ID that can be attached when creating a status (see above).
+
+### Retrieving a timeline
+
+**GET /api/v1/timelines/home**
+**GET /api/v1/timelines/mentions**
+**GET /api/v1/timelines/public**
+**GET /api/v1/timelines/tag/:hashtag**
+
+Returns statuses, most recent ones first. Home timeline is statuses from people you follow, mentions timeline is all statuses that mention you. Public timeline is "whole known network", and the last is the hashtag timeline.
+
+Query parameters:
+
+- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
+- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
+
+### Notifications
+
+**GET /api/v1/notifications**
+
+Returns notifications for the authenticated user. Each notification has an `id`, a `type` (mention, reblog, favourite, follow), an `account` which it came *from*, and in case of mention, reblog and favourite also a `status`.
+
+### Following a remote user
+
+**POST /api/v1/follows**
+
+Form data:
+
+- uri: username@domain of the person you want to follow
+
+Returns the local representation of the followed account.
+
+### Fetching data
+
+**GET /api/v1/statuses/:id**
+
+Returns status.
+
+**GET /api/v1/accounts/:id**
+
+Returns account.
+
+**GET /api/v1/accounts/verify_credentials**
+
+Returns authenticated user's account.
+
+**GET /api/v1/accounts/:id/statuses**
+
+Returns statuses by user. Same options as timeline are permitted.
+
+**GET /api/v1/accounts/:id/following**
+
+Returns users the given user is following.
+
+**GET /api/v1/accounts/:id/followers**
+
+Returns users the given user is followed by.
+
+**GET /api/v1/accounts/relationships**
+
+Returns relationships (`following`, `followed_by`, `blocking`) of the current user to a list of given accounts.
+
+Query parameters:
+
+- `id` (can be array): Account IDs
+
+**GET /api/v1/accounts/search**
+
+Returns matching accounts. Will lookup an account remotely if the search term is in the username@domain format and not yet in the database.
+
+Query parameters:
+
+- `q`: what to search for
+- `limit`: maximum number of matching accounts to return
+
+**GET /api/v1/blocks**
+
+Returns accounts blocked by authenticated user.
+
+**GET /api/v1/favourites**
+
+Returns statuses favourited by authenticated user.
+
+### Deleting a status
+
+**DELETE /api/v1/statuses/:id**
+
+Returns an empty object.
+
+### Reblogging a status
+
+**POST /api/v1/statuses/:id/reblog**
+
+Returns a new status that wraps around the reblogged one.
+
+### Unreblogging a status
+
+**POST /api/v1/statuses/:id/unreblog**
+
+Returns the status that used to be reblogged.
+
+### Favouriting a status
+
+**POST /api/v1/statuses/:id/favourite**
+
+Returns the target status.
+
+### Unfavouriting a status
+
+**POST /api/v1/statuses/:id/unfavourite**
+
+Returns the target status.
+
+### Threads
+
+**GET /api/v1/statuses/:id/context**
+
+Returns `ancestors` and `descendants` of the status.
+
+### Who reblogged/favourited a status
+
+**GET /api/v1/statuses/:id/reblogged_by**
+**GET /api/v1/statuses/:id/favourited_by**
+
+Returns list of accounts.
+
+### Following and unfollowing users
+
+**POST /api/v1/accounts/:id/follow**
+**POST /api/v1/accounts/:id/unfollow**
+
+Returns the updated relationship to the user.
+
+### Blocking and unblocking users
+
+**POST /api/v1/accounts/:id/block**
+**POST /api/v1/accounts/:id/unblock**
+
+Returns the updated relationship to the user.
+
+### OAuth apps
+
+**POST /api/v1/apps**
+
+Form data:
+
+- `client_name`: Name of your application
+- `redirect_uris`: Where the user should be redirected after authorization (for no redirect, use `urn:ietf:wg:oauth:2.0:oob`)
+- `scopes`: This can be a space-separated list of the following items: "read", "write" and "follow" (see [this page](OAuth-details.md) for details on what the scopes do)
+- `website`: (optional) URL to the homepage of your app
+
+Creates a new OAuth app. Returns `id`, `client_id` and `client_secret` which can be used with [OAuth authentication in your 3rd party app](Testing-with-cURL.md).
+
+___
+
+## Entities
+
+### Status
+
+| Attribute           | Description |
+|---------------------|-------------|
+| `id`                ||
+| `uri`               | fediverse-unique resource ID |
+| `url`               | URL to the status page (can be remote) |
+| `account`           | Account |
+| `in_reply_to_id`    | null or ID of status it replies to |
+| `reblog`            | null or Status|
+| `content`           | Body of the status. This will contain HTML (remote HTML already sanitized) |
+| `created_at`        ||
+| `reblogs_count`     ||
+| `favourites_count`  ||
+| `reblogged`         | Boolean for authenticated user |
+| `favourited`        | Boolean for authenticated user |
+| `media_attachments` | array of MediaAttachments |
+| `mentions`          | array of Mentions |
+| `application`       | Application from which the status was posted |
+
+Media Attachment:
+
+| Attribute           | Description |
+|---------------------|-------------|
+| `url`               | URL of the original image (can be remote) |
+| `preview_url`       | URL of the preview image |
+| `type`              | Image or video |
+
+Mention:
+
+| Attribute           | Description |
+|---------------------|-------------|
+| `url`               | URL of user's profile (can be remote) |
+| `acct`              | Username for local or username@domain for remote users |
+| `id`                | Account ID |
+
+Application:
+
+| Attribute           | Description |
+|---------------------|-------------|
+| `name`              | Name of the app |
+| `website`           | Homepage URL of the app |
+
+### Account
+
+| Attribute         | Description |
+|-------------------|-------------|
+| `id`              ||
+| `username`        ||
+| `acct`            | Equals username for local users, includes @domain for remote ones |
+| `display_name`    ||
+| `note`            | Biography of user |
+| `url`             | URL of the user's profile page (can be remote) |
+| `avatar`          | URL to the avatar image |
+| `header`          | URL to the header image |
+| `followers_count` ||
+| `following_count` ||
+| `statuses_count`  ||
+
+## Pagination
+
+API methods that return collections of items can return a `Link` header containing URLs for the `next` and `prev` pages. [Link header RFC](https://tools.ietf.org/html/rfc5988)
diff --git a/docs/Using-the-API/OAuth-details.md b/docs/Using-the-API/OAuth-details.md
new file mode 100644
index 000000000..d0b5abd40
--- /dev/null
+++ b/docs/Using-the-API/OAuth-details.md
@@ -0,0 +1,12 @@
+OAuth details
+=============
+
+We use the [Doorkeeper gem for OAuth](https://github.com/doorkeeper-gem/doorkeeper/wiki), so you can refer to their docs on specifics of the end-points.
+
+The API is divided up into access scopes:
+
+- `read`: Read data
+- `write`: Post statuses and upload media for statuses
+- `follow`: Follow, unfollow, block, unblock
+
+Multiple scopes can be requested during the authorization phase with the `scope` query param (space-separate the scopes).
diff --git a/docs/Using-the-API/Testing-with-cURL.md b/docs/Using-the-API/Testing-with-cURL.md
new file mode 100644
index 000000000..977773a08
--- /dev/null
+++ b/docs/Using-the-API/Testing-with-cURL.md
@@ -0,0 +1,16 @@
+Testing the API with cURL
+=========================
+
+Mastodon builds around the idea of being a server first, rather than a client itself. Similarly to how a XMPP chat server communicates with others and with its own clients, Mastodon takes care of federation to other networks, like other Mastodon or GNU Social instances. So Mastodon provides a REST API, and a 3rd-party app system for using it via OAuth2.
+
+You can get a client ID and client secret required for OAuth [via an API end-point](API.md#oauth-apps).
+
+From these two, you will need to acquire an access token. It is possible to do using your account's e-mail and password like this:
+
+    curl -X POST -d "client_id=CLIENT_ID_HERE&client_secret=CLIENT_SECRET_HERE&grant_type=password&username=YOUR_EMAIL&password=YOUR_PASSWORD" -Ss https://mastodon.social/oauth/token
+
+The response will be a JSON object containing the key `access_token`. Use that token in any API requests by setting a header like this:
+
+    curl --header "Authorization: Bearer ACCESS_TOKEN_HERE" -sS https://mastodon.social/api/statuses/home
+
+Please note that the password-based approach is not recommended especially if you're dealing with other user's accounts and not just your own. Usually you would use the authorization grant approach where you redirect the user to a web page on the original site where they can login and authorize the application and are then redirected back to your application with an access code.
diff --git a/docs/Using-the-API/Tips-for-app-developers.md b/docs/Using-the-API/Tips-for-app-developers.md
new file mode 100644
index 000000000..561f1e273
--- /dev/null
+++ b/docs/Using-the-API/Tips-for-app-developers.md
@@ -0,0 +1,16 @@
+Tips for app developers
+=======================
+
+## Authentication
+
+Make sure that you allow your users to specify the domain they want to connect to before login. Use that domain to acquire a client id/secret for OAuth2 and then proceed with normal OAuth2 also using that domain to build the URLs.
+
+In my opinion it is easier for people to understand what is being asked of them if you ask for a `username@domain` type input, since it looks like an e-mail address. Though the username part is not required for anything in the OAuth2 process. Once the user is logged in, you get information about the logged in user from `/api/v1/accounts/verify_credentials`
+
+## Usernames
+
+Make sure that you make it possible to see the `acct` of any user in your app (since it includes the domain part for remote users), people must be able to tell apart users from different domains with the same username.
+
+## Formatting
+
+The API delivers already formatted HTML to your app. This isn't ideal since not all apps are based on HTML, but this is not fixable as its part of the way OStatus federation works. Most importantly, you get some information on linked entities alongside the HTML of the status body. For example, you get a list of mentioned users, and a list of media attachments, and a list of hashtags. It is possible to convert the HTML to whatever you need in your app by parsing the HTML tags and matching their `href`s to the linked entities. If a match cannot be found, the link must stay a clickable link.
diff --git a/lib/statsd_monitor.rb b/lib/statsd_monitor.rb
new file mode 100644
index 000000000..e48ce6541
--- /dev/null
+++ b/lib/statsd_monitor.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class StatsDMonitor
+  def initialize(app)
+    @app = app
+  end
+
+  def call(env)
+    @app.call(env)
+  end
+end
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index a95a7258f..13220f68e 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -6,6 +6,11 @@ namespace :mastodon do
     task clear: :environment do
       MediaAttachment.where(status_id: nil).where('created_at < ?', 1.day.ago).find_each(&:destroy)
     end
+
+    desc 'Remove media attachments attributed to silenced accounts'
+    task remove_silenced: :environment do
+      MediaAttachment.where(account: Account.silenced).find_each(&:destroy)
+    end
   end
 
   namespace :push do
diff --git a/package.json b/package.json
index 05663a729..dbcc032c6 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
     "test": "mocha --require ./spec/javascript/setup.js --compilers js:babel-register ./spec/javascript/components/*.test.jsx",
     "storybook": "start-storybook -p 9001 -c storybook"
   },
-  "devDependencies": {
+  "dependencies": {
     "@kadira/storybook": "^2.24.0",
     "axios": "^0.14.0",
     "babel-plugin-react-transform": "^2.0.2",
@@ -18,7 +18,7 @@
     "chai": "^3.5.0",
     "chai-enzyme": "^0.5.2",
     "css-loader": "^0.26.1",
-    "emojione": "^2.2.6",
+    "emojione": "latest",
     "enzyme": "^2.4.1",
     "es6-promise": "^3.2.1",
     "http-link-header": "^0.5.0",
@@ -34,24 +34,27 @@
     "react-autosuggest": "^7.0.1",
     "react-decoration": "^1.4.0",
     "react-dom": "^15.3.0",
+    "react-imageloader": "^2.1.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": "^5.0.1",
     "react-redux-loading-bar": "^2.4.1",
     "react-router": "^2.8.0",
     "react-router-scroll": "^0.3.2",
     "react-simple-dropdown": "^1.1.4",
     "react-storybook-addon-intl": "^0.1.0",
     "react-toggle": "^2.1.1",
-    "redux": "^3.5.2",
+    "redux": "^3.6.0",
     "redux-immutable": "^3.0.8",
+    "redux-sounds": "^1.1.1",
     "redux-thunk": "^2.1.0",
     "reselect": "^2.5.4",
     "sass-loader": "^4.0.2",
     "sinon": "^1.17.6",
-    "style-loader": "^0.13.1"
+    "style-loader": "^0.13.1",
+    "webpack": "^1.14.0"
   }
 }
diff --git a/public/404.html b/public/404.html
deleted file mode 100644
index eecfd6743..000000000
--- a/public/404.html
+++ /dev/null
@@ -1,43 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-  <meta charset="utf-8">
-  <title>The page you were looking for doesn't exist</title>
-  <meta name="viewport" content="width=device-width,initial-scale=1">
-  <link href="https://fonts.googleapis.com/css?family=Roboto:400" rel="stylesheet">
-  <style>
-    body {
-      font-family: 'Roboto', sans-serif;
-      background: #282c37;
-      color: #9baec8;
-      text-align: center;
-      margin: 0;
-      padding: 20px;
-    }
-
-    .dialog img {
-      display: block;
-      margin: 20px auto;
-      margin-top: 50px;
-      max-width: 600px;
-      width: 100%;
-      height: auto;
-    }
-
-    .dialog h1 {
-      font: 20px/28px 'Roboto', sans-serif;
-      font-weight: 400;
-    }
-  </style>
-</head>
-
-<body>
-  <div class="dialog">
-    <img src="/oops.png" alt="Mastodon" />
-
-    <div>
-      <h1>The page you were looking for doesn't exist</h1>
-    </div>
-  </div>
-</body>
-</html>
diff --git a/public/500.html b/public/500.html
index 915b890f1..d085d490b 100644
--- a/public/500.html
+++ b/public/500.html
@@ -7,7 +7,7 @@
   <link href="https://fonts.googleapis.com/css?family=Roboto:400" rel="stylesheet">
   <style>
     body {
-      font-family: 'Roboto', sans-serif;
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
       background: #282c37;
       color: #9baec8;
       text-align: center;
@@ -25,7 +25,7 @@
     }
 
     .dialog h1 {
-      font: 20px/28px 'Roboto', sans-serif;
+      font: 20px/28px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
       font-weight: 400;
     }
   </style>
diff --git a/public/headers/original/missing.png b/public/headers/original/missing.png
new file mode 100644
index 000000000..fdc34289d
--- /dev/null
+++ b/public/headers/original/missing.png
Binary files differdiff --git a/public/sounds/boop.mp3 b/public/sounds/boop.mp3
new file mode 100644
index 000000000..02a035d91
--- /dev/null
+++ b/public/sounds/boop.mp3
Binary files differdiff --git a/spec/controllers/api/oembed_controller_spec.rb b/spec/controllers/api/oembed_controller_spec.rb
index 758bfd1da..511cdb463 100644
--- a/spec/controllers/api/oembed_controller_spec.rb
+++ b/spec/controllers/api/oembed_controller_spec.rb
@@ -1,5 +1,16 @@
 require 'rails_helper'
 
-RSpec.describe Api::OembedController, type: :controller do
+RSpec.describe Api::OEmbedController, type: :controller do
+  let(:alice)  { Fabricate(:account, username: 'alice') }
+  let(:status) { Fabricate(:status, text: 'Hello world', account: alice) }
 
+  describe 'GET #show' do
+    before do
+      get :show, params: { url: account_stream_entry_url(alice, status.stream_entry) }, format: :json
+    end
+
+    it 'returns http success' do
+      expect(response).to have_http_status(:success)
+    end
+  end
 end
diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb
index d9c73f952..669956659 100644
--- a/spec/controllers/api/v1/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses_controller_spec.rb
@@ -4,7 +4,8 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
   render_views
 
   let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-  let(:token) { double acceptable?: true, resource_owner_id: user.id }
+  let(:app)   { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
+  let(:token) { double acceptable?: true, resource_owner_id: user.id, application: app }
 
   before do
     allow(controller).to receive(:doorkeeper_token) { token }
diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb
index f7ebebbcb..27ad6cbde 100644
--- a/spec/controllers/auth/registrations_controller_spec.rb
+++ b/spec/controllers/auth/registrations_controller_spec.rb
@@ -20,8 +20,8 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
       post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678' } }
     end
 
-    it 'redirects to home page' do
-      expect(response).to redirect_to root_path
+    it 'redirects to login page' do
+      expect(response).to redirect_to new_user_session_path
     end
 
     it 'creates user' do
diff --git a/spec/fabricators/application_fabricator.rb b/spec/fabricators/application_fabricator.rb
new file mode 100644
index 000000000..42b7009dc
--- /dev/null
+++ b/spec/fabricators/application_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:application, from: Doorkeeper::Application) do
+  name         'Example'
+  website      'http://example.com'
+  redirect_uri 'http://example.com/callback'
+end
diff --git a/spec/fabricators/media_attachment_fabricator.rb b/spec/fabricators/media_attachment_fabricator.rb
index b1a0cd991..59db2440d 100644
--- a/spec/fabricators/media_attachment_fabricator.rb
+++ b/spec/fabricators/media_attachment_fabricator.rb
@@ -1,2 +1,3 @@
 Fabricator(:media_attachment) do
+
 end
diff --git a/spec/fabricators/preview_card_fabricator.rb b/spec/fabricators/preview_card_fabricator.rb
new file mode 100644
index 000000000..448a94e7e
--- /dev/null
+++ b/spec/fabricators/preview_card_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:preview_card) do
+  status_id 1
+  url       "MyString"
+  html      "MyText"
+end
diff --git a/spec/fabricators/web_setting_fabricator.rb b/spec/fabricators/web_setting_fabricator.rb
new file mode 100644
index 000000000..e5136829b9
--- /dev/null
+++ b/spec/fabricators/web_setting_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator('Web::Setting') do
+
+end
diff --git a/spec/fixtures/xml/mastodon.atom b/spec/fixtures/xml/mastodon.atom
index ce28cd77b..9ece3bc2e 100644
--- a/spec/fixtures/xml/mastodon.atom
+++ b/spec/fixtures/xml/mastodon.atom
@@ -107,14 +107,14 @@
         <uri>https://mastodon.social/users/Gargron</uri>
         <name>Gargron</name>
         <email>Gargron@mastodon.social</email>
-        <summary>Developer of Mastodon, a GNU social alternative: https://github.com/Gargron/mastodon</summary>
+        <summary>Developer of Mastodon, a GNU social alternative: https://github.com/tootsuite/mastodon</summary>
         <link rel="alternate" type="text/html" href="https://mastodon.social/users/Gargron"/>
         <link rel="avatar" type="image/png" media:width="300" media:height="300" href="http://kickass.zone/system/accounts/avatars/000/000/003/large/4375_eugencommish.png"/>
         <link rel="avatar" type="image/png" media:width="96" media:height="96" href="http://kickass.zone/system/accounts/avatars/000/000/003/medium/4375_eugencommish.png"/>
         <link rel="avatar" type="image/png" media:width="48" media:height="48" href="http://kickass.zone/system/accounts/avatars/000/000/003/small/4375_eugencommish.png"/>
         <poco:preferredUsername>Gargron</poco:preferredUsername>
         <poco:displayName>Eugen</poco:displayName>
-        <poco:note>Developer of Mastodon, a GNU social alternative: https://github.com/Gargron/mastodon</poco:note>
+        <poco:note>Developer of Mastodon, a GNU social alternative: https://github.com/tootsuite/mastodon</poco:note>
       </author>
     </activity:object>
   </entry>
@@ -192,14 +192,14 @@
       <uri>https://mastodon.social/users/Gargron</uri>
       <name>Gargron</name>
       <email>Gargron@mastodon.social</email>
-      <summary>Developer of Mastodon, a GNU social alternative: https://github.com/Gargron/mastodon</summary>
+      <summary>Developer of Mastodon, a GNU social alternative: https://github.com/tootsuite/mastodon</summary>
       <link rel="alternate" type="text/html" href="https://mastodon.social/users/Gargron"/>
       <link rel="avatar" type="image/png" media:width="300" media:height="300" href="http://kickass.zone/system/accounts/avatars/000/000/003/large/4375_eugencommish.png"/>
       <link rel="avatar" type="image/png" media:width="96" media:height="96" href="http://kickass.zone/system/accounts/avatars/000/000/003/medium/4375_eugencommish.png"/>
       <link rel="avatar" type="image/png" media:width="48" media:height="48" href="http://kickass.zone/system/accounts/avatars/000/000/003/small/4375_eugencommish.png"/>
       <poco:preferredUsername>Gargron</poco:preferredUsername>
       <poco:displayName>Eugen</poco:displayName>
-      <poco:note>Developer of Mastodon, a GNU social alternative: https://github.com/Gargron/mastodon</poco:note>
+      <poco:note>Developer of Mastodon, a GNU social alternative: https://github.com/tootsuite/mastodon</poco:note>
     </activity:object>
   </entry>
   <entry>
diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb
index e7126127e..d982b9dca 100644
--- a/spec/i18n_spec.rb
+++ b/spec/i18n_spec.rb
@@ -6,12 +6,12 @@ RSpec.describe 'I18n' do
   let(:missing_keys) { i18n.missing_keys }
   let(:unused_keys) { i18n.unused_keys }
 
-  it 'does not have missing keys' do
+  xit 'does not have missing keys' do
     expect(missing_keys).to be_empty,
       "Missing #{missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them"
   end
 
-  it 'does not have unused keys' do
+  xit 'does not have unused keys' do
     expect(unused_keys).to be_empty,
       "#{unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them"
   end
diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb
index 7b8259fa6..6ec28f5d8 100644
--- a/spec/lib/formatter_spec.rb
+++ b/spec/lib/formatter_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Formatter do
     end
 
     it 'contains a link' do
-      expect(subject).to match('<a rel="nofollow noopener" target="_blank" href="http://google.com">google.com</a>')
+      expect(subject).to match('<a rel="nofollow noopener" target="_blank" href="http://google.com"><span class="invisible">http://</span><span class="ellipsis">google.com</span><span class="invisible"></span></a>')
     end
   end
 
diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb
index d4baca5aa..3beaebeb1 100644
--- a/spec/mailers/notification_mailer_spec.rb
+++ b/spec/mailers/notification_mailer_spec.rb
@@ -53,12 +53,12 @@ RSpec.describe NotificationMailer, type: :mailer do
     let(:mail) { NotificationMailer.reblog(own_status.account, Notification.create!(account: receiver.account, activity: reblog)) }
 
     it "renders the headers" do
-      expect(mail.subject).to eq("bob reblogged your status")
+      expect(mail.subject).to eq("bob boosted your status")
       expect(mail.to).to eq([receiver.email])
     end
 
     it "renders the body" do
-      expect(mail.body.encoded).to match("Your status was reblogged by bob")
+      expect(mail.body.encoded).to match("Your status was boosted by bob")
     end
   end
 
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index a72369b1c..287f389ac 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -154,6 +154,31 @@ RSpec.describe Account, type: :model do
     end
   end
 
+  describe '.following_map' do
+    it 'returns an hash' do
+      expect(Account.following_map([], 1)).to be_a Hash
+    end
+  end
+
+  describe '.followed_by_map' do
+    it 'returns an hash' do
+      expect(Account.followed_by_map([], 1)).to be_a Hash
+    end
+  end
+
+  describe '.blocking_map' do
+    it 'returns an hash' do
+      expect(Account.blocking_map([], 1)).to be_a Hash
+    end
+  end
+
+  describe '.requested_map' do
+    it 'returns an hash' do
+      expect(Account.requested_map([], 1)).to be_a Hash
+    end
+  end
+
+
   describe 'MENTION_RE' do
     subject { Account::MENTION_RE }
 
diff --git a/spec/models/preview_card_spec.rb b/spec/models/preview_card_spec.rb
new file mode 100644
index 000000000..14ef23923
--- /dev/null
+++ b/spec/models/preview_card_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe PreviewCard, type: :model do
+
+end
diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb
index d40bf0b44..9cb3d41ce 100644
--- a/spec/models/subscription_spec.rb
+++ b/spec/models/subscription_spec.rb
@@ -1,5 +1,5 @@
 require 'rails_helper'
 
 RSpec.describe Subscription, type: :model do
-  pending "add some examples to (or delete) #{__FILE__}"
+
 end
diff --git a/spec/models/web/setting_spec.rb b/spec/models/web/setting_spec.rb
new file mode 100644
index 000000000..90e7695aa
--- /dev/null
+++ b/spec/models/web/setting_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Web::Setting, type: :model do
+
+end
diff --git a/spec/services/block_domain_service_spec.rb b/spec/services/block_domain_service_spec.rb
index 9933d016f..d88b3b55c 100644
--- a/spec/services/block_domain_service_spec.rb
+++ b/spec/services/block_domain_service_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe BlockDomainService do
     bad_status2
     bad_attachment
 
-    subject.call('evil.org')
+    subject.call('evil.org', :suspend)
   end
 
   it 'creates a domain block' do
@@ -22,7 +22,7 @@ RSpec.describe BlockDomainService do
   end
 
   it 'removes remote accounts from that domain' do
-    expect(Account.find_remote('badguy666', 'evil.org')).to be_nil
+    expect(Account.find_remote('badguy666', 'evil.org').suspended?).to be true
   end
 
   it 'removes the remote accounts\'s statuses and media attachments' do
diff --git a/storybook/storybook.scss b/storybook/storybook.scss
index b0145f9bd..31f11b5ad 100644
--- a/storybook/storybook.scss
+++ b/storybook/storybook.scss
@@ -2,7 +2,7 @@
 @import url(https://fonts.googleapis.com/css?family=Roboto+Mono:400,500);
 
 #root {
-  font-family: 'Roboto', sans-serif;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
   background: #282c37;
   font-size: 13px;
   line-height: 18px;
diff --git a/yarn.lock b/yarn.lock
index f71a8ae10..ee3e57783 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -97,10 +97,6 @@
     webpack-dev-middleware "^1.6.0"
     webpack-hot-middleware "^2.10.0"
 
-Base64@~0.2.0:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/Base64/-/Base64-0.2.1.tgz#ba3a4230708e186705065e66babdd4c35cf60028"
-
 JSONStream@^0.10.0:
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-0.10.0.tgz#74349d0d89522b71f30f0a03ff9bd20ca6f12ac0"
@@ -1124,6 +1120,12 @@ browser-stdout@1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f"
 
+browserify-aes@0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-0.4.0.tgz#067149b668df31c4b58533e02d01e806d8608e2c"
+  dependencies:
+    inherits "^2.0.1"
+
 browserify-aes@^1.0.0, browserify-aes@^1.0.4:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.6.tgz#5e7725dbdef1fd5930d4ebab48567ce451c48a0a"
@@ -1186,7 +1188,7 @@ browserify-sign@^4.0.0:
     inherits "^2.0.1"
     parse-asn1 "^5.0.0"
 
-browserify-zlib@~0.1.2, browserify-zlib@~0.1.4:
+browserify-zlib@^0.1.4, browserify-zlib@~0.1.2:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d"
   dependencies:
@@ -1516,11 +1518,7 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
 
-constants-browserify@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-0.0.1.tgz#92577db527ba6c4cf0a4568d84bc031f441e21f2"
-
-constants-browserify@~1.0.0:
+constants-browserify@^1.0.0, constants-browserify@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
 
@@ -1596,6 +1594,15 @@ cryptiles@2.x.x:
   dependencies:
     boom "2.x.x"
 
+crypto-browserify@3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.3.0.tgz#b9fc75bb4a0ed61dcf1cd5dae96eb30c9c3e506c"
+  dependencies:
+    browserify-aes "0.4.0"
+    pbkdf2-compat "2.0.1"
+    ripemd160 "0.2.0"
+    sha.js "2.2.6"
+
 crypto-browserify@^3.0.0:
   version "3.11.0"
   resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.0.tgz#3652a0906ab9b2a7e0c3ce66a408e957a2485522"
@@ -1611,14 +1618,6 @@ crypto-browserify@^3.0.0:
     public-encrypt "^4.0.0"
     randombytes "^2.0.0"
 
-crypto-browserify@~3.2.6:
-  version "3.2.8"
-  resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.2.8.tgz#b9b11dbe6d9651dd882a01e6cc467df718ecf189"
-  dependencies:
-    pbkdf2-compat "2.0.1"
-    ripemd160 "0.2.0"
-    sha.js "2.2.6"
-
 css-color-names@0.0.4:
   version "0.0.4"
   resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
@@ -1935,9 +1934,9 @@ elliptic@^6.0.0:
     hash.js "^1.0.0"
     inherits "^2.0.1"
 
-emojione:
-  version "2.2.6"
-  resolved "https://registry.yarnpkg.com/emojione/-/emojione-2.2.6.tgz#67dec452937d5b14ee669207ea41cdb1f69fb8f6"
+emojione@latest:
+  version "2.2.7"
+  resolved "https://registry.yarnpkg.com/emojione/-/emojione-2.2.7.tgz#46457cf6b9b2f8da13ae8a2e4e547de06ee15e96"
 
 emojis-list@^2.0.0:
   version "2.1.0"
@@ -2368,7 +2367,7 @@ glob-parent@^2.0.0:
   dependencies:
     is-glob "^2.0.0"
 
-glob@7.0.5, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5:
+glob@7.0.5, glob@^7.0.0, glob@^7.0.3:
   version "7.0.5"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.5.tgz#b4202a69099bbb4d292a7c1b95b6682b67ebdc95"
   dependencies:
@@ -2389,7 +2388,7 @@ glob@^5.0.15:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@~7.1.1:
+glob@^7.0.5, glob@~7.1.1:
   version "7.1.1"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8"
   dependencies:
@@ -2500,6 +2499,10 @@ hosted-git-info@^2.1.4:
   version "2.1.5"
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.1.5.tgz#0ba81d90da2e25ab34a332e6ec77936e1598118b"
 
+howler@^1.1.28:
+  version "1.1.29"
+  resolved "https://registry.yarnpkg.com/howler/-/howler-1.1.29.tgz#9a3a7fa69e9b9d805c65ad98f66e35893a597b63"
+
 html-comment-regex@^1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
@@ -2528,13 +2531,6 @@ htmlparser2@~3.8.1:
     entities "1.0"
     readable-stream "1.1"
 
-http-browserify@^1.3.2:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/http-browserify/-/http-browserify-1.7.0.tgz#33795ade72df88acfbfd36773cefeda764735b20"
-  dependencies:
-    Base64 "~0.2.0"
-    inherits "~2.0.1"
-
 http-errors@~1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.5.0.tgz#b1cb3d8260fd8e2386cad3189045943372d48211"
@@ -2555,11 +2551,7 @@ http-signature@~1.1.0:
     jsprim "^1.2.2"
     sshpk "^1.7.0"
 
-https-browserify@0.0.0:
-  version "0.0.0"
-  resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.0.tgz#b3ffdfe734b2a3d4a9efd58e8654c91fce86eafd"
-
-https-browserify@~0.0.0:
+https-browserify@0.0.1, https-browserify@~0.0.0:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82"
 
@@ -3430,32 +3422,32 @@ node-gyp@^3.3.1:
     tar "^2.0.0"
     which "1"
 
-node-libs-browser@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-0.6.0.tgz#244806d44d319e048bc8607b5cc4eaf9a29d2e3c"
+node-libs-browser@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-0.7.0.tgz#3e272c0819e308935e26674408d7af0e1491b83b"
   dependencies:
     assert "^1.1.1"
-    browserify-zlib "~0.1.4"
+    browserify-zlib "^0.1.4"
     buffer "^4.9.0"
     console-browserify "^1.1.0"
-    constants-browserify "0.0.1"
-    crypto-browserify "~3.2.6"
+    constants-browserify "^1.0.0"
+    crypto-browserify "3.3.0"
     domain-browser "^1.1.1"
     events "^1.0.0"
-    http-browserify "^1.3.2"
-    https-browserify "0.0.0"
-    os-browserify "~0.1.2"
+    https-browserify "0.0.1"
+    os-browserify "^0.2.0"
     path-browserify "0.0.0"
     process "^0.11.0"
     punycode "^1.2.4"
-    querystring-es3 "~0.2.0"
-    readable-stream "^1.1.13"
-    stream-browserify "^1.0.0"
-    string_decoder "~0.10.25"
-    timers-browserify "^1.0.1"
+    querystring-es3 "^0.2.0"
+    readable-stream "^2.0.5"
+    stream-browserify "^2.0.1"
+    stream-http "^2.3.1"
+    string_decoder "^0.10.25"
+    timers-browserify "^2.0.2"
     tty-browserify "0.0.0"
-    url "~0.10.1"
-    util "~0.10.3"
+    url "^0.11.0"
+    util "^0.10.3"
     vm-browserify "0.0.4"
 
 node-pre-gyp@^0.6.29:
@@ -3663,7 +3655,11 @@ optionator@^0.8.1:
     type-check "~0.3.2"
     wordwrap "~1.0.0"
 
-os-browserify@~0.1.1, os-browserify@~0.1.2:
+os-browserify@^0.2.0:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f"
+
+os-browserify@~0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.1.2.tgz#49ca0293e0b19590a5f5de10c7f265a617d8fe54"
 
@@ -4133,7 +4129,7 @@ query-string@^4.1.0:
     object-assign "^4.1.0"
     strict-uri-encode "^1.0.0"
 
-querystring-es3@~0.2.0:
+querystring-es3@^0.2.0, querystring-es3@~0.2.0:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
 
@@ -4233,6 +4229,10 @@ react-fuzzy@^0.3.3:
     classnames "^2.2.3"
     fuse.js "^2.2.0"
 
+react-imageloader@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/react-imageloader/-/react-imageloader-2.1.0.tgz#a58401970b3282386aeb810c43175165634f6308"
+
 react-immutable-proptypes@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4"
@@ -4301,9 +4301,9 @@ react-redux@^4.4.5:
     lodash "^4.2.0"
     loose-envify "^1.1.0"
 
-react-redux@^5.0.0-beta.3:
-  version "5.0.0-beta.3"
-  resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.0-beta.3.tgz#d50bfb00799cf7d2a9fd55fe34d6b3ecc24d3072"
+react-redux@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.1.tgz#84a41bd4cdd180452bb6922bc79ad25bd5abb7c4"
   dependencies:
     hoist-non-react-statics "^1.0.3"
     invariant "^2.0.0"
@@ -4388,7 +4388,7 @@ read-pkg@^1.0.0:
     normalize-package-data "^2.3.2"
     path-type "^1.0.0"
 
-readable-stream@1.1, readable-stream@^1.0.27-1, readable-stream@^1.1.13:
+readable-stream@1.1:
   version "1.1.14"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
   dependencies:
@@ -4397,7 +4397,7 @@ readable-stream@1.1, readable-stream@^1.0.27-1, readable-stream@^1.1.13:
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-"readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.0, readable-stream@~2.1.4:
+"readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.1.0, readable-stream@~2.1.4:
   version "2.1.5"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0"
   dependencies:
@@ -4470,6 +4470,12 @@ redux-immutable@^3.0.8:
   dependencies:
     immutable "^3.7.6"
 
+redux-sounds@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/redux-sounds/-/redux-sounds-1.1.1.tgz#7a31052dbc617d419c53056215865762f44adb7e"
+  dependencies:
+    howler "^1.1.28"
+
 redux-thunk@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.1.0.tgz#c724bfee75dbe352da2e3ba9bc14302badd89a98"
@@ -4689,6 +4695,10 @@ set-immediate-shim@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
 
+setimmediate@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
+
 setprototypeof@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.1.tgz#52009b27888c4dc48f591949c0a8275834c1ca7e"
@@ -4780,10 +4790,14 @@ sortobject@^1.0.0:
   dependencies:
     editions "^1.1.1"
 
-source-list-map@^0.1.4, source-list-map@~0.1.0:
+source-list-map@^0.1.4:
   version "0.1.6"
   resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.6.tgz#e1e6f94f0b40c4d28dcf8f5b8766e0e45636877f"
 
+source-list-map@~0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.7.tgz#d4b5ce2a46535c72c7e8527c71a77d250618172e"
+
 source-map-support@^0.4.2:
   version "0.4.3"
   resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.3.tgz#693c8383d4389a4569486987c219744dfc601685"
@@ -4854,14 +4868,7 @@ stackframe@^0.3.1:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.0.tgz#8e55758cb20e7682c1f4fce8dcab30bf01d1e07a"
 
-stream-browserify@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-1.0.0.tgz#bf9b4abfb42b274d751479e44e0ff2656b6f1193"
-  dependencies:
-    inherits "~2.0.1"
-    readable-stream "^1.0.27-1"
-
-stream-browserify@^2.0.0:
+stream-browserify@^2.0.0, stream-browserify@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"
   dependencies:
@@ -4879,7 +4886,7 @@ stream-consume@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/stream-consume/-/stream-consume-0.1.0.tgz#a41ead1a6d6081ceb79f65b061901b6d8f3d1d0f"
 
-stream-http@^2.0.0:
+stream-http@^2.0.0, stream-http@^2.3.1:
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.4.0.tgz#9599aa8e263667ce4190e0dc04a1d065d3595a7e"
   dependencies:
@@ -4924,7 +4931,7 @@ string.prototype.padstart@^3.0.0:
     es-abstract "^1.4.3"
     function-bind "^1.0.2"
 
-string_decoder@~0.10.0, string_decoder@~0.10.25, string_decoder@~0.10.x:
+string_decoder@^0.10.25, string_decoder@~0.10.0, string_decoder@~0.10.x:
   version "0.10.31"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
 
@@ -5051,6 +5058,12 @@ timers-browserify@^1.0.1:
   dependencies:
     process "~0.11.0"
 
+timers-browserify@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.2.tgz#ab4883cf597dcd50af211349a00fbca56ac86b86"
+  dependencies:
+    setimmediate "^1.0.4"
+
 to-arraybuffer@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
@@ -5116,9 +5129,9 @@ ua-parser-js@^0.7.9:
   version "0.7.10"
   resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.10.tgz#917559ddcce07cbc09ece7d80495e4c268f4ef9f"
 
-uglify-js@~2.6.0:
-  version "2.6.4"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.6.4.tgz#65ea2fb3059c9394692f15fed87c2b36c16b9adf"
+uglify-js@~2.7.3:
+  version "2.7.5"
+  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.7.5.tgz#4612c0c7baaee2ba7c487de4904ae122079f2ca8"
   dependencies:
     async "~0.2.6"
     source-map "~0.5.1"
@@ -5162,14 +5175,7 @@ url-loader@^0.5.7:
     loader-utils "0.2.x"
     mime "1.2.x"
 
-url@~0.10.1:
-  version "0.10.3"
-  resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"
-  dependencies:
-    punycode "1.3.2"
-    querystring "0.2.0"
-
-url@~0.11.0:
+url@^0.11.0, url@~0.11.0:
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
   dependencies:
@@ -5184,7 +5190,7 @@ util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
 
-util@0.10.3, "util@>=0.10.3 <1", util@~0.10.1, util@~0.10.3:
+util@0.10.3, "util@>=0.10.3 <1", util@^0.10.3, util@~0.10.1:
   version "0.10.3"
   resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9"
   dependencies:
@@ -5253,11 +5259,11 @@ webidl-conversions@^3.0.0, webidl-conversions@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
 
-webpack-core@~0.6.0:
-  version "0.6.8"
-  resolved "https://registry.yarnpkg.com/webpack-core/-/webpack-core-0.6.8.tgz#edf9135de00a6a3c26dd0f14b208af0aa4af8d0a"
+webpack-core@~0.6.9:
+  version "0.6.9"
+  resolved "https://registry.yarnpkg.com/webpack-core/-/webpack-core-0.6.9.tgz#fc571588c8558da77be9efb6debdc5a3b172bdc2"
   dependencies:
-    source-list-map "~0.1.0"
+    source-list-map "~0.1.7"
     source-map "~0.4.1"
 
 webpack-dev-middleware@^1.6.0:
@@ -5278,9 +5284,9 @@ webpack-hot-middleware@^2.10.0:
     querystring "^0.2.0"
     strip-ansi "^3.0.0"
 
-webpack@^1.13.1:
-  version "1.13.2"
-  resolved "https://registry.yarnpkg.com/webpack/-/webpack-1.13.2.tgz#f11a96f458eb752970a86abe746c0704fabafaf3"
+webpack@^1.13.1, webpack@^1.14.0:
+  version "1.14.0"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-1.14.0.tgz#54f1ffb92051a328a5b2057d6ae33c289462c823"
   dependencies:
     acorn "^3.0.0"
     async "^1.3.0"
@@ -5290,13 +5296,13 @@ webpack@^1.13.1:
     loader-utils "^0.2.11"
     memory-fs "~0.3.0"
     mkdirp "~0.5.0"
-    node-libs-browser "^0.6.0"
+    node-libs-browser "^0.7.0"
     optimist "~0.6.0"
     supports-color "^3.1.0"
     tapable "~0.1.8"
-    uglify-js "~2.6.0"
+    uglify-js "~2.7.3"
     watchpack "^0.2.1"
-    webpack-core "~0.6.0"
+    webpack-core "~0.6.9"
 
 whatwg-fetch@>=0.10.0:
   version "1.0.0"