about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.circleci/config.yml20
-rw-r--r--.env.production.sample1
-rw-r--r--Dockerfile6
-rw-r--r--Gemfile13
-rw-r--r--Gemfile.lock59
-rw-r--r--app/controllers/admin/account_actions_controller.rb4
-rw-r--r--app/controllers/admin/tags_controller.rb5
-rw-r--r--app/controllers/api/base_controller.rb8
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb3
-rw-r--r--app/controllers/api/v1/directories_controller.rb30
-rw-r--r--app/controllers/api/v1/reports_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/reblogs_controller.rb3
-rw-r--r--app/controllers/api/v1/statuses_controller.rb3
-rw-r--r--app/controllers/application_controller.rb12
-rw-r--r--app/controllers/auth/confirmations_controller.rb23
-rw-r--r--app/controllers/directories_controller.rb8
-rw-r--r--app/controllers/remote_follow_controller.rb2
-rw-r--r--app/controllers/remote_interaction_controller.rb2
-rw-r--r--app/controllers/well_known/webfinger_controller.rb2
-rw-r--r--app/helpers/instance_helper.rb12
-rw-r--r--app/helpers/statuses_helper.rb20
-rw-r--r--app/javascript/core/admin.js10
-rw-r--r--app/javascript/flavours/glitch/actions/alerts.js12
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js47
-rw-r--r--app/javascript/flavours/glitch/actions/directory.js61
-rw-r--r--app/javascript/flavours/glitch/actions/polls.js2
-rw-r--r--app/javascript/flavours/glitch/actions/trends.js32
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_hashtag.js28
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_input.js15
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_textarea.js15
-rw-r--r--app/javascript/flavours/glitch/components/hashtag.js4
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js17
-rw-r--r--app/javascript/flavours/glitch/components/poll.js6
-rw-r--r--app/javascript/flavours/glitch/components/radio_button.js35
-rw-r--r--app/javascript/flavours/glitch/components/status.js32
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js16
-rw-r--r--app/javascript/flavours/glitch/containers/media_container.js14
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js13
-rw-r--r--app/javascript/flavours/glitch/features/audio/index.js226
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.js12
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/header_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_results_container.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/warning_container.js5
-rw-r--r--app/javascript/flavours/glitch/features/directory/components/account_card.js190
-rw-r--r--app/javascript/flavours/glitch/features/directory/index.js171
-rw-r--r--app/javascript/flavours/glitch/features/followers/index.js1
-rw-r--r--app/javascript/flavours/glitch/features/following/index.js1
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/trends.js46
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js13
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js22
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js2
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js16
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js19
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js44
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js15
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js14
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/link_footer.js71
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/navigation_panel.js13
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/notifications_container.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js186
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/alerts.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js53
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/polls.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/suggestions.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/trends.js23
-rw-r--r--app/javascript/flavours/glitch/reducers/user_lists.js18
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js1
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss18
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss47
-rw-r--r--app/javascript/flavours/glitch/styles/components/directory.scss180
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss59
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss87
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss12
-rw-r--r--app/javascript/flavours/glitch/styles/components/single_column.scss36
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss70
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss18
-rw-r--r--app/javascript/flavours/glitch/styles/dashboard.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/footer.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/diff.scss7
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss10
-rw-r--r--app/javascript/flavours/glitch/util/async-components.js8
-rw-r--r--app/javascript/flavours/glitch/util/backend_links.js2
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js2
-rw-r--r--app/javascript/flavours/glitch/util/log_out.js34
-rw-r--r--app/javascript/flavours/glitch/util/rtl.js1
-rw-r--r--app/javascript/mastodon/actions/alerts.js12
-rw-r--r--app/javascript/mastodon/actions/compose.js2
-rw-r--r--app/javascript/mastodon/actions/directory.js61
-rw-r--r--app/javascript/mastodon/components/autosuggest_hashtag.js6
-rw-r--r--app/javascript/mastodon/components/column_back_button.js14
-rw-r--r--app/javascript/mastodon/components/column_header.js14
-rw-r--r--app/javascript/mastodon/components/hashtag.js4
-rw-r--r--app/javascript/mastodon/components/media_gallery.js17
-rw-r--r--app/javascript/mastodon/components/radio_button.js35
-rw-r--r--app/javascript/mastodon/components/status.js28
-rw-r--r--app/javascript/mastodon/components/status_content.js2
-rw-r--r--app/javascript/mastodon/containers/media_container.js3
-rw-r--r--app/javascript/mastodon/features/audio/index.js226
-rw-r--r--app/javascript/mastodon/features/compose/components/action_bar.js7
-rw-r--r--app/javascript/mastodon/features/compose/components/navigation_bar.js3
-rw-r--r--app/javascript/mastodon/features/compose/containers/navigation_container.js20
-rw-r--r--app/javascript/mastodon/features/compose/index.js21
-rw-r--r--app/javascript/mastodon/features/directory/components/account_card.js190
-rw-r--r--app/javascript/mastodon/features/directory/index.js171
-rw-r--r--app/javascript/mastodon/features/getting_started/components/trends.js5
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js4
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js2
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js15
-rw-r--r--app/javascript/mastodon/features/status/index.js44
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js14
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js14
-rw-r--r--app/javascript/mastodon/features/ui/components/link_footer.js95
-rw-r--r--app/javascript/mastodon/features/ui/components/navigation_panel.js2
-rw-r--r--app/javascript/mastodon/features/ui/containers/notifications_container.js2
-rw-r--r--app/javascript/mastodon/features/ui/index.js20
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js8
-rw-r--r--app/javascript/mastodon/features/video/index.js2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json167
-rw-r--r--app/javascript/mastodon/locales/en.json17
-rw-r--r--app/javascript/mastodon/reducers/alerts.js1
-rw-r--r--app/javascript/mastodon/reducers/compose.js27
-rw-r--r--app/javascript/mastodon/reducers/user_lists.js18
-rw-r--r--app/javascript/mastodon/rtl.js1
-rw-r--r--app/javascript/mastodon/selectors/index.js1
-rw-r--r--app/javascript/mastodon/utils/log_out.js33
-rw-r--r--app/javascript/styles/mailer.scss7
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss10
-rw-r--r--app/javascript/styles/mastodon/components.scss374
-rw-r--r--app/javascript/styles/mastodon/containers.scss18
-rw-r--r--app/javascript/styles/mastodon/dashboard.scss2
-rw-r--r--app/javascript/styles/mastodon/footer.scss2
-rw-r--r--app/javascript/styles/mastodon/forms.scss9
-rw-r--r--app/lib/activitypub/activity/delete.rb2
-rw-r--r--app/lib/activitypub/adapter.rb14
-rw-r--r--app/lib/activitypub/serializer.rb8
-rw-r--r--app/lib/feed_manager.rb2
-rw-r--r--app/lib/request.rb52
-rw-r--r--app/mailers/user_mailer.rb4
-rw-r--r--app/models/account.rb7
-rw-r--r--app/models/admin/account_action.rb22
-rw-r--r--app/models/feed.rb5
-rw-r--r--app/models/form/status_batch.rb3
-rw-r--r--app/models/media_attachment.rb4
-rw-r--r--app/models/remote_follow.rb6
-rw-r--r--app/models/report.rb2
-rw-r--r--app/models/status.rb6
-rw-r--r--app/models/tag.rb4
-rw-r--r--app/models/trending_tags.rb102
-rw-r--r--app/serializers/activitypub/actor_serializer.rb8
-rw-r--r--app/serializers/activitypub/note_serializer.rb7
-rw-r--r--app/serializers/rest/account_serializer.rb2
-rw-r--r--app/services/activitypub/process_account_service.rb1
-rw-r--r--app/services/batched_remove_status_service.rb2
-rw-r--r--app/services/post_status_service.rb8
-rw-r--r--app/services/remove_status_service.rb12
-rw-r--r--app/services/suspend_account_service.rb1
-rw-r--r--app/validators/domain_validator.rb12
-rw-r--r--app/validators/email_mx_validator.rb3
-rw-r--r--app/views/admin/account_actions/new.html.haml4
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/instances/index.html.haml19
-rw-r--r--app/views/admin/reports/_status.html.haml5
-rw-r--r--app/views/admin/tags/show.html.haml4
-rw-r--r--app/views/application/_card.html.haml2
-rw-r--r--app/views/application/_sidebar.html.haml2
-rw-r--r--app/views/auth/registrations/new.html.haml2
-rw-r--r--app/views/auth/setup/show.html.haml5
-rw-r--r--app/views/auth/shared/_links.html.haml22
-rw-r--r--app/views/directories/index.html.haml95
-rw-r--r--app/views/errors/400.html.haml5
-rw-r--r--app/views/errors/406.html.haml5
-rw-r--r--app/views/errors/503.html.haml5
-rw-r--r--app/views/notification_mailer/_status.html.haml11
-rw-r--r--app/views/settings/deletes/show.html.haml24
-rw-r--r--app/views/settings/profiles/show.html.haml2
-rw-r--r--app/views/shared/_og.html.haml4
-rw-r--r--app/views/statuses/_detailed_status.html.haml6
-rw-r--r--app/views/statuses/_simple_status.html.haml6
-rw-r--r--app/views/user_mailer/warning.html.haml27
-rw-r--r--app/views/user_mailer/warning.text.erb13
-rw-r--r--app/workers/removal_worker.rb4
-rw-r--r--app/workers/scheduler/trending_tags_scheduler.rb11
-rw-r--r--config/deploy.rb2
-rw-r--r--config/environments/production.rb5
-rw-r--r--config/initializers/active_model_serializers.rb19
-rw-r--r--config/locales/en.yml33
-rw-r--r--config/locales/simple_form.en.yml5
-rw-r--r--config/puma.rb2
-rw-r--r--config/routes.rb11
-rw-r--r--config/sidekiq.yml3
-rw-r--r--db/migrate/20190819134503_add_deleted_at_to_statuses.rb5
-rw-r--r--db/migrate/20190820003045_update_statuses_index.rb13
-rw-r--r--db/migrate/20190823221802_add_local_index_to_statuses.rb11
-rw-r--r--db/migrate/20190901035623_add_max_score_to_tags.rb6
-rw-r--r--db/post_migrate/20190901040524_remove_score_from_tags.rb12
-rw-r--r--db/schema.rb10
-rw-r--r--dist/nginx.conf2
-rw-r--r--package.json13
-rw-r--r--spec/controllers/admin/reported_statuses_controller_spec.rb2
-rw-r--r--spec/controllers/admin/statuses_controller_spec.rb2
-rw-r--r--spec/controllers/remote_follow_controller_spec.rb4
-rw-r--r--spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb3
-rw-r--r--spec/controllers/settings/two_factor_authentications_controller_spec.rb3
-rw-r--r--spec/lib/activitypub/activity/update_spec.rb2
-rw-r--r--spec/mailers/previews/user_mailer_preview.rb2
-rw-r--r--spec/models/admin/account_action_spec.rb4
-rw-r--r--spec/models/form/status_batch_spec.rb4
-rw-r--r--spec/models/remote_follow_spec.rb2
-rw-r--r--spec/models/trending_tags_spec.rb68
-rw-r--r--yarn.lock167
214 files changed, 4230 insertions, 911 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 8c8b411df..529b645aa 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -3,7 +3,7 @@ version: 2
 aliases:
   - &defaults
     docker:
-      - image: circleci/ruby:2.6.0-stretch-node
+      - image: circleci/ruby:2.6-stretch-node
         environment: &ruby_environment
           BUNDLE_APP_CONFIG: ./.bundle/
           DB_HOST: localhost
@@ -105,14 +105,14 @@ jobs:
   install-ruby2.5:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.5.3-stretch-node
+      - image: circleci/ruby:2.5-stretch-node
         environment: *ruby_environment
     <<: *install_ruby_dependencies
 
   install-ruby2.4:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.4.5-stretch-node
+      - image: circleci/ruby:2.4-stretch-node
         environment: *ruby_environment
     <<: *install_ruby_dependencies
 
@@ -134,40 +134,40 @@ jobs:
   test-ruby2.6:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.6.0-stretch-node
+      - image: circleci/ruby:2.6-stretch-node
         environment: *ruby_environment
       - image: circleci/postgres:10.6-alpine
         environment:
           POSTGRES_USER: root
-      - image: circleci/redis:5.0.3-alpine3.8
+      - image: circleci/redis:5-alpine
     <<: *test_steps
 
   test-ruby2.5:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.5.3-stretch-node
+      - image: circleci/ruby:2.5-stretch-node
         environment: *ruby_environment
       - image: circleci/postgres:10.6-alpine
         environment:
           POSTGRES_USER: root
-      - image: circleci/redis:4.0.12-alpine
+      - image: circleci/redis:5-alpine
     <<: *test_steps
 
   test-ruby2.4:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.4.5-stretch-node
+      - image: circleci/ruby:2.4-stretch-node
         environment: *ruby_environment
       - image: circleci/postgres:10.6-alpine
         environment:
           POSTGRES_USER: root
-      - image: circleci/redis:4.0.12-alpine
+      - image: circleci/redis:5-alpine
     <<: *test_steps
 
   test-webui:
     <<: *defaults
     docker:
-      - image: circleci/node:8.15.0-stretch
+      - image: circleci/node:12.9-stretch
     steps:
       - *attach_workspace
       - run: ./bin/retry yarn test:jest
diff --git a/.env.production.sample b/.env.production.sample
index a2a9246d4..2fbecc91a 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -69,6 +69,7 @@ SMTP_PORT=587
 SMTP_LOGIN=
 SMTP_PASSWORD=
 SMTP_FROM_ADDRESS=notifications@example.com
+#SMTP_REPLY_TO=
 #SMTP_DOMAIN= # defaults to LOCAL_DOMAIN
 #SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail
 #SMTP_AUTH_METHOD=plain
diff --git a/Dockerfile b/Dockerfile
index 816078a4b..cbf8ba3a0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,7 +4,7 @@ FROM ubuntu:18.04 as build-dep
 SHELL ["bash", "-c"]
 
 # Install Node
-ENV NODE_VER="10.16.0"
+ENV NODE_VER="12.9.1"
 RUN	echo "Etc/UTC" > /etc/localtime && \
 	apt update && \
 	apt -y install wget make gcc g++ python && \
@@ -17,7 +17,7 @@ RUN	echo "Etc/UTC" > /etc/localtime && \
 	make install
 
 # Install jemalloc
-ENV JE_VER="5.2.0"
+ENV JE_VER="5.2.1"
 RUN apt update && \
 	apt -y install autoconf && \
 	cd ~ && \
@@ -30,7 +30,7 @@ RUN apt update && \
 	make install_bin install_include install_lib
 
 # Install ruby
-ENV RUBY_VER="2.6.3"
+ENV RUBY_VER="2.6.4"
 ENV CPPFLAGS="-I/opt/jemalloc/include"
 ENV LDFLAGS="-L/opt/jemalloc/lib/"
 RUN apt update && \
diff --git a/Gemfile b/Gemfile
index ace4b58e6..cfaa6e444 100644
--- a/Gemfile
+++ b/Gemfile
@@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
 gem 'pghero', '~> 2.3'
 gem 'dotenv-rails', '~> 2.7'
 
-gem 'aws-sdk-s3', '~> 1.46', require: false
+gem 'aws-sdk-s3', '~> 1.48', require: false
 gem 'fog-core', '<= 2.1.0'
 gem 'fog-openstack', '~> 0.3', require: false
 gem 'paperclip', '~> 6.0'
@@ -24,14 +24,14 @@ gem 'streamio-ffmpeg', '~> 3.0'
 gem 'blurhash', '~> 0.1'
 
 gem 'active_model_serializers', '~> 0.10'
-gem 'addressable', '~> 2.6'
+gem 'addressable', '~> 2.7'
 gem 'bootsnap', '~> 1.4', require: false
 gem 'browser'
 gem 'charlock_holmes', '~> 0.7.6'
 gem 'iso-639'
 gem 'chewy', '~> 5.0'
 gem 'cld3', '~> 3.2.4'
-gem 'devise', '~> 4.6'
+gem 'devise', '~> 4.7'
 gem 'devise-two-factor', '~> 3.1'
 
 group :pam_authentication, optional: true do
@@ -43,6 +43,7 @@ gem 'omniauth-cas', '~> 1.1'
 gem 'omniauth-saml', '~> 1.10'
 gem 'omniauth', '~> 1.9'
 
+gem 'discard', '~> 1.1'
 gem 'doorkeeper', '~> 5.1'
 gem 'fast_blank', '~> 1.0'
 gem 'fastimage'
@@ -93,7 +94,7 @@ gem 'tzinfo-data', '~> 1.2019'
 gem 'webpacker', '~> 4.0'
 gem 'webpush'
 
-gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: '345b7a5733308af827e8491d284dbafa9128d7a2'
+gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d'
 gem 'json-ld-preloaded', '~> 3.0'
 gem 'rdf-normalize', '~> 0.3'
 
@@ -115,12 +116,12 @@ end
 group :test do
   gem 'capybara', '~> 3.28'
   gem 'climate_control', '~> 0.2'
-  gem 'faker', '~> 2.1'
+  gem 'faker', '~> 2.2'
   gem 'microformats', '~> 4.1'
   gem 'rails-controller-testing', '~> 1.0'
   gem 'rspec-sidekiq', '~> 3.0'
   gem 'simplecov', '~> 0.17', require: false
-  gem 'webmock', '~> 3.6'
+  gem 'webmock', '~> 3.7'
   gem 'parallel_tests', '~> 2.29'
 end
 
diff --git a/Gemfile.lock b/Gemfile.lock
index 0af2b2a89..68a68c848 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -7,8 +7,8 @@ GIT
 
 GIT
   remote: https://github.com/ruby-rdf/json-ld.git
-  revision: 345b7a5733308af827e8491d284dbafa9128d7a2
-  ref: 345b7a5733308af827e8491d284dbafa9128d7a2
+  revision: e742697a0906e74e8bb777ef98137bc3955d981d
+  ref: e742697a0906e74e8bb777ef98137bc3955d981d
   specs:
     json-ld (3.0.2)
       htmlentities (~> 4.3)
@@ -83,9 +83,9 @@ GEM
       i18n (>= 0.7, < 2)
       minitest (~> 5.1)
       tzinfo (~> 1.1)
-    addressable (2.6.0)
-      public_suffix (>= 2.0.2, < 4.0)
-    airbrussh (1.3.0)
+    addressable (2.7.0)
+      public_suffix (>= 2.0.2, < 5.0)
+    airbrussh (1.3.3)
       sshkit (>= 1.6.1, != 1.7.0)
     annotate (2.7.5)
       activerecord (>= 3.2, < 7.0)
@@ -97,8 +97,8 @@ GEM
     av (0.9.0)
       cocaine (~> 0.5.3)
     aws-eventstream (1.0.3)
-    aws-partitions (1.193.0)
-    aws-sdk-core (3.61.1)
+    aws-partitions (1.207.0)
+    aws-sdk-core (3.65.1)
       aws-eventstream (~> 1.0, >= 1.0.2)
       aws-partitions (~> 1.0)
       aws-sigv4 (~> 1.1)
@@ -106,7 +106,7 @@ GEM
     aws-sdk-kms (1.24.0)
       aws-sdk-core (~> 3, >= 3.61.1)
       aws-sigv4 (~> 1.1)
-    aws-sdk-s3 (1.46.0)
+    aws-sdk-s3 (1.48.0)
       aws-sdk-core (~> 3, >= 3.61.1)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.1)
@@ -122,19 +122,19 @@ GEM
       debug_inspector (>= 0.0.1)
     blurhash (0.1.3)
       ffi (~> 1.10.0)
-    bootsnap (1.4.4)
+    bootsnap (1.4.5)
       msgpack (~> 1.0)
     brakeman (4.6.1)
     browser (2.6.1)
     builder (3.2.3)
-    bullet (6.0.1)
+    bullet (6.0.2)
       activesupport (>= 3.0.0)
       uniform_notifier (~> 1.11)
     bundler-audit (0.6.1)
       bundler (>= 1.2.0, < 3)
       thor (~> 0.18)
     byebug (11.0.0)
-    capistrano (3.11.0)
+    capistrano (3.11.1)
       airbrussh (>= 1.0.0)
       i18n
       rake (>= 10.0.0)
@@ -188,10 +188,10 @@ GEM
       rack (>= 1)
       rake (> 10, < 13)
       thor (~> 0.19)
-    devise (4.6.2)
+    devise (4.7.0)
       bcrypt (~> 3.0)
       orm_adapter (~> 0.1)
-      railties (>= 4.1.0, < 6.0)
+      railties (>= 4.1.0)
       responders
       warden (~> 1.2.3)
     devise-two-factor (3.1.0)
@@ -204,6 +204,8 @@ GEM
       devise (>= 4.0.0)
       rpam2 (~> 4.0)
     diff-lcs (1.3)
+    discard (1.1.0)
+      activerecord (>= 4.2, < 7)
     docile (1.3.2)
     domain_name (0.5.20180417)
       unf (>= 0.0.5, < 1.0.0)
@@ -229,7 +231,7 @@ GEM
       tzinfo
     excon (0.62.0)
     fabrication (2.20.2)
-    faker (2.1.2)
+    faker (2.2.1)
       i18n (>= 0.8)
     faraday (0.15.0)
       multipart-post (>= 1.2, < 3)
@@ -369,14 +371,14 @@ GEM
     mini_mime (1.0.2)
     mini_portile2 (2.4.0)
     minitest (5.11.3)
-    msgpack (1.2.10)
+    msgpack (1.3.1)
     multi_json (1.13.1)
     multipart-post (2.0.0)
     necromancer (0.5.0)
     net-ldap (0.16.1)
-    net-scp (1.2.1)
-      net-ssh (>= 2.6.5)
-    net-ssh (5.0.2)
+    net-scp (2.0.0)
+      net-ssh (>= 2.6.5, < 6.0.0)
+    net-ssh (5.2.0)
     nio4r (2.4.0)
     nokogiri (1.10.4)
       mini_portile2 (~> 2.4.0)
@@ -416,7 +418,7 @@ GEM
     parallel (1.17.0)
     parallel_tests (2.29.2)
       parallel
-    parser (2.6.3.0)
+    parser (2.6.4.0)
       ast (~> 2.4.0)
     parslet (1.8.2)
     pastel (0.7.2)
@@ -442,7 +444,7 @@ GEM
       pry (~> 0.10)
     pry-rails (0.3.9)
       pry (>= 0.10.4)
-    public_suffix (3.1.1)
+    public_suffix (4.0.1)
     puma (4.1.0)
       nio4r (~> 2.0)
     pundit (2.1.0)
@@ -555,7 +557,7 @@ GEM
       rainbow (>= 2.2.2, < 4.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (>= 1.4.0, < 1.7)
-    rubocop-rails (2.3.0)
+    rubocop-rails (2.3.2)
       rack (>= 1.1)
       rubocop (>= 0.72.0)
     ruby-progressbar (1.10.1)
@@ -601,7 +603,7 @@ GEM
       actionpack (>= 4.0)
       activesupport (>= 4.0)
       sprockets (>= 3.0.0)
-    sshkit (1.17.0)
+    sshkit (1.20.0)
       net-scp (>= 1.1.2)
       net-ssh (>= 2.8.0)
     stackprof (0.2.12)
@@ -645,7 +647,7 @@ GEM
     uniform_notifier (1.12.1)
     warden (1.2.8)
       rack (>= 2.0.6)
-    webmock (3.6.2)
+    webmock (3.7.1)
       addressable (>= 2.3.6)
       crack (>= 0.3.2)
       hashdiff (>= 0.4.0, < 2.0.0)
@@ -669,9 +671,9 @@ PLATFORMS
 DEPENDENCIES
   active_model_serializers (~> 0.10)
   active_record_query_trace (~> 1.6)
-  addressable (~> 2.6)
+  addressable (~> 2.7)
   annotate (~> 2.7)
-  aws-sdk-s3 (~> 1.46)
+  aws-sdk-s3 (~> 1.48)
   better_errors (~> 2.5)
   binding_of_caller (~> 0.7)
   blurhash (~> 0.1)
@@ -692,13 +694,14 @@ DEPENDENCIES
   concurrent-ruby
   connection_pool
   derailed_benchmarks
-  devise (~> 4.6)
+  devise (~> 4.7)
   devise-two-factor (~> 3.1)
   devise_pam_authenticatable2 (~> 9.2)
+  discard (~> 1.1)
   doorkeeper (~> 5.1)
   dotenv-rails (~> 2.7)
   fabrication (~> 2.20)
-  faker (~> 2.1)
+  faker (~> 2.2)
   fast_blank (~> 1.0)
   fastimage
   fog-core (<= 2.1.0)
@@ -786,7 +789,7 @@ DEPENDENCIES
   tty-prompt (~> 0.19)
   twitter-text (~> 1.14)
   tzinfo-data (~> 1.2019)
-  webmock (~> 3.6)
+  webmock (~> 3.7)
   webpacker (~> 4.0)
   webpush
 
diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb
index a2cea461e..ea56fa0ac 100644
--- a/app/controllers/admin/account_actions_controller.rb
+++ b/app/controllers/admin/account_actions_controller.rb
@@ -5,7 +5,7 @@ module Admin
     before_action :set_account
 
     def new
-      @account_action  = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true)
+      @account_action  = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true)
       @warning_presets = AccountWarningPreset.all
     end
 
@@ -30,7 +30,7 @@ module Admin
     end
 
     def resource_params
-      params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification)
+      params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses)
     end
   end
 end
diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb
index 39aca2a4b..8bd4e5f8b 100644
--- a/app/controllers/admin/tags_controller.rb
+++ b/app/controllers/admin/tags_controller.rb
@@ -37,7 +37,8 @@ module Admin
 
     def set_usage_by_domain
       @usage_by_domain = @tag.statuses
-                             .where(visibility: :public)
+                             .with_public_visibility
+                             .excluding_silenced_accounts
                              .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
                              .joins(:account)
                              .group('accounts.domain')
@@ -56,7 +57,7 @@ module Admin
       scope = scope.unreviewed if filter_params[:review] == 'unreviewed'
       scope = scope.reviewed.order(reviewed_at: :desc) if filter_params[:review] == 'reviewed'
       scope = scope.pending_review.order(requested_review_at: :desc) if filter_params[:review] == 'pending_review'
-      scope.order(score: :desc)
+      scope.order(max_score: :desc)
     end
 
     def filter_params
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index de8fff30e..33df75b37 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -36,6 +36,14 @@ class Api::BaseController < ApplicationController
     render json: { error: 'This action is not allowed' }, status: 403
   end
 
+  rescue_from Mastodon::RaceConditionError do
+    render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
+  end
+
+  rescue_from ActionController::ParameterMissing do |e|
+    render json: { error: e.to_s }, status: 400
+  end
+
   def doorkeeper_unauthorized_render_options(error: nil)
     { json: { error: (error.try(:description) || 'Not authorized') } }
   end
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 13cb4caf1..0787cd636 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -29,14 +29,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
 
   def account_statuses
     statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
-    statuses = statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
 
     statuses.merge!(only_media_scope) if truthy_param?(:only_media)
     statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
     statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
     statuses.merge!(hashtag_scope)    if params[:tagged].present?
 
-    statuses
+    statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
   end
 
   def permitted_account_statuses
diff --git a/app/controllers/api/v1/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb
new file mode 100644
index 000000000..c91543e3a
--- /dev/null
+++ b/app/controllers/api/v1/directories_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Api::V1::DirectoriesController < Api::BaseController
+  before_action :require_enabled!
+  before_action :set_accounts
+
+  def show
+    render json: @accounts, each_serializer: REST::AccountSerializer
+  end
+
+  private
+
+  def require_enabled!
+    return not_found unless Setting.profile_directory
+  end
+
+  def set_accounts
+    @accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT))
+  end
+
+  def accounts_scope
+    Account.discoverable.tap do |scope|
+      scope.merge!(Account.local)                                          if truthy_param?(:local)
+      scope.merge!(Account.by_recent_status)                               if params[:order].blank? || params[:order] == 'active'
+      scope.merge!(Account.order(id: :desc))                               if params[:order] == 'new'
+      scope.merge!(Account.not_excluded_by_account(current_account))       if current_account
+      scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local)
+    end
+  end
+end
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
index e182a9c6c..1b0b4b05b 100644
--- a/app/controllers/api/v1/reports_controller.rb
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController
   private
 
   def reported_status_ids
-    reported_account.statuses.find(status_ids).pluck(:id)
+    reported_account.statuses.with_discarded.find(status_ids).pluck(:id)
   end
 
   def status_ids
diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb
index ed4f55100..42381a37f 100644
--- a/app/controllers/api/v1/statuses/reblogs_controller.rb
+++ b/app/controllers/api/v1/statuses/reblogs_controller.rb
@@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
     @reblogs_map = { @status.id => false }
 
     authorize status_for_destroy, :unreblog?
+    status_for_destroy.discard
     RemovalWorker.perform_async(status_for_destroy.id)
 
     render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
@@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
   end
 
   def status_for_destroy
-    current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
+    @status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
   end
 
   def reblog_params
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 4e7476a84..486004f9c 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -54,7 +54,8 @@ class Api::V1::StatusesController < Api::BaseController
     @status = Status.where(account_id: current_user.account).find(params[:id])
     authorize @status, :destroy?
 
-    RemovalWorker.perform_async(@status.id)
+    @status.discard
+    RemovalWorker.perform_async(@status.id, redraft: true)
 
     render json: @status, serializer: REST::StatusSerializer, source_requested: true
   end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 5f88838e4..59624cad5 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -22,11 +22,13 @@ class ApplicationController < ActionController::Base
   helper_method :whitelist_mode?
 
   rescue_from ActionController::RoutingError, with: :not_found
-  rescue_from ActiveRecord::RecordNotFound, with: :not_found
   rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
   rescue_from ActionController::UnknownFormat, with: :not_acceptable
+  rescue_from ActionController::ParameterMissing, with: :bad_request
+  rescue_from ActiveRecord::RecordNotFound, with: :not_found
   rescue_from Mastodon::NotPermittedError, with: :forbidden
   rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
+  rescue_from Mastodon::RaceConditionError, with: :service_unavailable
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
   before_action :require_functional!, if: :user_signed_in?
@@ -166,10 +168,18 @@ class ApplicationController < ActionController::Base
     respond_with_error(406)
   end
 
+  def bad_request
+    respond_with_error(400)
+  end
+
   def internal_server_error
     respond_with_error(500)
   end
 
+  def service_unavailable
+    respond_with_error(503)
+  end
+
   def single_user_mode?
     @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists?
   end
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
index 1d6e4ec19..4e89446c7 100644
--- a/app/controllers/auth/confirmations_controller.rb
+++ b/app/controllers/auth/confirmations_controller.rb
@@ -5,19 +5,42 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
 
   before_action :set_body_classes
   before_action :set_pack
+  before_action :require_unconfirmed!
 
   skip_before_action :require_functional!
 
+  def new
+    super
+
+    resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
+  end
+
   private
 
   def set_pack
     use_pack 'auth'
   end
 
+  def require_unconfirmed!
+    redirect_to edit_user_registration_path if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
+  end
+
   def set_body_classes
     @body_classes = 'lighter'
   end
 
+  def after_resending_confirmation_instructions_path_for(_resource_name)
+    if user_signed_in?
+      if current_user.confirmed? && current_user.approved?
+        edit_user_registration_path
+      else
+        auth_setup_path
+      end
+    else
+      new_user_session_path
+    end
+  end
+
   def after_confirmation_path_for(_resource_name, user)
     if user.created_by_application && truthy_param?(:redirect_to_app)
       user.created_by_application.redirect_uri
diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb
index f2d1f5661..bbfdde8af 100644
--- a/app/controllers/directories_controller.rb
+++ b/app/controllers/directories_controller.rb
@@ -7,7 +7,6 @@ class DirectoriesController < ApplicationController
   before_action :require_enabled!
   before_action :set_instance_presenter
   before_action :set_tag, only: :show
-  before_action :set_tags
   before_action :set_accounts
   before_action :set_pack
 
@@ -33,13 +32,10 @@ class DirectoriesController < ApplicationController
     @tag = Tag.discoverable.find_normalized!(params[:id])
   end
 
-  def set_tags
-    @tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? }
-  end
-
   def set_accounts
-    @accounts = Account.discoverable.by_recent_status.page(params[:page]).per(40).tap do |query|
+    @accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query|
       query.merge!(Account.tagged_with(@tag.id)) if @tag
+      query.merge!(Account.not_excluded_by_account(current_account)) if current_account
     end
   end
 
diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb
index 46dd444a4..65dfa35db 100644
--- a/app/controllers/remote_follow_controller.rb
+++ b/app/controllers/remote_follow_controller.rb
@@ -30,7 +30,7 @@ class RemoteFollowController < ApplicationController
   end
 
   def session_params
-    { acct: session[:remote_follow] }
+    { acct: session[:remote_follow] || current_account&.username }
   end
 
   def set_pack
diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb
index 5ae72989b..6b797b10f 100644
--- a/app/controllers/remote_interaction_controller.rb
+++ b/app/controllers/remote_interaction_controller.rb
@@ -33,7 +33,7 @@ class RemoteInteractionController < ApplicationController
   end
 
   def session_params
-    { acct: session[:remote_follow] }
+    { acct: session[:remote_follow] || current_account&.username }
   end
 
   def set_status
diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb
index 50bace217..d60bf98ab 100644
--- a/app/controllers/well_known/webfinger_controller.rb
+++ b/app/controllers/well_known/webfinger_controller.rb
@@ -11,7 +11,7 @@ module WellKnown
 
       expires_in 3.days, public: true
       render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
-    rescue ActiveRecord::RecordNotFound
+    rescue ActiveRecord::RecordNotFound, ActionController::ParameterMissing
       head 404
     end
 
diff --git a/app/helpers/instance_helper.rb b/app/helpers/instance_helper.rb
index dd0b25f3e..daacb535b 100644
--- a/app/helpers/instance_helper.rb
+++ b/app/helpers/instance_helper.rb
@@ -8,4 +8,16 @@ module InstanceHelper
   def site_hostname
     @site_hostname ||= Addressable::URI.parse("//#{Rails.configuration.x.local_domain}").display_uri.host
   end
+
+  def description_for_sign_up
+    prefix = begin
+      if @invite.present?
+        I18n.t('auth.description.prefix_invited_by_user', name: @invite.user.account.username)
+      else
+        I18n.t('auth.description.prefix_sign_up')
+      end
+    end
+
+    safe_join([prefix, I18n.t('auth.description.suffix')], ' ')
+  end
 end
diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb
index 2996631a3..880a4037f 100644
--- a/app/helpers/statuses_helper.rb
+++ b/app/helpers/statuses_helper.rb
@@ -34,6 +34,26 @@ module StatusesHelper
     end
   end
 
+  def minimal_account_action_button(account)
+    if user_signed_in?
+      return if account.id == current_user.account_id
+
+      if current_account.following?(account) || current_account.requested?(account)
+        link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do
+          fa_icon('user-times fw')
+        end
+      elsif !(account.memorial? || account.moved?)
+        link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do
+          fa_icon('user-plus fw')
+        end
+      end
+    elsif !(account.memorial? || account.moved?)
+      link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do
+        fa_icon('user-plus fw')
+      end
+    end
+  end
+
   def svg_logo
     content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976')
   end
diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js
index 3f6f187bc..ffdabe674 100644
--- a/app/javascript/core/admin.js
+++ b/app/javascript/core/admin.js
@@ -1,6 +1,7 @@
 //  This file will be loaded on admin pages, regardless of theme.
 
 import { delegate } from 'rails-ujs';
+import ready from '../mastodon/ready';
 
 const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
 
@@ -31,7 +32,7 @@ delegate(document, '.media-spoiler-hide-button', 'click', () => {
   });
 });
 
-delegate(document, '#domain_block_severity', 'change', ({ target }) => {
+const onDomainBlockSeverityChange = (target) => {
   const rejectMediaDiv   = document.querySelector('.input.with_label.domain_block_reject_media');
   const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports');
 
@@ -42,4 +43,11 @@ delegate(document, '#domain_block_severity', 'change', ({ target }) => {
   if (rejectReportsDiv) {
     rejectReportsDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
   }
+};
+
+delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target));
+
+ready(() => {
+  const input = document.getElementById('domain_block_severity');
+  if (input) onDomainBlockSeverityChange(input);
 });
diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js
index ef2500e7b..cd36d8007 100644
--- a/app/javascript/flavours/glitch/actions/alerts.js
+++ b/app/javascript/flavours/glitch/actions/alerts.js
@@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
 const messages = defineMessages({
   unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
   unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
+  rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
+  rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
 });
 
 export const ALERT_SHOW    = 'ALERT_SHOW';
@@ -23,23 +25,29 @@ export function clearAlert() {
   };
 };
 
-export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
+export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
   return {
     type: ALERT_SHOW,
     title,
     message,
+    message_values,
   };
 };
 
 export function showAlertForError(error) {
   if (error.response) {
-    const { data, status, statusText } = error.response;
+    const { data, status, statusText, headers } = error.response;
 
     if (status === 404 || status === 410) {
       // Skip these errors as they are reflected in the UI
       return { type: ALERT_NOOP };
     }
 
+    if (status === 429 && headers['x-ratelimit-reset']) {
+      const reset_date = new Date(headers['x-ratelimit-reset']);
+      return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
+    }
+
     let message = statusText;
     let title   = `${status}`;
 
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 2312bae63..e1da03745 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -12,7 +12,7 @@ import { showAlertForError } from './alerts';
 import { showAlert } from './alerts';
 import { defineMessages } from 'react-intl';
 
-let cancelFetchComposeSuggestionsAccounts;
+let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
 
 export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE';
 export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND';
@@ -352,10 +352,12 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
   if (cancelFetchComposeSuggestionsAccounts) {
     cancelFetchComposeSuggestionsAccounts();
   }
+
   api(getState).get('/api/v1/accounts/search', {
     cancelToken: new CancelToken(cancel => {
       cancelFetchComposeSuggestionsAccounts = cancel;
     }),
+
     params: {
       q: token.slice(1),
       resolve: false,
@@ -376,9 +378,32 @@ const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
   dispatch(readyComposeSuggestionsEmojis(token, results));
 };
 
-const fetchComposeSuggestionsTags = (dispatch, getState, token) => {
+const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
+  if (cancelFetchComposeSuggestionsTags) {
+    cancelFetchComposeSuggestionsTags();
+  }
+
   dispatch(updateSuggestionTags(token));
-};
+
+  api(getState).get('/api/v2/search', {
+    cancelToken: new CancelToken(cancel => {
+      cancelFetchComposeSuggestionsTags = cancel;
+    }),
+
+    params: {
+      type: 'hashtags',
+      q: token.slice(1),
+      resolve: false,
+      limit: 4,
+    },
+  }).then(({ data }) => {
+    dispatch(readyComposeSuggestionsTags(token, data.hashtags));
+  }).catch(error => {
+    if (!isCancel(error)) {
+      dispatch(showAlertForError(error));
+    }
+  });
+}, 200, { leading: true, trailing: true });
 
 export function fetchComposeSuggestions(token) {
   return (dispatch, getState) => {
@@ -412,16 +437,22 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
   };
 };
 
+export const readyComposeSuggestionsTags = (token, tags) => ({
+  type: COMPOSE_SUGGESTIONS_READY,
+  token,
+  tags,
+});
+
 export function selectComposeSuggestion(position, token, suggestion, path) {
   return (dispatch, getState) => {
     let completion;
-    if (typeof suggestion === 'object' && suggestion.id) {
+    if (suggestion.type === 'emoji') {
       dispatch(useEmoji(suggestion));
       completion = suggestion.native || suggestion.colons;
-    } else if (suggestion[0] === '#') {
-      completion = suggestion;
-    } else {
-      completion = '@' + getState().getIn(['accounts', suggestion, 'acct']);
+    } else if (suggestion.type === 'hashtag') {
+      completion = `#${suggestion.name}`;
+    } else if (suggestion.type === 'account') {
+      completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']);
     }
 
     dispatch({
diff --git a/app/javascript/flavours/glitch/actions/directory.js b/app/javascript/flavours/glitch/actions/directory.js
new file mode 100644
index 000000000..9fbfb7f5b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/directory.js
@@ -0,0 +1,61 @@
+import api from 'flavours/glitch/util/api';
+import { importFetchedAccounts } from './importer';
+import { fetchRelationships } from './accounts';
+
+export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
+export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
+export const DIRECTORY_FETCH_FAIL    = 'DIRECTORY_FETCH_FAIL';
+
+export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
+export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
+export const DIRECTORY_EXPAND_FAIL    = 'DIRECTORY_EXPAND_FAIL';
+
+export const fetchDirectory = params => (dispatch, getState) => {
+  dispatch(fetchDirectoryRequest());
+
+  api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchDirectorySuccess(data));
+    dispatch(fetchRelationships(data.map(x => x.id)));
+  }).catch(error => dispatch(fetchDirectoryFail(error)));
+};
+
+export const fetchDirectoryRequest = () => ({
+  type: DIRECTORY_FETCH_REQUEST,
+});
+
+export const fetchDirectorySuccess = accounts => ({
+  type: DIRECTORY_FETCH_SUCCESS,
+  accounts,
+});
+
+export const fetchDirectoryFail = error => ({
+  type: DIRECTORY_FETCH_FAIL,
+  error,
+});
+
+export const expandDirectory = params => (dispatch, getState) => {
+  dispatch(expandDirectoryRequest());
+
+  const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
+
+  api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(expandDirectorySuccess(data));
+    dispatch(fetchRelationships(data.map(x => x.id)));
+  }).catch(error => dispatch(expandDirectoryFail(error)));
+};
+
+export const expandDirectoryRequest = () => ({
+  type: DIRECTORY_EXPAND_REQUEST,
+});
+
+export const expandDirectorySuccess = accounts => ({
+  type: DIRECTORY_EXPAND_SUCCESS,
+  accounts,
+});
+
+export const expandDirectoryFail = error => ({
+  type: DIRECTORY_EXPAND_FAIL,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/polls.js b/app/javascript/flavours/glitch/actions/polls.js
index 8e8b82df5..ca94a095f 100644
--- a/app/javascript/flavours/glitch/actions/polls.js
+++ b/app/javascript/flavours/glitch/actions/polls.js
@@ -1,4 +1,4 @@
-import api from '../api';
+import api from 'flavours/glitch/util/api';
 import { importFetchedPoll } from './importer';
 
 export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
diff --git a/app/javascript/flavours/glitch/actions/trends.js b/app/javascript/flavours/glitch/actions/trends.js
new file mode 100644
index 000000000..1b0ce2b5b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/trends.js
@@ -0,0 +1,32 @@
+import api from 'flavours/glitch/util/api';
+
+export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
+export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
+export const TRENDS_FETCH_FAIL    = 'TRENDS_FETCH_FAIL';
+
+export const fetchTrends = () => (dispatch, getState) => {
+  dispatch(fetchTrendsRequest());
+
+  api(getState)
+    .get('/api/v1/trends')
+    .then(({ data }) => dispatch(fetchTrendsSuccess(data)))
+    .catch(err => dispatch(fetchTrendsFail(err)));
+};
+
+export const fetchTrendsRequest = () => ({
+  type: TRENDS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendsSuccess = trends => ({
+  type: TRENDS_FETCH_SUCCESS,
+  trends,
+  skipLoading: true,
+});
+
+export const fetchTrendsFail = error => ({
+  type: TRENDS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.js b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js
new file mode 100644
index 000000000..648987dfd
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { shortNumberFormat } from 'flavours/glitch/util/numbers';
+import { FormattedMessage } from 'react-intl';
+
+export default class AutosuggestHashtag extends React.PureComponent {
+
+  static propTypes = {
+    tag: PropTypes.shape({
+      name: PropTypes.string.isRequired,
+      url: PropTypes.string,
+      history: PropTypes.array,
+    }).isRequired,
+  };
+
+  render () {
+    const { tag } = this.props;
+    const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
+
+    return (
+      <div className='autosuggest-hashtag'>
+        <div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
+        {tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.js b/app/javascript/flavours/glitch/components/autosuggest_input.js
index 5fc952d8e..1ef7ee216 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_input.js
+++ b/app/javascript/flavours/glitch/components/autosuggest_input.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
 import AutosuggestEmoji from './autosuggest_emoji';
+import AutosuggestHashtag from './autosuggest_hashtag';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { isRtl } from 'flavours/glitch/util/rtl';
@@ -167,15 +168,15 @@ export default class AutosuggestInput extends ImmutablePureComponent {
     const { selectedSuggestion } = this.state;
     let inner, key;
 
-    if (typeof suggestion === 'object') {
+    if (suggestion.type === 'emoji') {
       inner = <AutosuggestEmoji emoji={suggestion} />;
       key   = suggestion.id;
-    } else if (suggestion[0] === '#') {
-      inner = suggestion;
-      key   = suggestion;
-    } else {
-      inner = <AutosuggestAccountContainer id={suggestion} />;
-      key   = suggestion;
+    } else if (suggestion.type ==='hashtag') {
+      inner = <AutosuggestHashtag tag={suggestion} />;
+      key   = suggestion.name;
+    } else if (suggestion.type === 'account') {
+      inner = <AutosuggestAccountContainer id={suggestion.id} />;
+      key   = suggestion.id;
     }
 
     return (
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
index bbe0ffcbe..ec2fbbe4b 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js
+++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
 import AutosuggestEmoji from './autosuggest_emoji';
+import AutosuggestHashtag from './autosuggest_hashtag';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { isRtl } from 'flavours/glitch/util/rtl';
@@ -173,15 +174,15 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
     const { selectedSuggestion } = this.state;
     let inner, key;
 
-    if (typeof suggestion === 'object') {
+    if (suggestion.type === 'emoji') {
       inner = <AutosuggestEmoji emoji={suggestion} />;
       key   = suggestion.id;
-    } else if (suggestion[0] === '#') {
-      inner = suggestion;
-      key   = suggestion;
-    } else {
-      inner = <AutosuggestAccountContainer id={suggestion} />;
-      key   = suggestion;
+    } else if (suggestion.type === 'hashtag') {
+      inner = <AutosuggestHashtag tag={suggestion} />;
+      key   = suggestion.name;
+    } else if (suggestion.type === 'account') {
+      inner = <AutosuggestAccountContainer id={suggestion.id} />;
+      key   = suggestion.id;
     }
 
     return (
diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js
index d75edd994..d42bee0e9 100644
--- a/app/javascript/flavours/glitch/components/hashtag.js
+++ b/app/javascript/flavours/glitch/components/hashtag.js
@@ -12,11 +12,11 @@ const Hashtag = ({ hashtag }) => (
         #<span>{hashtag.get('name')}</span>
       </Permalink>
 
-      <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
+      <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} />
     </div>
 
     <div className='trends__item__current'>
-      {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
+      {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)}
     </div>
 
     <div className='trends__item__sparkline'>
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index 04d3ce751..28b369d09 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -179,7 +179,7 @@ class Item extends React.PureComponent {
     if (attachment.get('type') === 'unknown') {
       return (
         <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
-          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
+          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
             <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
           </a>
         </div>
@@ -329,7 +329,8 @@ export default class MediaGallery extends React.PureComponent {
   render () {
     const { media, intl, sensitive, letterbox, fullwidth, defaultWidth } = this.props;
     const { visible } = this.state;
-    const size = media.take(4).size;
+    const size     = media.take(4).size;
+    const uncached = media.every(attachment => attachment.get('type') === 'unknown');
 
     const width = this.state.width || defaultWidth;
 
@@ -350,10 +351,16 @@ export default class MediaGallery extends React.PureComponent {
     if (this.isStandaloneEligible()) {
       children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
     } else {
-      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible} />);
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible || uncached} />);
     }
 
-    if (visible) {
+    if (uncached) {
+      spoilerButton = (
+        <button type='button' disabled className='spoiler-button__overlay'>
+          <span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span>
+        </button>
+      );
+    } else if (visible) {
       spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
     } else {
       spoilerButton = (
@@ -365,7 +372,7 @@ export default class MediaGallery extends React.PureComponent {
 
     return (
       <div className={computedClass} style={style} ref={this.handleRef}>
-        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
+        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
           {spoilerButton}
           {visible && sensitive && (
             <span className='sensitive-marker'>
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index 690f9ae5a..36c4b236c 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -4,11 +4,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import classNames from 'classnames';
-import { vote, fetchPoll } from 'mastodon/actions/polls';
-import Motion from 'mastodon/features/ui/util/optional_motion';
+import { vote, fetchPoll } from 'flavours/glitch/actions/polls';
+import Motion from 'flavours/glitch/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import escapeTextContentForBrowser from 'escape-html';
-import emojify from 'mastodon/features/emoji/emoji';
+import emojify from 'flavours/glitch/util/emoji';
 import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
diff --git a/app/javascript/flavours/glitch/components/radio_button.js b/app/javascript/flavours/glitch/components/radio_button.js
new file mode 100644
index 000000000..0496fa286
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/radio_button.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class RadioButton extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    checked: PropTypes.bool,
+    name: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+    label: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { name, value, checked, onChange, label } = this.props;
+
+    return (
+      <label className='radio-button'>
+        <input
+          name={name}
+          type='radio'
+          value={value}
+          checked={checked}
+          onChange={onChange}
+        />
+
+        <span className={classNames('radio-button__input', { checked })} />
+
+        <span>{label}</span>
+      </label>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 88994c2ac..e7bf1f4d0 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -10,7 +10,7 @@ import AttachmentList from './attachment_list';
 import Card from '../features/status/components/card';
 import { injectIntl, FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { MediaGallery, Video } from 'flavours/glitch/util/async-components';
+import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components';
 import { HotKeys } from 'react-hotkeys';
 import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
 import classNames from 'classnames';
@@ -443,11 +443,15 @@ class Status extends ImmutablePureComponent {
   }
 
   renderLoadingMediaGallery () {
-    return <div className='media_gallery' style={{ height: '110px' }} />;
+    return <div className='media-gallery' style={{ height: '110px' }} />;
   }
 
   renderLoadingVideoPlayer () {
-    return <div className='media-spoiler-video' style={{ height: '110px' }} />;
+    return <div className='video-player' style={{ height: '110px' }} />;
+  }
+
+  renderLoadingAudioPlayer () {
+    return <div className='audio-player' style={{ height: '110px' }} />;
   }
 
   render () {
@@ -561,7 +565,24 @@ class Status extends ImmutablePureComponent {
             media={status.get('media_attachments')}
           />
         );
-      } else if (['video', 'audio'].includes(attachments.getIn([0, 'type']))) {
+      } else if (attachments.getIn([0, 'type']) === 'audio') {
+        const attachment = status.getIn(['media_attachments', 0]);
+
+        media = (
+          <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
+            {Component => (
+              <Component
+                src={attachment.get('url')}
+                alt={attachment.get('description')}
+                duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
+                peaks={[0]}
+                height={70}
+              />
+            )}
+          </Bundle>
+        );
+        mediaIcon = 'music';
+      } else if (attachments.getIn([0, 'type']) === 'video') {
         const attachment = status.getIn(['media_attachments', 0]);
 
         media = (
@@ -584,7 +605,7 @@ class Status extends ImmutablePureComponent {
             />)}
           </Bundle>
         );
-        mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music';
+        mediaIcon = 'video-camera';
       } else {  //  Media type is 'image' or 'gifv'
         media = (
           <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
@@ -702,6 +723,7 @@ class Status extends ImmutablePureComponent {
             parseClick={parseClick}
             disabled={!router}
             tagLinks={settings.get('tag_misleading_links')}
+            rewriteMentions={settings.get('rewrite_mentions')}
           />
           {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
             <StatusActionBar
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 95a4fe3fa..c34464fde 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -67,10 +67,12 @@ export default class StatusContent extends React.PureComponent {
     disabled: PropTypes.bool,
     onUpdate: PropTypes.func,
     tagLinks: PropTypes.bool,
+    rewriteMentions: PropTypes.string,
   };
 
   static defaultProps = {
     tagLinks: true,
+    rewriteMentions: 'no',
   };
 
   state = {
@@ -79,7 +81,7 @@ export default class StatusContent extends React.PureComponent {
 
   _updateStatusLinks () {
     const node = this.contentsNode;
-    const { tagLinks } = this.props;
+    const { tagLinks, rewriteMentions } = this.props;
 
     if (!node) {
       return;
@@ -99,6 +101,13 @@ export default class StatusContent extends React.PureComponent {
       if (mention) {
         link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
         link.setAttribute('title', mention.get('acct'));
+        if (rewriteMentions !== 'no') {
+          while (link.firstChild) link.removeChild(link.firstChild);
+          link.appendChild(document.createTextNode('@'));
+          const acctSpan = document.createElement('span');
+          acctSpan.textContent = rewriteMentions === 'acct' ? mention.get('acct') : mention.get('username');
+          link.appendChild(acctSpan);
+        }
       } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
         link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
       } else {
@@ -203,7 +212,7 @@ export default class StatusContent extends React.PureComponent {
 
     let element = e.target;
     while (element) {
-      if (element.localName === 'button' || element.localName === 'video' || element.localName === 'a' || element.localName === 'label') {
+      if (['button', 'video', 'a', 'label', 'wave'].includes(element.localName)) {
         return;
       }
       element = element.parentNode;
@@ -242,6 +251,7 @@ export default class StatusContent extends React.PureComponent {
       parseClick,
       disabled,
       tagLinks,
+      rewriteMentions,
     } = this.props;
 
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
@@ -340,7 +350,7 @@ export default class StatusContent extends React.PureComponent {
         >
           <div
             ref={this.setContentsRef}
-            key={`contents-${tagLinks}`}
+            key={`contents-${tagLinks}-${rewriteMentions}`}
             dangerouslySetInnerHTML={content}
             lang={status.get('language')}
             className='status__content__text'
diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js
index 1b480658f..41547412e 100644
--- a/app/javascript/flavours/glitch/containers/media_container.js
+++ b/app/javascript/flavours/glitch/containers/media_container.js
@@ -7,6 +7,8 @@ import MediaGallery from 'flavours/glitch/components/media_gallery';
 import Video from 'flavours/glitch/features/video';
 import Card from 'flavours/glitch/features/status/components/card';
 import Poll from 'flavours/glitch/components/poll';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import Audio from 'flavours/glitch/features/audio';
 import ModalRoot from 'flavours/glitch/components/modal_root';
 import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
 import { List as ImmutableList, fromJS } from 'immutable';
@@ -14,7 +16,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
 const { localeData, messages } = getLocale();
 addLocaleData(localeData);
 
-const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll };
+const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
 
 export default class MediaContainer extends PureComponent {
 
@@ -55,12 +57,13 @@ export default class MediaContainer extends PureComponent {
           {[].map.call(components, (component, i) => {
             const componentName = component.getAttribute('data-component');
             const Component = MEDIA_COMPONENTS[componentName];
-            const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props'));
+            const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props'));
 
             Object.assign(props, {
-              ...(media ? { media: fromJS(media) } : {}),
-              ...(card  ? { card:  fromJS(card)  } : {}),
-              ...(poll  ? { poll:  fromJS(poll)  } : {}),
+              ...(media   ? { media:   fromJS(media)   } : {}),
+              ...(card    ? { card:    fromJS(card)    } : {}),
+              ...(poll    ? { poll:    fromJS(poll)    } : {}),
+              ...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
 
               ...(componentName === 'Video' ? {
                 onOpenVideo: this.handleOpenVideo,
@@ -74,6 +77,7 @@ export default class MediaContainer extends PureComponent {
               component,
             );
           })}
+
           <ModalRoot onClose={this.handleCloseMedia}>
             {this.state.media && (
               <MediaModal
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index b0072533c..2c0ad74db 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { autoPlayGif, me, isStaff } from 'flavours/glitch/util/initial_state';
+import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links';
 import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 import Avatar from 'flavours/glitch/components/avatar';
@@ -69,7 +70,7 @@ class Header extends ImmutablePureComponent {
   };
 
   openEditProfile = () => {
-    window.open('/settings/profile', '_blank');
+    window.open(profileLink, '_blank');
   }
 
   _updateEmojis () {
@@ -148,7 +149,7 @@ class Header extends ImmutablePureComponent {
       } else if (account.getIn(['relationship', 'blocking'])) {
         actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
       }
-    } else {
+    } else if (profileLink) {
       actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
     }
 
@@ -172,8 +173,8 @@ class Header extends ImmutablePureComponent {
     }
 
     if (account.get('id') === me) {
-      menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
-      menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
+      if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
+      if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
       menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
@@ -223,9 +224,9 @@ class Header extends ImmutablePureComponent {
       }
     }
 
-    if (account.get('id') !== me && isStaff) {
+    if (account.get('id') !== me && isStaff && accountAdminLink) {
       menu.push(null);
-      menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` });
+      menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) });
     }
 
     const content          = { __html: account.get('note_emojified') };
diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js
new file mode 100644
index 000000000..0830a4684
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/audio/index.js
@@ -0,0 +1,226 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import WaveSurfer from 'wavesurfer.js';
+import { defineMessages, injectIntl } from 'react-intl';
+import { formatTime } from 'flavours/glitch/features/video';
+import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+import { throttle } from 'lodash';
+
+const messages = defineMessages({
+  play: { id: 'video.play', defaultMessage: 'Play' },
+  pause: { id: 'video.pause', defaultMessage: 'Pause' },
+  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+});
+
+export default @injectIntl
+class Audio extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
+    duration: PropTypes.number,
+    peaks: PropTypes.arrayOf(PropTypes.number),
+    height: PropTypes.number,
+    preload: PropTypes.bool,
+    editable: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    currentTime: 0,
+    duration: null,
+    paused: true,
+    muted: false,
+    volume: 0.5,
+  };
+
+  // hard coded in components.scss
+  // any way to get ::before values programatically?
+
+  volWidth = 50;
+
+  volOffset = 70;
+
+  volHandleOffset = v => {
+    const offset = v * this.volWidth + this.volOffset;
+    return (offset > 110) ? 110 : offset;
+  }
+
+  setVolumeRef = c => {
+    this.volume = c;
+  }
+
+  setWaveformRef = c => {
+    this.waveform = c;
+  }
+
+  componentDidMount () {
+    if (this.waveform) {
+      this._updateWaveform();
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.waveform && prevProps.src !== this.props.src) {
+      this._updateWaveform();
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.wavesurfer) {
+      this.wavesurfer.destroy();
+      this.wavesurfer = null;
+    }
+  }
+
+  _updateWaveform () {
+    const { src, height, duration, peaks, preload } = this.props;
+
+    const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
+    const waveColor     = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
+
+    if (this.wavesurfer) {
+      this.wavesurfer.destroy();
+      this.loaded = false;
+    }
+
+    const wavesurfer = WaveSurfer.create({
+      container: this.waveform,
+      height,
+      barWidth: 3,
+      cursorWidth: 0,
+      progressColor,
+      waveColor,
+      backend: 'MediaElement',
+      interact: preload,
+    });
+
+    wavesurfer.setVolume(this.state.volume);
+
+    if (preload) {
+      wavesurfer.load(src);
+      this.loaded = true;
+    } else {
+      wavesurfer.load(src, peaks, 'none', duration);
+      this.loaded = false;
+    }
+
+    wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
+    wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
+    wavesurfer.on('pause', () => this.setState({ paused: true }));
+    wavesurfer.on('play', () => this.setState({ paused: false }));
+    wavesurfer.on('volume', volume => this.setState({ volume }));
+    wavesurfer.on('mute', muted => this.setState({ muted }));
+
+    this.wavesurfer = wavesurfer;
+  }
+
+  togglePlay = () => {
+    if (this.state.paused) {
+      if (!this.props.preload && !this.loaded) {
+        this.wavesurfer.createBackend();
+        this.wavesurfer.createPeakCache();
+        this.wavesurfer.load(this.props.src);
+        this.wavesurfer.toggleInteraction();
+        this.loaded = true;
+      }
+
+      this.wavesurfer.play();
+      this.setState({ paused: false });
+    } else {
+      this.wavesurfer.pause();
+      this.setState({ paused: true });
+    }
+  }
+
+  toggleMute = () => {
+    this.wavesurfer.setMute(!this.state.muted);
+  }
+
+  handleVolumeMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.addEventListener('touchend', this.handleVolumeMouseUp, true);
+
+    this.handleMouseVolSlide(e);
+
+    e.preventDefault();
+    e.stopPropagation();
+  }
+
+  handleVolumeMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+  }
+
+  handleMouseVolSlide = throttle(e => {
+    const rect = this.volume.getBoundingClientRect();
+    const x    = (e.clientX - rect.left) / this.volWidth; // x position within the element.
+
+    if(!isNaN(x)) {
+      let slideamt = x;
+
+      if (x > 1) {
+        slideamt = 1;
+      } else if(x < 0) {
+        slideamt = 0;
+      }
+
+      this.wavesurfer.setVolume(slideamt);
+    }
+  }, 60);
+
+  render () {
+    const { height, intl, alt, editable } = this.props;
+    const { paused, muted, volume, currentTime } = this.state;
+
+    const volumeWidth     = muted ? 0 : volume * this.volWidth;
+    const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
+
+    return (
+      <div className={classNames('audio-player', { editable })}>
+        <div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
+        <div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
+
+        <div
+          className='audio-player__waveform'
+          aria-label={alt}
+          title={alt}
+          style={{ height }}
+          ref={this.setWaveformRef}
+        />
+
+        <div className='video-player__controls active'>
+          <div className='video-player__buttons-bar'>
+            <div className='video-player__buttons left'>
+              <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon icon={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon icon={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+
+              <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
+                <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
+
+                <span
+                  className={classNames('video-player__volume__handle')}
+                  tabIndex='0'
+                  style={{ left: `${volumeHandleLoc}px` }}
+                />
+              </div>
+
+              <span>
+                <span className='video-player__time-current'>{formatTime(currentTime)}</span>
+                <span className='video-player__time-sep'>/</span>
+                <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js
index 2e29084f2..8b0f540ef 100644
--- a/app/javascript/flavours/glitch/features/compose/components/header.js
+++ b/app/javascript/flavours/glitch/features/compose/components/header.js
@@ -53,8 +53,18 @@ class Header extends ImmutablePureComponent {
     showNotificationsBadge: PropTypes.bool,
     intl: PropTypes.object,
     onSettingsClick: PropTypes.func,
+    onLogout: PropTypes.func.isRequired,
   };
 
+  handleLogoutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+
+    return false;
+  }
+
   render () {
     const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
 
@@ -114,7 +124,7 @@ class Header extends ImmutablePureComponent {
         ><Icon icon='cogs' /></a>
         <a
           aria-label={intl.formatMessage(messages.logout)}
-          data-method='delete'
+          onClick={this.handleLogoutClick}
           href={ signOutLink }
           title={intl.formatMessage(messages.logout)}
         ><Icon icon='sign-out' /></a>
diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
index ce1dea319..b4dcb4d56 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
@@ -1,6 +1,13 @@
 import { openModal } from 'flavours/glitch/actions/modal';
 import { connect }   from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
 import Header from '../components/header';
+import { logOut } from 'flavours/glitch/util/log_out';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
 
 const mapStateToProps = state => {
   return {
@@ -16,6 +23,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     e.stopPropagation();
     dispatch(openModal('SETTINGS', {}));
   },
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      onConfirm: () => logOut(),
+    }));
+  },
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(Header);
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
index 1f714ff83..5c2c1be23 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
@@ -1,7 +1,7 @@
 import { connect } from 'react-redux';
 import SearchResults from '../components/search_results';
-import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
-import { expandSearch } from 'mastodon/actions/search';
+import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
+import { expandSearch } from 'flavours/glitch/actions/search';
 
 const mapStateToProps = state => ({
   results: state.getIn(['search', 'results']),
diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
index fdd21f114..b9b0a2644 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
@@ -4,6 +4,7 @@ import Warning from '../components/warning';
 import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
 import { me } from 'flavours/glitch/util/initial_state';
+import { profileLink, termsLink } from 'flavours/glitch/util/backend_links';
 
 const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
 
@@ -15,7 +16,7 @@ const mapStateToProps = state => ({
 
 const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
   if (needsLockWarning) {
-    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
+    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
   }
 
   if (hashtagWarning) {
@@ -25,7 +26,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
   if (directMessageWarning) {
     const message = (
       <span>
-        <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
+        <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> {!!termsLink && <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>}
       </span>
     );
 
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js
new file mode 100644
index 000000000..d1c406933
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js
@@ -0,0 +1,190 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import Permalink from 'flavours/glitch/components/permalink';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+import { autoPlayGif, me, unfollowModal } from 'flavours/glitch/util/initial_state';
+import { shortNumberFormat } from 'flavours/glitch/util/numbers';
+import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'flavours/glitch/actions/accounts';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+
+const messages = defineMessages({
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { id }) => ({
+    account: getAccount(state, id),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onFollow (account) {
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+      if (unfollowModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.unfollowConfirm),
+          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        }));
+      } else {
+        dispatch(unfollowAccount(account.get('id')));
+      }
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(blockAccount(account.get('id')));
+    }
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(initMuteModal(account));
+    }
+  },
+
+});
+
+export default @injectIntl
+@connect(makeMapStateToProps, mapDispatchToProps)
+class AccountCard extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+  };
+
+  _updateEmojis () {
+    const node = this.node;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  componentDidMount () {
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateEmojis();
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
+  handleFollow = () => {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock = () => {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMute = () => {
+    this.props.onMute(this.props.account);
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  render () {
+    const { account, intl } = this.props;
+
+    let buttons;
+
+    if (account.get('id') !== me && account.get('relationship', null) !== null) {
+      const following = account.getIn(['relationship', 'following']);
+      const requested = account.getIn(['relationship', 'requested']);
+      const blocking  = account.getIn(['relationship', 'blocking']);
+      const muting    = account.getIn(['relationship', 'muting']);
+
+      if (requested) {
+        buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
+      } else if (blocking) {
+        buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
+      } else if (muting) {
+        buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
+      } else if (!account.get('moved') || following) {
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      }
+    }
+
+    return (
+      <div className='directory__card'>
+        <div className='directory__card__img'>
+          <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
+        </div>
+
+        <div className='directory__card__bar'>
+          <Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+            <Avatar account={account} size={48} />
+            <DisplayName account={account} />
+          </Permalink>
+
+          <div className='directory__card__bar__relationship account__relationship'>
+            {buttons}
+          </div>
+        </div>
+
+        <div className='directory__card__extra' ref={this.setRef}>
+          <div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
+        </div>
+
+        <div className='directory__card__extra'>
+          <div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div>
+          <div className='accounts-table__count'>{account.get('followers_count') < 0 ? '-' : shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div>
+          <div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/directory/index.js b/app/javascript/flavours/glitch/features/directory/index.js
new file mode 100644
index 000000000..858a8fa55
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/directory/index.js
@@ -0,0 +1,171 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/glitch/actions/columns';
+import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directory';
+import { List as ImmutableList } from 'immutable';
+import AccountCard from './components/account_card';
+import RadioButton from 'flavours/glitch/components/radio_button';
+import classNames from 'classnames';
+import LoadMore from 'flavours/glitch/components/load_more';
+import { ScrollContainer } from 'react-router-scroll-4';
+
+const messages = defineMessages({
+  title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
+  recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
+  newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
+  local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
+  federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
+  isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
+  domain: state.getIn(['meta', 'domain']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Directory extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    isLoading: PropTypes.bool,
+    accountIds: ImmutablePropTypes.list.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    shouldUpdateScroll: PropTypes.func,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
+    params: PropTypes.shape({
+      order: PropTypes.string,
+      local: PropTypes.bool,
+    }),
+  };
+
+  state = {
+    order: null,
+    local: null,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
+    }
+  }
+
+  getParams = (props, state) => ({
+    order: state.order === null ? (props.params.order || 'active') : state.order,
+    local: state.local === null ? (props.params.local || false) : state.local,
+  });
+
+  handleMove = dir => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchDirectory(this.getParams(this.props, this.state)));
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    const { dispatch } = this.props;
+    const paramsOld = this.getParams(prevProps, prevState);
+    const paramsNew = this.getParams(this.props, this.state);
+
+    if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
+      dispatch(fetchDirectory(paramsNew));
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleChangeOrder = e => {
+    const { dispatch, columnId } = this.props;
+
+    if (columnId) {
+      dispatch(changeColumnParams(columnId, ['order'], e.target.value));
+    } else {
+      this.setState({ order: e.target.value });
+    }
+  }
+
+  handleChangeLocal = e => {
+    const { dispatch, columnId } = this.props;
+
+    if (columnId) {
+      dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
+    } else {
+      this.setState({ local: e.target.value === '1' });
+    }
+  }
+
+  handleLoadMore = () => {
+    const { dispatch } = this.props;
+    dispatch(expandDirectory(this.getParams(this.props, this.state)));
+  }
+
+  render () {
+    const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
+    const { order, local }  = this.getParams(this.props, this.state);
+    const pinned = !!columnId;
+
+    const scrollableArea = (
+      <div className='scrollable' style={{ background: 'transparent' }}>
+        <div className='filter-form'>
+          <div className='filter-form__column' role='group'>
+            <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
+            <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
+          </div>
+
+          <div className='filter-form__column' role='group'>
+            <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
+            <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
+          </div>
+        </div>
+
+        <div className={classNames('directory__list', { loading: isLoading })}>
+          {accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)}
+        </div>
+
+        <LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
+      </div>
+    );
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='address-book-o'
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        />
+
+        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js
index c57a2b1a2..0f04b9ddc 100644
--- a/app/javascript/flavours/glitch/features/followers/index.js
+++ b/app/javascript/flavours/glitch/features/followers/index.js
@@ -60,7 +60,6 @@ export default class Followers extends ImmutablePureComponent {
   }
 
   handleLoadMore = debounce(() => {
-    e.preventDefault();
     this.props.dispatch(expandFollowers(this.props.params.accountId));
   }, 300, { leading: true });
 
diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js
index b61f83988..279c27500 100644
--- a/app/javascript/flavours/glitch/features/following/index.js
+++ b/app/javascript/flavours/glitch/features/following/index.js
@@ -60,7 +60,6 @@ export default class Following extends ImmutablePureComponent {
   }
 
   handleLoadMore = debounce(() => {
-    e.preventDefault();
     this.props.dispatch(expandFollowing(this.props.params.accountId));
   }, 300, { leading: true });
 
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/trends.js b/app/javascript/flavours/glitch/features/getting_started/components/trends.js
new file mode 100644
index 000000000..0734ec72b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/components/trends.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import { FormattedMessage } from 'react-intl';
+
+export default class Trends extends ImmutablePureComponent {
+
+  static defaultProps = {
+    loading: false,
+  };
+
+  static propTypes = {
+    trends: ImmutablePropTypes.list,
+    fetchTrends: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    this.props.fetchTrends();
+    this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
+  }
+
+  componentWillUnmount () {
+    if (this.refreshInterval) {
+      clearInterval(this.refreshInterval);
+    }
+  }
+
+  render () {
+    const { trends } = this.props;
+
+    if (!trends || trends.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='getting-started__trends'>
+        <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
+
+        {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
new file mode 100644
index 000000000..1df3fb4fe
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { fetchTrends } from '../../../actions/trends';
+import Trends from '../components/trends';
+
+const mapStateToProps = state => ({
+  trends: state.getIn(['trends', 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  fetchTrends: () => dispatch(fetchTrends()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Trends);
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js
index 36a445dca..68b5209dc 100644
--- a/app/javascript/flavours/glitch/features/getting_started/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -8,14 +8,15 @@ import { openModal } from 'flavours/glitch/actions/modal';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me } from 'flavours/glitch/util/initial_state';
+import { me, profile_directory, showTrends } from 'flavours/glitch/util/initial_state';
 import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
 import { List as ImmutableList } from 'immutable';
 import { createSelector } from 'reselect';
 import { fetchLists } from 'flavours/glitch/actions/lists';
-import { preferencesLink, signOutLink } from 'flavours/glitch/util/backend_links';
+import { preferencesLink } from 'flavours/glitch/util/backend_links';
 import NavigationBar from '../compose/components/navigation_bar';
 import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
+import TrendsContainer from './containers/trends_container';
 
 const messages = defineMessages({
   heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@@ -30,13 +31,13 @@ const messages = defineMessages({
   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
   settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
-  sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
   keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
   lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' },
   misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' },
   menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  profile_directory: { id: 'getting_started.directory', defaultMessage: 'Profile directory' },
 });
 
 const makeMapStateToProps = () => {
@@ -151,13 +152,17 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
       navItems.push(<ColumnLink key='6' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
     }
 
-    navItems.push(<ColumnLink key='7' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
+    if (profile_directory) {
+      navItems.push(<ColumnLink key='7' icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />);
+    }
+
+    navItems.push(<ColumnLink key='8' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
 
     listItems = listItems.concat([
-      <div key='8'>
-        <ColumnLink key='9' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
+      <div key='9'>
+        <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
         {lists.map(list =>
-          <ColumnLink key={(9 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
+          <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
         )}
       </div>,
     ]);
@@ -174,11 +179,12 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
             <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
             { preferencesLink !== undefined && <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> }
             <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={openSettings} />
-            <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href={signOutLink} method='delete' />
           </div>
 
           <LinkFooter />
         </div>
+
+        {multiColumn && showTrends && <TrendsContainer />}
       </Column>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
index 757cd48fb..de1db692d 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
@@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({
   },
 
   onLoad (value) {
-    return api().get('/api/v2/search', { params: { q: value } }).then(response => {
+    return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
       return (response.data.hashtags || []).map((tag) => {
         return { value: tag.name, label: `#${tag.name}` };
       });
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index bd92a81c2..64ed68876 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -25,6 +25,9 @@ const messages = defineMessages({
   filters_upstream: { id: 'settings.filtering_behavior.upstream', defaultMessage: 'Show "filtered" like vanilla Mastodon' },
   filters_hide: { id: 'settings.filtering_behavior.hide', defaultMessage: 'Show "filtered" and add a button to display why' },
   filters_cw: { id: 'settings.filtering_behavior.cw', defaultMessage: 'Still display the post, and add filtered words to content warning' },
+  rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' },
+  rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' },
+  rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage:  'Rewrite with username' },
 });
 
 @injectIntl
@@ -75,6 +78,19 @@ export default class LocalSettingsPage extends React.PureComponent {
           <FormattedMessage id='settings.tag_misleading_links' defaultMessage='Tag misleading links' />
           <span className='hint'><FormattedMessage id='settings.tag_misleading_links.hint' defaultMessage="Add a visual indication with the link target host to every link not mentioning it explicitly" /></span>
         </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['rewrite_mentions']}
+          id='mastodon-settings--rewrite_mentions'
+          options={[
+            { value: 'no', message: intl.formatMessage(messages.rewrite_mentions_no) },
+            { value: 'acct', message: intl.formatMessage(messages.rewrite_mentions_acct) },
+            { value: 'username', message: intl.formatMessage(messages.rewrite_mentions_username) },
+          ]}
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.rewrite_mentions' defaultMessage='Rewrite mentions in displayed statuses' />
+        </LocalSettingsPageItem>
         <section>
           <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2>
           <LocalSettingsPageItem
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index fa4ed2fd5..5242c7d5c 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -11,6 +11,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from 'flavours/glitch/features/video';
+import Audio from 'flavours/glitch/features/audio';
 import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
 import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
 import classNames from 'classnames';
@@ -131,7 +132,20 @@ export default class DetailedStatus extends ImmutablePureComponent {
     } else if (status.get('media_attachments').size > 0) {
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
         media = <AttachmentList media={status.get('media_attachments')} />;
-      } else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+        const attachment = status.getIn(['media_attachments', 0]);
+
+        media = (
+          <Audio
+            src={attachment.get('url')}
+            alt={attachment.get('description')}
+            duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
+            height={110}
+            preload
+          />
+        );
+        mediaIcon = 'music';
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
         const attachment = status.getIn(['media_attachments', 0]);
         media = (
           <Video
@@ -150,7 +164,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
             onToggleVisibility={this.props.onToggleMediaVisibility}
           />
         );
-        mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music';
+        mediaIcon = 'video-camera';
       } else {
         media = (
           <MediaGallery
@@ -242,6 +256,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
             parseClick={this.parseClick}
             onUpdate={this.handleChildUpdate}
             tagLinks={settings.get('tag_misleading_links')}
+            rewriteMentions={settings.get('rewrite_mentions')}
             disabled
           />
 
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 40e100fd5..58b8a8cbb 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -82,28 +82,38 @@ const makeMapStateToProps = () => {
   const getDescendantsIds = createSelector([
     (_, { id }) => id,
     state => state.getIn(['contexts', 'replies']),
-  ], (statusId, contextReplies) => {
-    let descendantsIds = Immutable.List();
-    descendantsIds = descendantsIds.withMutations(mutable => {
-      const ids = [statusId];
+    state => state.get('statuses'),
+  ], (statusId, contextReplies, statuses) => {
+    let descendantsIds = [];
+    const ids = [statusId];
 
-      while (ids.length > 0) {
-        let id        = ids.shift();
-        const replies = contextReplies.get(id);
+    while (ids.length > 0) {
+      let id        = ids.shift();
+      const replies = contextReplies.get(id);
 
-        if (statusId !== id) {
-          mutable.push(id);
-        }
+      if (statusId !== id) {
+        descendantsIds.push(id);
+      }
 
-        if (replies) {
-          replies.reverse().forEach(reply => {
-            ids.unshift(reply);
-          });
-        }
+      if (replies) {
+        replies.reverse().forEach(reply => {
+          ids.unshift(reply);
+        });
       }
-    });
+    }
+
+    let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
+    if (insertAt !== -1) {
+      descendantsIds.forEach((id, idx) => {
+        if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
+          descendantsIds.splice(idx, 1);
+          descendantsIds.splice(insertAt, 0, id);
+          insertAt += 1;
+        }
+      });
+    }
 
-    return descendantsIds;
+    return Immutable.List(descendantsIds);
   });
 
   const mapStateToProps = (state, props) => {
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 30097f064..46df1f4ef 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -12,7 +12,19 @@ import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
 import BundleColumnError from './bundle_column_error';
-import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from 'flavours/glitch/util/async-components';
+import {
+  Compose,
+  Notifications,
+  HomeTimeline,
+  CommunityTimeline,
+  PublicTimeline,
+  HashtagTimeline,
+  DirectTimeline,
+  FavouritedStatuses,
+  BookmarkedStatuses,
+  ListTimeline,
+  Directory,
+} from 'flavours/glitch/util/async-components';
 import ComposePanel from './compose_panel';
 import NavigationPanel from './navigation_panel';
 
@@ -30,6 +42,7 @@ const componentMap = {
   'FAVOURITES': FavouritedStatuses,
   'BOOKMARKS': BookmarkedStatuses,
   'LIST': ListTimeline,
+  'DIRECTORY': Directory,
 };
 
 const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/);
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index c4cc18f94..7d1deb4ce 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import IconButton from 'flavours/glitch/components/icon_button';
 import Button from 'flavours/glitch/components/button';
 import Video from 'flavours/glitch/features/video';
+import Audio from 'flavours/glitch/features/audio';
 import Textarea from 'react-textarea-autosize';
 import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
 import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter';
@@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
               </div>
             )}
 
-            {['audio', 'video'].includes(media.get('type')) && (
+            {media.get('type') === 'video' && (
               <Video
                 preview={media.get('preview_url')}
                 blurhash={media.get('blurhash')}
                 src={media.get('url')}
                 detailed
+                inline
+                editable
+              />
+            )}
+
+            {media.get('type') === 'audio' && (
+              <Audio
+                src={media.get('url')}
+                duration={media.getIn(['meta', 'original', 'duration'], 0)}
+                height={150}
+                preload
                 editable
               />
             )}
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
index e63ed274e..04a2e9761 100644
--- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
@@ -1,8 +1,10 @@
+import { connect } from 'react-redux';
 import React from 'react';
 import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { Link } from 'react-router-dom';
 import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state';
+<<<<<<< HEAD
 import { signOutLink } from 'flavours/glitch/util/backend_links';
 
 const LinkFooter = () => (
@@ -33,5 +35,70 @@ const LinkFooter = () => (
 
 LinkFooter.propTypes = {
 };
+=======
+import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links';
+import { logOut } from 'flavours/glitch/util/log_out';
+import { openModal } from 'flavours/glitch/actions/modal';
 
-export default LinkFooter;
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default @injectIntl
+@connect(null, mapDispatchToProps)
+class LinkFooter extends React.PureComponent {
+
+  static propTypes = {
+    onLogout: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+>>>>>>> glitch
+
+  handleLogoutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+ 
+    return false;
+  }
+
+  render () {
+    return (
+      <div className='getting-started__footer'>
+        <ul>
+          {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
+          {!!securityLink && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>}
+          <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
+          <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
+          <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
+          <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
+          <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
+          <li><a href={signOutLink} onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
+        </ul>
+
+        <p>
+          <FormattedMessage
+            id='getting_started.open_source_notice'
+            defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
+            values={{
+              github: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>,
+              Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }}
+          />
+        </p>
+      </div>
+    );
+  }
+
+};
diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
index 4688c7766..a4f06f4c5 100644
--- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
@@ -2,10 +2,12 @@ import React from 'react';
 import { NavLink, withRouter } from 'react-router-dom';
 import { FormattedMessage } from 'react-intl';
 import Icon from 'flavours/glitch/components/icon';
-import { profile_directory } from 'flavours/glitch/util/initial_state';
+import { profile_directory, showTrends } from 'flavours/glitch/util/initial_state';
+import { preferencesLink, relationshipsLink } from 'flavours/glitch/util/backend_links';
 import NotificationsCounterIcon from './notifications_counter_icon';
 import FollowRequestsNavLink from './follow_requests_nav_link';
 import ListPanel from './list_panel';
+import TrendsContainer from 'flavours/glitch/features/getting_started/containers/trends_container';
 
 const NavigationPanel = ({ onOpenSettings }) => (
   <div className='navigation-panel'>
@@ -16,16 +18,19 @@ const NavigationPanel = ({ onOpenSettings }) => (
     <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' icon='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' icon='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' icon='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
+    {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' icon='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>}
     <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' icon='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
 
     <ListPanel />
 
     <hr />
 
-    <a className='column-link column-link--transparent' href='/settings/preferences' target='_blank'><Icon className='column-link__icon' icon='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
+    {!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' icon='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>}
     <a className='column-link column-link--transparent' href='#' onClick={onOpenSettings}><Icon className='column-link__icon' icon='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a>
-    <a className='column-link column-link--transparent' href='/relationships' target='_blank'><Icon className='column-link__icon' icon='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>
-    {!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>}
+    {!!relationshipsLink && <a className='column-link column-link--transparent' href={relationshipsLink} target='_blank'><Icon className='column-link__icon' icon='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>}
+
+    {showTrends && <div className='flex-spacer' />}
+    {showTrends && <TrendsContainer />}
   </div>
 );
 
diff --git a/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
index 283aa2373..82278a3be 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
@@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
     const value = notification[key];
 
     if (typeof value === 'object') {
-      notification[key] = intl.formatMessage(value);
+      notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
     }
   }));
 
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index e072c22ec..1feda0b97 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -46,6 +46,7 @@ import {
   Lists,
   Search,
   GettingStartedMisc,
+  Directory,
 } from 'flavours/glitch/util/async-components';
 import { HotKeys } from 'react-hotkeys';
 import { me } from 'flavours/glitch/util/initial_state';
@@ -104,10 +105,119 @@ const keyMap = {
   toggleSensitive: 'h',
 };
 
-@connect(mapStateToProps)
+class SwitchingColumnsArea extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node,
+    layout: PropTypes.string,
+    location: PropTypes.object,
+    navbarUnder: PropTypes.bool,
+    onLayoutChange: PropTypes.func.isRequired,
+  };
+
+  state = {
+    mobile: isMobile(window.innerWidth, this.props.layout),
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.layout !== this.props.layout) {
+      this.setState({ mobile: isMobile(window.innerWidth, nextProps.layout) });
+    }
+  }
+
+  componentWillMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentDidUpdate (prevProps) {
+    if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+      this.node.handleChildrenContentChange();
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
+  handleLayoutChange = debounce(() => {
+    // The cached heights are no longer accurate, invalidate
+    this.props.onLayoutChange();
+  }, 500, {
+    trailing: true,
+  })
+
+  handleResize = () => {
+    const mobile = isMobile(window.innerWidth, this.props.layout);
+
+    if (mobile !== this.state.mobile) {
+      this.handleLayoutChange.cancel();
+      this.props.onLayoutChange();
+      this.setState({ mobile });
+    } else {
+      this.handleLayoutChange();
+    }
+  }
+
+  setRef = c => {
+    this.node = c.getWrappedInstance();
+  }
+
+  render () {
+    const { children, navbarUnder } = this.props;
+    const singleColumn = this.state.mobile;
+    const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
+
+    return (
+      <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn} navbarUnder={navbarUnder}>
+        <WrappedSwitch>
+          {redirect}
+          <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
+          <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
+          <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
+          <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
+          <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} />
+          <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
+          <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
+
+          <WrappedRoute path='/notifications' component={Notifications} content={children} />
+          <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+          <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
+          <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
+
+          <WrappedRoute path='/search' component={Search} content={children} />
+          <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
+
+          <WrappedRoute path='/statuses/new' component={Compose} content={children} />
+          <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
+          <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
+          <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
+
+          <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
+          <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
+          <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
+          <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
+          <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
+
+          <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
+          <WrappedRoute path='/blocks' component={Blocks} content={children} />
+          <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
+          <WrappedRoute path='/mutes' component={Mutes} content={children} />
+          <WrappedRoute path='/lists' component={Lists} content={children} />
+          <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
+
+          <WrappedRoute component={GenericNotFound} content={children} />
+        </WrappedSwitch>
+      </ColumnsAreaContainer>
+    );
+  };
+
+}
+
+export default @connect(mapStateToProps)
 @injectIntl
 @withRouter
-export default class UI extends React.Component {
+class UI extends React.Component {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
@@ -129,7 +239,6 @@ export default class UI extends React.Component {
   };
 
   state = {
-    width: window.innerWidth,
     draggingOver: false,
   };
 
@@ -144,14 +253,10 @@ export default class UI extends React.Component {
     }
   }
 
-  handleResize = debounce(() => {
+  handleLayoutChange = () => {
     // The cached heights are no longer accurate, invalidate
     this.props.dispatch(clearHeight());
-
-    this.setState({ width: window.innerWidth });
-  }, 500, {
-    trailing: true,
-  });
+  }
 
   handleDragEnter = (e) => {
     e.preventDefault();
@@ -246,7 +351,6 @@ export default class UI extends React.Component {
     }
 
     window.addEventListener('beforeunload', this.handleBeforeUnload, false);
-    window.addEventListener('resize', this.handleResize, { passive: true });
     document.addEventListener('dragenter', this.handleDragEnter, false);
     document.addEventListener('dragover', this.handleDragOver, false);
     document.addEventListener('drop', this.handleDrop, false);
@@ -271,13 +375,14 @@ export default class UI extends React.Component {
   }
 
   componentDidUpdate (prevProps) {
-    if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
-      this.columnsAreaNode.handleChildrenContentChange();
-    }
     if (this.props.unreadNotifications != prevProps.unreadNotifications ||
         this.props.showFaviconBadge != prevProps.showFaviconBadge) {
       if (this.favicon) {
-        this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0);
+        try {
+          this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0);
+        } catch (err) {
+          console.error(err);
+        }
       }
     }
   }
@@ -288,7 +393,6 @@ export default class UI extends React.Component {
     }
 
     window.removeEventListener('beforeunload', this.handleBeforeUnload);
-    window.removeEventListener('resize', this.handleResize);
     document.removeEventListener('dragenter', this.handleDragEnter);
     document.removeEventListener('dragover', this.handleDragOver);
     document.removeEventListener('drop', this.handleDrop);
@@ -300,10 +404,6 @@ export default class UI extends React.Component {
     this.node = c;
   }
 
-  setColumnsAreaRef = c => {
-    this.columnsAreaNode = c.getWrappedInstance();
-  }
-
   handleHotkeyNew = e => {
     e.preventDefault();
 
@@ -417,10 +517,8 @@ export default class UI extends React.Component {
   }
 
   render () {
-    const { width, draggingOver } = this.state;
-    const { children, layout, isWide, navbarUnder, dropdownMenuIsOpen } = this.props;
-    const singleColumn = isMobile(width, layout);
-    const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
+    const { draggingOver } = this.state;
+    const { children, layout, isWide, navbarUnder, location, dropdownMenuIsOpen } = this.props;
 
     const columnsClass = layout => {
       switch (layout) {
@@ -464,45 +562,9 @@ export default class UI extends React.Component {
     return (
       <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
         <div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
-          <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={singleColumn} navbarUnder={navbarUnder}>
-            <WrappedSwitch>
-              {redirect}
-              <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
-              <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
-              <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
-              <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
-              <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} />
-              <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
-              <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
-              <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
-              <WrappedRoute path='/notifications' component={Notifications} content={children} />
-              <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
-              <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
-              <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
-
-              <WrappedRoute path='/search' component={Search} content={children} />
-
-              <WrappedRoute path='/statuses/new' component={Compose} content={children} />
-              <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
-              <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
-              <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
-
-              <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
-              <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
-              <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
-              <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
-              <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
-
-              <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
-              <WrappedRoute path='/blocks' component={Blocks} content={children} />
-              <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
-              <WrappedRoute path='/mutes' component={Mutes} content={children} />
-              <WrappedRoute path='/lists' component={Lists} content={children} />
-              <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
-
-              <WrappedRoute component={GenericNotFound} content={children} />
-            </WrappedSwitch>
-          </ColumnsAreaContainer>
+          <SwitchingColumnsArea location={location} layout={layout} navbarUnder={navbarUnder} onLayoutChange={this.handleLayoutChange}>
+            {children}
+          </SwitchingColumnsArea>
 
           <NotificationsContainer />
           <LoadingBarContainer className='loading-bar' />
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index 6d5162519..24368bef9 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -20,7 +20,7 @@ const messages = defineMessages({
   exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
 });
 
-const formatTime = secondsNum => {
+export const formatTime = secondsNum => {
   let hours   = Math.floor(secondsNum / 3600);
   let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
   let seconds = secondsNum - (hours * 3600) - (minutes * 60);
diff --git a/app/javascript/flavours/glitch/reducers/alerts.js b/app/javascript/flavours/glitch/reducers/alerts.js
index 50f8d30f7..ee3d54ab0 100644
--- a/app/javascript/flavours/glitch/reducers/alerts.js
+++ b/app/javascript/flavours/glitch/reducers/alerts.js
@@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) {
       key: state.size > 0 ? state.last().get('key') + 1 : 0,
       title: action.title,
       message: action.message,
+      message_values: action.message_values,
     }));
   case ALERT_DISMISS:
     return state.filterNot(item => item.get('key') === action.alert.key);
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 5f176b832..adad205c0 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -231,15 +231,20 @@ const insertSuggestion = (state, position, token, completion, path) => {
   });
 };
 
-const updateSuggestionTags = (state, token) => {
-  const prefix = token.slice(1);
+const sortHashtagsByUse = (state, tags) => {
+  const personalHistory = state.get('tagHistory');
 
-  return state.merge({
-    suggestions: state.get('tagHistory')
-      .filter(tag => tag.toLowerCase().startsWith(prefix.toLowerCase()))
-      .slice(0, 4)
-      .map(tag => '#' + tag),
-    suggestion_token: token,
+  return tags.sort((a, b) => {
+    const usedA = personalHistory.includes(a.name);
+    const usedB = personalHistory.includes(b.name);
+
+    if (usedA === usedB) {
+      return 0;
+    } else if (usedA && !usedB) {
+      return 1;
+    } else {
+      return -1;
+    }
   });
 };
 
@@ -282,6 +287,36 @@ const expiresInFromExpiresAt = expires_at => {
   return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
 };
 
+const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
+  prefix = prefix.toLowerCase();
+  if (suggestions.length < 4) {
+    const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase()));
+    return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
+  } else {
+    return suggestions;
+  }
+};
+
+const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => {
+  if (accounts) {
+    return accounts.map(item => ({ id: item.id, type: 'account' }));
+  } else if (emojis) {
+    return emojis.map(item => ({ ...item, type: 'emoji' }));
+  } else {
+    return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory'));
+  }
+};
+
+const updateSuggestionTags = (state, token) => {
+  const prefix = token.slice(1);
+
+  const suggestions = state.get('suggestions').toJS();
+  return state.merge({
+    suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))),
+    suggestion_token: token,
+  });
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
@@ -412,7 +447,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_SUGGESTIONS_CLEAR:
     return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
   case COMPOSE_SUGGESTIONS_READY:
-    return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
+    return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
   case COMPOSE_SUGGESTION_SELECT:
     return insertSuggestion(state, action.position, action.token, action.completion, action.path);
   case COMPOSE_SUGGESTION_TAGS_UPDATE:
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index 266d87dc1..b03590194 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -33,6 +33,7 @@ import suggestions from './suggestions';
 import pinnedAccountsEditor from './pinned_accounts_editor';
 import polls from './polls';
 import identity_proofs from './identity_proofs';
+import trends from './trends';
 
 const reducers = {
   dropdown_menu,
@@ -69,6 +70,7 @@ const reducers = {
   suggestions,
   pinnedAccountsEditor,
   polls,
+  trends,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index 7477c5584..ad94ea243 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -23,6 +23,7 @@ const initialState = ImmutableMap({
   show_content_type_choice: false,
   filtering_behavior: 'hide',
   tag_misleading_links: true,
+  rewrite_mentions: 'no',
   content_warnings : ImmutableMap({
     auto_unfold : false,
     filter      : null,
diff --git a/app/javascript/flavours/glitch/reducers/polls.js b/app/javascript/flavours/glitch/reducers/polls.js
index 9956cf83f..595f340bc 100644
--- a/app/javascript/flavours/glitch/reducers/polls.js
+++ b/app/javascript/flavours/glitch/reducers/polls.js
@@ -1,4 +1,4 @@
-import { POLLS_IMPORT } from 'mastodon/actions/importer';
+import { POLLS_IMPORT } from 'flavours/glitch/actions/importer';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 
 const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll))));
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index a37863a69..9be27a02f 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -15,6 +15,10 @@ const initialState = ImmutableMap({
 
   skinTone: 1,
 
+  trends: ImmutableMap({
+    show: true,
+  }),
+
   home: ImmutableMap({
     shows: ImmutableMap({
       reblog: true,
diff --git a/app/javascript/flavours/glitch/reducers/suggestions.js b/app/javascript/flavours/glitch/reducers/suggestions.js
index 834be728f..a08fedc25 100644
--- a/app/javascript/flavours/glitch/reducers/suggestions.js
+++ b/app/javascript/flavours/glitch/reducers/suggestions.js
@@ -4,8 +4,8 @@ import {
   SUGGESTIONS_FETCH_FAIL,
   SUGGESTIONS_DISMISS,
 } from '../actions/suggestions';
-import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
-import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
+import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts';
+import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
 import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
 const initialState = ImmutableMap({
diff --git a/app/javascript/flavours/glitch/reducers/trends.js b/app/javascript/flavours/glitch/reducers/trends.js
new file mode 100644
index 000000000..5cecc8fca
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/trends.js
@@ -0,0 +1,23 @@
+import { TRENDS_FETCH_REQUEST, TRENDS_FETCH_SUCCESS, TRENDS_FETCH_FAIL } from '../actions/trends';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+});
+
+export default function trendsReducer(state = initialState, action) {
+  switch(action.type) {
+  case TRENDS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case TRENDS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.trends));
+      map.set('isLoading', false);
+    });
+  case TRENDS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js
index a4df9ec8d..b4e1d1eae 100644
--- a/app/javascript/flavours/glitch/reducers/user_lists.js
+++ b/app/javascript/flavours/glitch/reducers/user_lists.js
@@ -20,6 +20,14 @@ import {
   MUTES_FETCH_SUCCESS,
   MUTES_EXPAND_SUCCESS,
 } from 'flavours/glitch/actions/mutes';
+import {
+  DIRECTORY_FETCH_REQUEST,
+  DIRECTORY_FETCH_SUCCESS,
+  DIRECTORY_FETCH_FAIL,
+  DIRECTORY_EXPAND_REQUEST,
+  DIRECTORY_EXPAND_SUCCESS,
+  DIRECTORY_EXPAND_FAIL,
+} from 'flavours/glitch/actions/directory';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 const initialState = ImmutableMap({
@@ -74,6 +82,16 @@ export default function userLists(state = initialState, action) {
     return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
   case MUTES_EXPAND_SUCCESS:
     return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+  case DIRECTORY_FETCH_SUCCESS:
+    return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
+  case DIRECTORY_EXPAND_SUCCESS:
+    return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
+  case DIRECTORY_FETCH_REQUEST:
+  case DIRECTORY_EXPAND_REQUEST:
+    return state.setIn(['directory', 'isLoading'], true);
+  case DIRECTORY_FETCH_FAIL:
+  case DIRECTORY_EXPAND_FAIL:
+    return state.setIn(['directory', 'isLoading'], false);
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
index b414cd5e5..8ceb71d03 100644
--- a/app/javascript/flavours/glitch/selectors/index.js
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -157,6 +157,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => {
   base.forEach(item => {
     arr.push({
       message: item.get('message'),
+      message_values: item.get('message_values'),
       title: item.get('title'),
       key: item.get('key'),
       dismissAfter: 5000,
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index d2233207d..dc49e083c 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -415,6 +415,24 @@
       }
     }
   }
+
+  &.directory__section-headline {
+    background: darken($ui-base-color, 2%);
+    border-bottom-color: transparent;
+
+    a,
+    button {
+      &.active {
+        &::before {
+          display: none;
+        }
+
+        &::after {
+          border-color: transparent transparent darken($ui-base-color, 7%);
+        }
+      }
+    }
+  }
 }
 
 .account__moved-note {
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
index c4fa4f654..656615f4f 100644
--- a/app/javascript/flavours/glitch/styles/components/composer.scss
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -326,29 +326,46 @@
 }
 
 .autosuggest-textarea__suggestions__item {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: flex-start;
-  border-radius: 4px;
   padding: 10px;
-  font-size: 14px;
-  line-height: 18px;
-  overflow: hidden;
   cursor: pointer;
+  border-radius: 4px;
 
   &:hover,
   &:focus,
   &:active,
   &.selected { background: darken($ui-secondary-color, 10%) }
 
-  & > .emoji {
-    img {
-      display: block;
-      float: left;
-      margin-right: 8px;
-      width: 18px;
-      height: 18px;
+  > .account,
+  > .emoji,
+  > .autosuggest-hashtag {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: flex-start;
+    line-height: 18px;
+    font-size: 14px;
+  }
+
+  .autosuggest-hashtag {
+    justify-content: space-between;
+
+    &__name {
+      flex: 1 1 auto;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    strong {
+      font-weight: 500;
+    }
+
+    &__uses {
+      flex: 0 0 auto;
+      text-align: right;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
     }
   }
 
diff --git a/app/javascript/flavours/glitch/styles/components/directory.scss b/app/javascript/flavours/glitch/styles/components/directory.scss
new file mode 100644
index 000000000..b0ad5a88a
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/directory.scss
@@ -0,0 +1,180 @@
+.directory {
+  &__list {
+    width: 100%;
+    margin: 10px 0;
+    transition: opacity 100ms ease-in;
+
+    &.loading {
+      opacity: 0.7;
+    }
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      margin: 0;
+    }
+  }
+
+  &__card {
+    box-sizing: border-box;
+    margin-bottom: 10px;
+
+    &__img {
+      height: 125px;
+      position: relative;
+      background: darken($ui-base-color, 12%);
+      overflow: hidden;
+
+      img {
+        display: block;
+        width: 100%;
+        height: 100%;
+        margin: 0;
+        object-fit: cover;
+      }
+    }
+
+    &__bar {
+      display: flex;
+      align-items: center;
+      background: lighten($ui-base-color, 4%);
+      padding: 10px;
+
+      &__name {
+        flex: 1 1 auto;
+        display: flex;
+        align-items: center;
+        text-decoration: none;
+        overflow: hidden;
+      }
+
+      &__relationship {
+        width: 23px;
+        min-height: 1px;
+        flex: 0 0 auto;
+      }
+
+      .avatar {
+        flex: 0 0 auto;
+        width: 48px;
+        height: 48px;
+        padding-top: 2px;
+
+        img {
+          width: 100%;
+          height: 100%;
+          display: block;
+          margin: 0;
+          border-radius: 4px;
+          background: darken($ui-base-color, 8%);
+          object-fit: cover;
+        }
+      }
+
+      .display-name {
+        margin-left: 15px;
+        text-align: left;
+
+        strong {
+          font-size: 15px;
+          color: $primary-text-color;
+          font-weight: 500;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+
+        span {
+          display: block;
+          font-size: 14px;
+          color: $darker-text-color;
+          font-weight: 400;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+      }
+    }
+
+    &__extra {
+      background: $ui-base-color;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      .accounts-table__count {
+        width: 33.33%;
+        flex: 0 0 auto;
+        padding: 15px 0;
+      }
+
+      .account__header__content {
+        box-sizing: border-box;
+        padding: 15px 10px;
+        border-bottom: 1px solid lighten($ui-base-color, 8%);
+        width: 100%;
+        min-height: 18px + 30px;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+
+        p {
+          display: none;
+
+          &:first-child {
+            display: inline;
+          }
+        }
+
+        br {
+          display: none;
+        }
+      }
+    }
+  }
+}
+
+.filter-form {
+  background: $ui-base-color;
+
+  &__column {
+    padding: 10px 15px;
+  }
+
+  .radio-button {
+    display: block;
+  }
+}
+
+.radio-button {
+  font-size: 14px;
+  position: relative;
+  display: inline-block;
+  padding: 6px 0;
+  line-height: 18px;
+  cursor: default;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  cursor: pointer;
+
+  input[type=radio],
+  input[type=checkbox] {
+    display: none;
+  }
+
+  &__input {
+    display: inline-block;
+    position: relative;
+    border: 1px solid $ui-primary-color;
+    box-sizing: border-box;
+    width: 18px;
+    height: 18px;
+    flex: 0 0 auto;
+    margin-right: 10px;
+    top: -1px;
+    border-radius: 50%;
+    vertical-align: middle;
+
+    &.checked {
+      border-color: lighten($ui-highlight-color, 8%);
+      background: lighten($ui-highlight-color, 8%);
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index f453a046e..97c525565 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -847,7 +847,8 @@
 }
 
 .getting-started__wrapper,
-.getting_started {
+.getting_started,
+.flex-spacer {
   background: $ui-base-color;
 }
 
@@ -856,6 +857,10 @@
   overflow-y: auto;
 }
 
+.flex-spacer {
+  flex: 1 1 auto;
+}
+
 .getting-started {
   background: $ui-base-color;
   flex: 1 0 auto;
@@ -903,6 +908,47 @@
       }
     }
   }
+
+  &__trends {
+    flex: 0 1 auto;
+    opacity: 1;
+    animation: fade 150ms linear;
+    margin-top: 10px;
+
+    h4 {
+      font-size: 12px;
+      text-transform: uppercase;
+      color: $darker-text-color;
+      padding: 10px;
+      font-weight: 500;
+      border-bottom: 1px solid lighten($ui-base-color, 8%);
+    }
+
+    @media screen and (max-height: 810px) {
+      .trends__item:nth-child(3) {
+        display: none;
+      }
+    }
+
+    @media screen and (max-height: 720px) {
+      .trends__item:nth-child(2) {
+        display: none;
+      }
+    }
+
+    @media screen and (max-height: 670px) {
+      display: none;
+    }
+
+    .trends__item {
+      border-bottom: 0;
+      padding: 10px;
+
+      &__current {
+        color: $darker-text-color;
+      }
+    }
+  }
 }
 
 .column-link__badge {
@@ -1193,6 +1239,10 @@
     align-items: center;
   }
 
+  &--click-thru {
+    pointer-events: none;
+  }
+
   &--hidden {
     display: none;
   }
@@ -1221,6 +1271,12 @@
         background: rgba($base-overlay-background, 0.8);
       }
     }
+
+    &:disabled {
+      .spoiler-button__overlay__label {
+        background: rgba($base-overlay-background, 0.5);
+      }
+    }
   }
 }
 
@@ -1467,6 +1523,7 @@ noscript {
 @import 'composer';
 @import 'columns';
 @import 'regeneration_indicator';
+@import 'directory';
 @import 'search';
 @import 'emoji';
 @import 'doodle';
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index 39ffcae9d..85982d938 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -333,15 +333,63 @@
 
 }
 
+.audio-player {
+  box-sizing: border-box;
+  position: relative;
+  background: darken($ui-base-color, 8%);
+  border-radius: 4px;
+  padding-bottom: 44px;
+
+  &.editable {
+    border-radius: 0;
+    height: 100%;
+  }
+
+  &__waveform {
+    padding: 15px 0;
+    position: relative;
+    overflow: hidden;
+
+    &::before {
+      content: "";
+      display: block;
+      position: absolute;
+      border-top: 1px solid lighten($ui-base-color, 4%);
+      width: 100%;
+      height: 0;
+      left: 0;
+      top: calc(50% + 1px);
+    }
+  }
+
+  &__progress-placeholder {
+    background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);
+  }
+
+  &__wave-placeholder {
+    background-color: lighten($ui-base-color, 16%);
+  }
+
+  .video-player__controls {
+    padding: 0 15px;
+    padding-top: 10px;
+    background: darken($ui-base-color, 8%);
+    border-top: 1px solid lighten($ui-base-color, 4%);
+    border-radius: 0 0 4px 4px;
+  }
+}
+
 .video-player {
   overflow: hidden;
   position: relative;
   background: $base-shadow-color;
   max-width: 100%;
   border-radius: 4px;
+  box-sizing: border-box;
 
   &.editable {
     border-radius: 0;
+    height: 100% !important;
   }
 
   &:focus {
@@ -621,38 +669,13 @@
     }
   }
 
-&.detailed,
-&.fullscreen {
-  .video-player__buttons {
-    button {
-      padding-top: 10px;
-      padding-bottom: 10px;
+  &.detailed,
+  &.fullscreen {
+    .video-player__buttons {
+      button {
+        padding-top: 10px;
+        padding-bottom: 10px;
+      }
     }
   }
 }
-}
-
-.media-spoiler-video {
-  background-size: cover;
-  background-repeat: no-repeat;
-  background-position: center;
-  cursor: pointer;
-  margin-top: 8px;
-  position: relative;
-
-  @include fullwidth-gallery;
-
-  border: 0;
-  display: block;
-}
-
-.media-spoiler-video-play-icon {
-  border-radius: 100px;
-  color: rgba($primary-text-color, 0.8);
-  font-size: 36px;
-  left: 50%;
-  padding: 5px;
-  position: absolute;
-  top: 50%;
-  transform: translate(-50%, -50%);
-}
diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss
index 0e518997d..c3ea47eb0 100644
--- a/app/javascript/flavours/glitch/styles/components/search.scss
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -143,11 +143,12 @@
 
     &__current {
       flex: 0 0 auto;
-      width: 100px;
       font-size: 24px;
       line-height: 36px;
       font-weight: 500;
-      text-align: center;
+      text-align: right;
+      padding-right: 15px;
+      margin-left: 5px;
       color: $secondary-text-color;
     }
 
@@ -155,7 +156,12 @@
       flex: 0 0 auto;
       width: 50px;
 
-      path {
+      path:first-child {
+        fill: rgba($highlight-text-color, 0.25) !important;
+        fill-opacity: 1 !important;
+      }
+
+      path:last-child {
         stroke: lighten($highlight-text-color, 6%) !important;
       }
     }
diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss
index 83c5d351b..1d8055fe5 100644
--- a/app/javascript/flavours/glitch/styles/components/single_column.scss
+++ b/app/javascript/flavours/glitch/styles/components/single_column.scss
@@ -54,13 +54,24 @@
   margin-bottom: 10px;
   height: calc(100% - 20px);
   overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+
+  & > a {
+    flex: 0 0 auto;
+  }
 
   hr {
+    flex: 0 0 auto;
     border: 0;
     background: transparent;
     border-top: 1px solid lighten($ui-base-color, 4%);
     margin: 10px 0;
   }
+
+  .flex-spacer {
+    background: transparent;
+  }
 }
 
 @media screen and (min-width: 600px) {
@@ -83,6 +94,24 @@
     padding: 0;
   }
 
+  .directory__list {
+    display: grid;
+    grid-gap: 10px;
+    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      display: block;
+    }
+  }
+
+  .directory__card {
+    margin-bottom: 0;
+  }
+
+  .filter-form {
+    display: flex;
+  }
+
   .autosuggest-textarea__textarea {
     font-size: 16px;
   }
@@ -107,7 +136,8 @@
       padding: 15px;
 
       .media-gallery,
-      .video-player {
+      .video-player,
+      .audio-player {
         margin-top: 15px;
       }
     }
@@ -131,7 +161,8 @@
 
       .media-gallery,
       &__action-bar,
-      .video-player {
+      .video-player,
+      .audio-player {
         margin-top: 10px;
       }
     }
@@ -196,7 +227,6 @@
   }
 
   .getting-started__wrapper,
-  .getting-started__trends,
   .search {
     margin-bottom: 10px;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 40db7b3cb..24ab71969 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -263,7 +263,8 @@
   opacity: 1;
   animation: fade 150ms linear;
 
-  .video-player {
+  .video-player,
+  .audio-player {
     margin-top: 8px;
   }
 
@@ -453,7 +454,8 @@
       white-space: normal;
     }
 
-    .video-player {
+    .video-player,
+    .audio-player {
       margin-top: 8px;
       max-width: 250px;
     }
@@ -561,7 +563,8 @@
     }
   }
 
-  .video-player {
+  .video-player,
+  .audio-player {
     margin-top: 8px;
   }
 }
@@ -883,67 +886,6 @@ a.status-card.compact:hover {
   background-position: center center;
 }
 
-.status__video-player {
-  display: flex;
-  align-items: center;
-  background: $base-shadow-color;
-  box-sizing: border-box;
-  cursor: default; /* May not be needed */
-  margin-top: 8px;
-  overflow: hidden;
-  position: relative;
-
-  @include fullwidth-gallery;
-}
-
-.status__video-player-video {
-  height: 100%;
-  object-fit: contain;
-  position: relative;
-  top: 50%;
-  transform: translateY(-50%);
-  width: 100%;
-  z-index: 1;
-
-  &:not(.letterbox) {
-    height: 100%;
-    object-fit: cover;
-  }
-}
-
-.status__video-player-expand,
-.status__video-player-mute {
-  color: $primary-text-color;
-  opacity: 0.8;
-  position: absolute;
-  right: 4px;
-  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
-}
-
-.status__video-player-spoiler {
-  display: none;
-  color: $primary-text-color;
-  left: 4px;
-  position: absolute;
-  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
-  top: 4px;
-  z-index: 100;
-
-  &.status__video-player-spoiler--visible {
-    display: block;
-  }
-}
-
-.status__video-player-expand {
-  bottom: 4px;
-  z-index: 100;
-}
-
-.status__video-player-mute {
-  top: 4px;
-  z-index: 5;
-}
-
 .attachment-list {
   display: flex;
   font-size: 14px;
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index 130e1461c..45eb5a9d0 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -769,6 +769,24 @@
     }
   }
 
+  .directory__list {
+    display: grid;
+    grid-gap: 10px;
+    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      display: block;
+    }
+
+    .icon-button {
+      font-size: 18px;
+    }
+  }
+
+  .directory__card {
+    margin-bottom: 0;
+  }
+
   .card-grid {
     display: flex;
     flex-wrap: wrap;
diff --git a/app/javascript/flavours/glitch/styles/dashboard.scss b/app/javascript/flavours/glitch/styles/dashboard.scss
index e4564f062..c0944d417 100644
--- a/app/javascript/flavours/glitch/styles/dashboard.scss
+++ b/app/javascript/flavours/glitch/styles/dashboard.scss
@@ -15,6 +15,8 @@
       padding: 20px;
       background: lighten($ui-base-color, 4%);
       border-radius: 4px;
+      box-sizing: border-box;
+      height: 100%;
     }
 
     & > a {
diff --git a/app/javascript/flavours/glitch/styles/footer.scss b/app/javascript/flavours/glitch/styles/footer.scss
index f74c004e9..00d290883 100644
--- a/app/javascript/flavours/glitch/styles/footer.scss
+++ b/app/javascript/flavours/glitch/styles/footer.scss
@@ -128,7 +128,7 @@
       &:hover,
       &:focus,
       &:active {
-        svg path {
+        svg {
           fill: lighten($ui-base-color, 38%);
         }
       }
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index 35a8ce7a3..4c2b76a21 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -372,3 +372,10 @@
 .directory__tag > div {
   box-shadow: none;
 }
+
+.audio-player .video-player__controls button,
+.audio-player .video-player__time-sep,
+.audio-player .video-player__time-current,
+.audio-player .video-player__time-total {
+  color: $primary-text-color;
+}
diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss
index e207113be..9e9c6eb58 100644
--- a/app/javascript/flavours/glitch/styles/widgets.scss
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -100,6 +100,16 @@
       background-size: 44px 44px;
     }
   }
+
+  .trends__item {
+    padding: 10px;
+  }
+}
+
+.trends-widget {
+  h4 {
+    color: $darker-text-color;
+  }
 }
 
 .box-widget {
diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js
index 8f2e4c6e4..6c0acdb27 100644
--- a/app/javascript/flavours/glitch/util/async-components.js
+++ b/app/javascript/flavours/glitch/util/async-components.js
@@ -138,6 +138,10 @@ export function Video () {
   return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video');
 }
 
+export function Audio () {
+  return import(/* webpackChunkName: "features/glitch/async/audio" */'flavours/glitch/features/audio');
+}
+
 export function EmbedModal () {
   return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal');
 }
@@ -157,3 +161,7 @@ export function Search () {
 export function Tesseract () {
   return import(/*webpackChunkName: "tesseract" */'tesseract.js');
 }
+
+export function Directory () {
+  return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory');
+}
diff --git a/app/javascript/flavours/glitch/util/backend_links.js b/app/javascript/flavours/glitch/util/backend_links.js
index bc82197be..0fb378cc1 100644
--- a/app/javascript/flavours/glitch/util/backend_links.js
+++ b/app/javascript/flavours/glitch/util/backend_links.js
@@ -5,3 +5,5 @@ export const termsLink = '/terms';
 export const accountAdminLink = (id) => `/admin/accounts/${id}`;
 export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`;
 export const filterEditLink = (id) => `/filters/${id}/edit`;
+export const relationshipsLink = '/relationships';
+export const securityLink = '/auth/edit';
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index 4b6227cac..911468e6f 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -26,11 +26,13 @@ export const pollLimits = (initialState && initialState.poll_limits);
 export const invitesEnabled = getMeta('invites_enabled');
 export const version = getMeta('version');
 export const mascot = getMeta('mascot');
+export const profile_directory = getMeta('profile_directory');
 export const isStaff = getMeta('is_staff');
 export const defaultContentType = getMeta('default_content_type');
 export const forceSingleColumn = getMeta('advanced_layout') === false;
 export const useBlurhash = getMeta('use_blurhash');
 export const usePendingItems = getMeta('use_pending_items');
 export const useSystemEmojiFont = getMeta('system_emoji_font');
+export const showTrends = getMeta('trends');
 
 export default initialState;
diff --git a/app/javascript/flavours/glitch/util/log_out.js b/app/javascript/flavours/glitch/util/log_out.js
new file mode 100644
index 000000000..8e1659293
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/log_out.js
@@ -0,0 +1,34 @@
+import Rails from 'rails-ujs';
+import { signOutLink } from 'flavours/glitch/util/backend_links';
+
+export const logOut = () => {
+  const form = document.createElement('form');
+
+  const methodInput = document.createElement('input');
+  methodInput.setAttribute('name', '_method');
+  methodInput.setAttribute('value', 'delete');
+  methodInput.setAttribute('type', 'hidden');
+  form.appendChild(methodInput);
+
+  const csrfToken = Rails.csrfToken();
+  const csrfParam = Rails.csrfParam();
+
+  if (csrfParam && csrfToken) {
+    const csrfInput = document.createElement('input');
+    csrfInput.setAttribute('name', csrfParam);
+    csrfInput.setAttribute('value', csrfToken);
+    csrfInput.setAttribute('type', 'hidden');
+    form.appendChild(csrfInput);
+  }
+
+  const submitButton = document.createElement('input');
+  submitButton.setAttribute('type', 'submit');
+  form.appendChild(submitButton);
+
+  form.method = 'post';
+  form.action = signOutLink;
+  form.style.display = 'none';
+
+  document.body.appendChild(form);
+  submitButton.click();
+};
diff --git a/app/javascript/flavours/glitch/util/rtl.js b/app/javascript/flavours/glitch/util/rtl.js
index 00870a15d..89bed6de8 100644
--- a/app/javascript/flavours/glitch/util/rtl.js
+++ b/app/javascript/flavours/glitch/util/rtl.js
@@ -20,6 +20,7 @@ export function isRtl(text) {
   text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, '');
   text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, '');
   text = text.replace(/\s+/g, '');
+  text = text.replace(/(\w\S+\.\w{2,}\S*)/g, '');
 
   const matches = text.match(rtlChars);
 
diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js
index ef2500e7b..cd36d8007 100644
--- a/app/javascript/mastodon/actions/alerts.js
+++ b/app/javascript/mastodon/actions/alerts.js
@@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
 const messages = defineMessages({
   unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
   unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
+  rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
+  rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
 });
 
 export const ALERT_SHOW    = 'ALERT_SHOW';
@@ -23,23 +25,29 @@ export function clearAlert() {
   };
 };
 
-export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
+export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
   return {
     type: ALERT_SHOW,
     title,
     message,
+    message_values,
   };
 };
 
 export function showAlertForError(error) {
   if (error.response) {
-    const { data, status, statusText } = error.response;
+    const { data, status, statusText, headers } = error.response;
 
     if (status === 404 || status === 410) {
       // Skip these errors as they are reflected in the UI
       return { type: ALERT_NOOP };
     }
 
+    if (status === 429 && headers['x-ratelimit-reset']) {
+      const reset_date = new Date(headers['x-ratelimit-reset']);
+      return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
+    }
+
     let message = statusText;
     let title   = `${status}`;
 
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index c27c53df0..061a36bb8 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
     cancelFetchComposeSuggestionsTags();
   }
 
+  dispatch(updateSuggestionTags(token));
+
   api(getState).get('/api/v2/search', {
     cancelToken: new CancelToken(cancel => {
       cancelFetchComposeSuggestionsTags = cancel;
diff --git a/app/javascript/mastodon/actions/directory.js b/app/javascript/mastodon/actions/directory.js
new file mode 100644
index 000000000..4b2b6dd56
--- /dev/null
+++ b/app/javascript/mastodon/actions/directory.js
@@ -0,0 +1,61 @@
+import api from '../api';
+import { importFetchedAccounts } from './importer';
+import { fetchRelationships } from './accounts';
+
+export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
+export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
+export const DIRECTORY_FETCH_FAIL    = 'DIRECTORY_FETCH_FAIL';
+
+export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
+export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
+export const DIRECTORY_EXPAND_FAIL    = 'DIRECTORY_EXPAND_FAIL';
+
+export const fetchDirectory = params => (dispatch, getState) => {
+  dispatch(fetchDirectoryRequest());
+
+  api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchDirectorySuccess(data));
+    dispatch(fetchRelationships(data.map(x => x.id)));
+  }).catch(error => dispatch(fetchDirectoryFail(error)));
+};
+
+export const fetchDirectoryRequest = () => ({
+  type: DIRECTORY_FETCH_REQUEST,
+});
+
+export const fetchDirectorySuccess = accounts => ({
+  type: DIRECTORY_FETCH_SUCCESS,
+  accounts,
+});
+
+export const fetchDirectoryFail = error => ({
+  type: DIRECTORY_FETCH_FAIL,
+  error,
+});
+
+export const expandDirectory = params => (dispatch, getState) => {
+  dispatch(expandDirectoryRequest());
+
+  const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
+
+  api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(expandDirectorySuccess(data));
+    dispatch(fetchRelationships(data.map(x => x.id)));
+  }).catch(error => dispatch(expandDirectoryFail(error)));
+};
+
+export const expandDirectoryRequest = () => ({
+  type: DIRECTORY_EXPAND_REQUEST,
+});
+
+export const expandDirectorySuccess = accounts => ({
+  type: DIRECTORY_EXPAND_SUCCESS,
+  accounts,
+});
+
+export const expandDirectoryFail = error => ({
+  type: DIRECTORY_EXPAND_FAIL,
+  error,
+});
diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.js b/app/javascript/mastodon/components/autosuggest_hashtag.js
index eabb8b178..e2f4e320d 100644
--- a/app/javascript/mastodon/components/autosuggest_hashtag.js
+++ b/app/javascript/mastodon/components/autosuggest_hashtag.js
@@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent {
     tag: PropTypes.shape({
       name: PropTypes.string.isRequired,
       url: PropTypes.string,
-      history: PropTypes.array.isRequired,
+      history: PropTypes.array,
     }).isRequired,
   };
 
   render () {
     const { tag } = this.props;
-    const weeklyUses = shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
+    const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
 
     return (
       <div className='autosuggest-hashtag'>
         <div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
-        <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>
+        {tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
       </div>
     );
   }
diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js
index cc0e5c07c..d97622705 100644
--- a/app/javascript/mastodon/components/column_back_button.js
+++ b/app/javascript/mastodon/components/column_back_button.js
@@ -35,7 +35,19 @@ export default class ColumnBackButton extends React.PureComponent {
     if (multiColumn) {
       return component;
     } else {
-      return createPortal(component, document.getElementById('tabs-bar__portal'));
+      // The portal container and the component may be rendered to the DOM in
+      // the same React render pass, so the container might not be available at
+      // the time `render()` is called.
+      const container = document.getElementById('tabs-bar__portal');
+      if (container === null) {
+        // The container wasn't available, force a re-render so that the
+        // component can eventually be inserted in the container and not scroll
+        // with the rest of the area.
+        this.forceUpdate();
+        return component;
+      } else {
+        return createPortal(component, container);
+      }
     }
   }
 
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index 89c5fe723..8a26742b5 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -178,7 +178,19 @@ class ColumnHeader extends React.PureComponent {
     if (multiColumn || placeholder) {
       return component;
     } else {
-      return createPortal(component, document.getElementById('tabs-bar__portal'));
+      // The portal container and the component may be rendered to the DOM in
+      // the same React render pass, so the container might not be available at
+      // the time `render()` is called.
+      const container = document.getElementById('tabs-bar__portal');
+      if (container === null) {
+        // The container wasn't available, force a re-render so that the
+        // component can eventually be inserted in the container and not scroll
+        // with the rest of the area.
+        this.forceUpdate();
+        return component;
+      } else {
+        return createPortal(component, container);
+      }
     }
   }
 
diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js
index f091d7893..62d613262 100644
--- a/app/javascript/mastodon/components/hashtag.js
+++ b/app/javascript/mastodon/components/hashtag.js
@@ -12,11 +12,11 @@ const Hashtag = ({ hashtag }) => (
         #<span>{hashtag.get('name')}</span>
       </Permalink>
 
-      <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
+      <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} />
     </div>
 
     <div className='trends__item__current'>
-      {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
+      {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)}
     </div>
 
     <div className='trends__item__sparkline'>
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 9cd71b7c9..e8dd79af9 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -159,7 +159,7 @@ class Item extends React.PureComponent {
     if (attachment.get('type') === 'unknown') {
       return (
         <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
-          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
+          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
             <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
           </a>
         </div>
@@ -315,15 +315,22 @@ class MediaGallery extends React.PureComponent {
       style.height = height;
     }
 
-    const size = media.take(4).size;
+    const size     = media.take(4).size;
+    const uncached = media.every(attachment => attachment.get('type') === 'unknown');
 
     if (this.isStandaloneEligible()) {
       children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
     } else {
-      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible || uncached} />);
     }
 
-    if (visible) {
+    if (uncached) {
+      spoilerButton = (
+        <button type='button' disabled className='spoiler-button__overlay'>
+          <span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span>
+        </button>
+      );
+    } else if (visible) {
       spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
     } else {
       spoilerButton = (
@@ -335,7 +342,7 @@ class MediaGallery extends React.PureComponent {
 
     return (
       <div className='media-gallery' style={style} ref={this.handleRef}>
-        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
+        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
           {spoilerButton}
         </div>
 
diff --git a/app/javascript/mastodon/components/radio_button.js b/app/javascript/mastodon/components/radio_button.js
new file mode 100644
index 000000000..0496fa286
--- /dev/null
+++ b/app/javascript/mastodon/components/radio_button.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class RadioButton extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    checked: PropTypes.bool,
+    name: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+    label: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { name, value, checked, onChange, label } = this.props;
+
+    return (
+      <label className='radio-button'>
+        <input
+          name={name}
+          type='radio'
+          value={value}
+          checked={checked}
+          onChange={onChange}
+        />
+
+        <span className={classNames('radio-button__input', { checked })} />
+
+        <span>{label}</span>
+      </label>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 735cab007..b5606aca5 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
 import Card from '../features/status/components/card';
 import { injectIntl, FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { MediaGallery, Video } from '../features/ui/util/async-components';
+import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
 import { HotKeys } from 'react-hotkeys';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
@@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
   };
 
   renderLoadingMediaGallery () {
-    return <div className='media_gallery' style={{ height: '110px' }} />;
+    return <div className='media-gallery' style={{ height: '110px' }} />;
   }
 
   renderLoadingVideoPlayer () {
-    return <div className='media-spoiler-video' style={{ height: '110px' }} />;
+    return <div className='video-player' style={{ height: '110px' }} />;
+  }
+
+  renderLoadingAudioPlayer () {
+    return <div className='audio-player' style={{ height: '110px' }} />;
   }
 
   handleOpenVideo = (media, startTime) => {
@@ -348,7 +352,23 @@ class Status extends ImmutablePureComponent {
             media={status.get('media_attachments')}
           />
         );
-      } else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+        const attachment = status.getIn(['media_attachments', 0]);
+
+        media = (
+          <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
+            {Component => (
+              <Component
+                src={attachment.get('url')}
+                alt={attachment.get('description')}
+                duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
+                peaks={[0]}
+                height={70}
+              />
+            )}
+          </Bundle>
+        );
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
         const attachment = status.getIn(['media_attachments', 0]);
 
         media = (
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 6aa0bfcc2..c171e7a66 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent {
       );
     } else if (this.props.onClick) {
       const output = [
-        <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+        <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
 
           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js
index 8fddb6f54..db340032a 100644
--- a/app/javascript/mastodon/containers/media_container.js
+++ b/app/javascript/mastodon/containers/media_container.js
@@ -8,6 +8,7 @@ import Video from '../features/video';
 import Card from '../features/status/components/card';
 import Poll from 'mastodon/components/poll';
 import Hashtag from 'mastodon/components/hashtag';
+import Audio from 'mastodon/features/audio';
 import ModalRoot from '../components/modal_root';
 import { getScrollbarWidth } from '../features/ui/components/modal_root';
 import MediaModal from '../features/ui/components/media_modal';
@@ -16,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
 const { localeData, messages } = getLocale();
 addLocaleData(localeData);
 
-const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag };
+const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
 
 export default class MediaContainer extends PureComponent {
 
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
new file mode 100644
index 000000000..95e5675f3
--- /dev/null
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -0,0 +1,226 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import WaveSurfer from 'wavesurfer.js';
+import { defineMessages, injectIntl } from 'react-intl';
+import { formatTime } from 'mastodon/features/video';
+import Icon from 'mastodon/components/icon';
+import classNames from 'classnames';
+import { throttle } from 'lodash';
+
+const messages = defineMessages({
+  play: { id: 'video.play', defaultMessage: 'Play' },
+  pause: { id: 'video.pause', defaultMessage: 'Pause' },
+  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+});
+
+export default @injectIntl
+class Audio extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
+    duration: PropTypes.number,
+    peaks: PropTypes.arrayOf(PropTypes.number),
+    height: PropTypes.number,
+    preload: PropTypes.bool,
+    editable: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    currentTime: 0,
+    duration: null,
+    paused: true,
+    muted: false,
+    volume: 0.5,
+  };
+
+  // hard coded in components.scss
+  // any way to get ::before values programatically?
+
+  volWidth = 50;
+
+  volOffset = 70;
+
+  volHandleOffset = v => {
+    const offset = v * this.volWidth + this.volOffset;
+    return (offset > 110) ? 110 : offset;
+  }
+
+  setVolumeRef = c => {
+    this.volume = c;
+  }
+
+  setWaveformRef = c => {
+    this.waveform = c;
+  }
+
+  componentDidMount () {
+    if (this.waveform) {
+      this._updateWaveform();
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.waveform && prevProps.src !== this.props.src) {
+      this._updateWaveform();
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.wavesurfer) {
+      this.wavesurfer.destroy();
+      this.wavesurfer = null;
+    }
+  }
+
+  _updateWaveform () {
+    const { src, height, duration, peaks, preload } = this.props;
+
+    const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
+    const waveColor     = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
+
+    if (this.wavesurfer) {
+      this.wavesurfer.destroy();
+      this.loaded = false;
+    }
+
+    const wavesurfer = WaveSurfer.create({
+      container: this.waveform,
+      height,
+      barWidth: 3,
+      cursorWidth: 0,
+      progressColor,
+      waveColor,
+      backend: 'MediaElement',
+      interact: preload,
+    });
+
+    wavesurfer.setVolume(this.state.volume);
+
+    if (preload) {
+      wavesurfer.load(src);
+      this.loaded = true;
+    } else {
+      wavesurfer.load(src, peaks, 'none', duration);
+      this.loaded = false;
+    }
+
+    wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
+    wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
+    wavesurfer.on('pause', () => this.setState({ paused: true }));
+    wavesurfer.on('play', () => this.setState({ paused: false }));
+    wavesurfer.on('volume', volume => this.setState({ volume }));
+    wavesurfer.on('mute', muted => this.setState({ muted }));
+
+    this.wavesurfer = wavesurfer;
+  }
+
+  togglePlay = () => {
+    if (this.state.paused) {
+      if (!this.props.preload && !this.loaded) {
+        this.wavesurfer.createBackend();
+        this.wavesurfer.createPeakCache();
+        this.wavesurfer.load(this.props.src);
+        this.wavesurfer.toggleInteraction();
+        this.loaded = true;
+      }
+
+      this.wavesurfer.play();
+      this.setState({ paused: false });
+    } else {
+      this.wavesurfer.pause();
+      this.setState({ paused: true });
+    }
+  }
+
+  toggleMute = () => {
+    this.wavesurfer.setMute(!this.state.muted);
+  }
+
+  handleVolumeMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.addEventListener('touchend', this.handleVolumeMouseUp, true);
+
+    this.handleMouseVolSlide(e);
+
+    e.preventDefault();
+    e.stopPropagation();
+  }
+
+  handleVolumeMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+  }
+
+  handleMouseVolSlide = throttle(e => {
+    const rect = this.volume.getBoundingClientRect();
+    const x    = (e.clientX - rect.left) / this.volWidth; // x position within the element.
+
+    if(!isNaN(x)) {
+      let slideamt = x;
+
+      if (x > 1) {
+        slideamt = 1;
+      } else if(x < 0) {
+        slideamt = 0;
+      }
+
+      this.wavesurfer.setVolume(slideamt);
+    }
+  }, 60);
+
+  render () {
+    const { height, intl, alt, editable } = this.props;
+    const { paused, muted, volume, currentTime } = this.state;
+
+    const volumeWidth     = muted ? 0 : volume * this.volWidth;
+    const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
+
+    return (
+      <div className={classNames('audio-player', { editable })}>
+        <div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
+        <div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
+
+        <div
+          className='audio-player__waveform'
+          aria-label={alt}
+          title={alt}
+          style={{ height }}
+          ref={this.setWaveformRef}
+        />
+
+        <div className='video-player__controls active'>
+          <div className='video-player__buttons-bar'>
+            <div className='video-player__buttons left'>
+              <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+
+              <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
+                <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
+
+                <span
+                  className={classNames('video-player__volume__handle')}
+                  tabIndex='0'
+                  style={{ left: `${volumeHandleLoc}px` }}
+                />
+              </div>
+
+              <span>
+                <span className='video-player__time-current'>{formatTime(currentTime)}</span>
+                <span className='video-player__time-sep'>/</span>
+                <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js
index d0303dbfb..dd2632796 100644
--- a/app/javascript/mastodon/features/compose/components/action_bar.js
+++ b/app/javascript/mastodon/features/compose/components/action_bar.js
@@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
+    onLogout: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
+  handleLogout = () => {
+    this.props.onLogout();
+  }
+
   render () {
     const { intl } = this.props;
 
@@ -44,7 +49,7 @@ class ActionBar extends React.PureComponent {
     menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
     menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
     menu.push(null);
-    menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' });
+    menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
 
     return (
       <div className='compose__action-bar'>
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js
index d8d49cb95..840d0a3da 100644
--- a/app/javascript/mastodon/features/compose/components/navigation_bar.js
+++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js
@@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
+    onLogout: PropTypes.func.isRequired,
     onClose: PropTypes.func,
   };
 
@@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent {
 
         <div className='navigation-bar__actions'>
           <IconButton className='close' title='' icon='close' onClick={this.props.onClose} />
-          <ActionBar account={this.props.account} />
+          <ActionBar account={this.props.account} onLogout={this.props.onLogout} />
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js
index eb9f3ea45..8606a642e 100644
--- a/app/javascript/mastodon/features/compose/containers/navigation_container.js
+++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js
@@ -1,11 +1,29 @@
 import { connect }   from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
 import NavigationBar from '../components/navigation_bar';
+import { logOut } from 'mastodon/utils/log_out';
+import { openModal } from 'mastodon/actions/modal';
 import { me } from '../../../initial_state';
 
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
 const mapStateToProps = state => {
   return {
     account: state.getIn(['accounts', me]),
   };
 };
 
-export default connect(mapStateToProps)(NavigationBar);
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index 0731abcf4..e2de8b0e6 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -12,9 +12,11 @@ import Motion from '../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import SearchResultsContainer from './containers/search_results_container';
 import { changeComposing } from '../../actions/compose';
+import { openModal } from 'mastodon/actions/modal';
 import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
 import { mascot } from '../../initial_state';
 import Icon from 'mastodon/components/icon';
+import { logOut } from 'mastodon/utils/log_out';
 
 const messages = defineMessages({
   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@@ -25,6 +27,8 @@ const messages = defineMessages({
   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
   logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
   compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
 });
 
 const mapStateToProps = (state, ownProps) => ({
@@ -61,6 +65,21 @@ class Compose extends React.PureComponent {
     }
   }
 
+  handleLogoutClick = e => {
+    const { dispatch, intl } = this.props;
+
+    e.preventDefault();
+    e.stopPropagation();
+
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      onConfirm: () => logOut(),
+    }));
+
+    return false;
+  }
+
   onFocus = () => {
     this.props.dispatch(changeComposing(true));
   }
@@ -92,7 +111,7 @@ class Compose extends React.PureComponent {
             <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
           )}
           <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a>
-          <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><Icon id='sign-out' fixedWidth /></a>
+          <a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a>
         </nav>
       );
     }
diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js
new file mode 100644
index 000000000..50ad74450
--- /dev/null
+++ b/app/javascript/mastodon/features/directory/components/account_card.js
@@ -0,0 +1,190 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'mastodon/selectors';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+import Permalink from 'mastodon/components/permalink';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+import IconButton from 'mastodon/components/icon_button';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
+import { shortNumberFormat } from 'mastodon/utils/numbers';
+import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
+import { openModal } from 'mastodon/actions/modal';
+import { initMuteModal } from 'mastodon/actions/mutes';
+
+const messages = defineMessages({
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { id }) => ({
+    account: getAccount(state, id),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onFollow (account) {
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+      if (unfollowModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.unfollowConfirm),
+          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        }));
+      } else {
+        dispatch(unfollowAccount(account.get('id')));
+      }
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(blockAccount(account.get('id')));
+    }
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(initMuteModal(account));
+    }
+  },
+
+});
+
+export default @injectIntl
+@connect(makeMapStateToProps, mapDispatchToProps)
+class AccountCard extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+  };
+
+  _updateEmojis () {
+    const node = this.node;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  componentDidMount () {
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateEmojis();
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
+  handleFollow = () => {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock = () => {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMute = () => {
+    this.props.onMute(this.props.account);
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  render () {
+    const { account, intl } = this.props;
+
+    let buttons;
+
+    if (account.get('id') !== me && account.get('relationship', null) !== null) {
+      const following = account.getIn(['relationship', 'following']);
+      const requested = account.getIn(['relationship', 'requested']);
+      const blocking  = account.getIn(['relationship', 'blocking']);
+      const muting    = account.getIn(['relationship', 'muting']);
+
+      if (requested) {
+        buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
+      } else if (blocking) {
+        buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
+      } else if (muting) {
+        buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
+      } else if (!account.get('moved') || following) {
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      }
+    }
+
+    return (
+      <div className='directory__card'>
+        <div className='directory__card__img'>
+          <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
+        </div>
+
+        <div className='directory__card__bar'>
+          <Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+            <Avatar account={account} size={48} />
+            <DisplayName account={account} />
+          </Permalink>
+
+          <div className='directory__card__bar__relationship account__relationship'>
+            {buttons}
+          </div>
+        </div>
+
+        <div className='directory__card__extra' ref={this.setRef}>
+          <div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
+        </div>
+
+        <div className='directory__card__extra'>
+          <div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div>
+          <div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div>
+          <div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/directory/index.js b/app/javascript/mastodon/features/directory/index.js
new file mode 100644
index 000000000..2f91e759b
--- /dev/null
+++ b/app/javascript/mastodon/features/directory/index.js
@@ -0,0 +1,171 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
+import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
+import { List as ImmutableList } from 'immutable';
+import AccountCard from './components/account_card';
+import RadioButton from 'mastodon/components/radio_button';
+import classNames from 'classnames';
+import LoadMore from 'mastodon/components/load_more';
+import { ScrollContainer } from 'react-router-scroll-4';
+
+const messages = defineMessages({
+  title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
+  recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
+  newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
+  local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
+  federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
+  isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
+  domain: state.getIn(['meta', 'domain']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Directory extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    isLoading: PropTypes.bool,
+    accountIds: ImmutablePropTypes.list.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    shouldUpdateScroll: PropTypes.func,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
+    params: PropTypes.shape({
+      order: PropTypes.string,
+      local: PropTypes.bool,
+    }),
+  };
+
+  state = {
+    order: null,
+    local: null,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
+    }
+  }
+
+  getParams = (props, state) => ({
+    order: state.order === null ? (props.params.order || 'active') : state.order,
+    local: state.local === null ? (props.params.local || false) : state.local,
+  });
+
+  handleMove = dir => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchDirectory(this.getParams(this.props, this.state)));
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    const { dispatch } = this.props;
+    const paramsOld = this.getParams(prevProps, prevState);
+    const paramsNew = this.getParams(this.props, this.state);
+
+    if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
+      dispatch(fetchDirectory(paramsNew));
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleChangeOrder = e => {
+    const { dispatch, columnId } = this.props;
+
+    if (columnId) {
+      dispatch(changeColumnParams(columnId, ['order'], e.target.value));
+    } else {
+      this.setState({ order: e.target.value });
+    }
+  }
+
+  handleChangeLocal = e => {
+    const { dispatch, columnId } = this.props;
+
+    if (columnId) {
+      dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
+    } else {
+      this.setState({ local: e.target.value === '1' });
+    }
+  }
+
+  handleLoadMore = () => {
+    const { dispatch } = this.props;
+    dispatch(expandDirectory(this.getParams(this.props, this.state)));
+  }
+
+  render () {
+    const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
+    const { order, local }  = this.getParams(this.props, this.state);
+    const pinned = !!columnId;
+
+    const scrollableArea = (
+      <div className='scrollable' style={{ background: 'transparent' }}>
+        <div className='filter-form'>
+          <div className='filter-form__column' role='group'>
+            <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
+            <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
+          </div>
+
+          <div className='filter-form__column' role='group'>
+            <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
+            <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
+          </div>
+        </div>
+
+        <div className={classNames('directory__list', { loading: isLoading })}>
+          {accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)}
+        </div>
+
+        <LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
+      </div>
+    );
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='address-book-o'
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        />
+
+        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js
index 1dcacc8b3..3b9a3075f 100644
--- a/app/javascript/mastodon/features/getting_started/components/trends.js
+++ b/app/javascript/mastodon/features/getting_started/components/trends.js
@@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Hashtag from 'mastodon/components/hashtag';
+import { FormattedMessage } from 'react-intl';
 
 export default class Trends extends ImmutablePureComponent {
 
@@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent {
 
   componentDidMount () {
     this.props.fetchTrends();
-    this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000);
+    this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
   }
 
   componentWillUnmount () {
@@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent {
 
     return (
       <div className='getting-started__trends'>
+        <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
+
         {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
       </div>
     );
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 6a122a750..f6d90580b 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -107,7 +107,7 @@ class GettingStarted extends ImmutablePureComponent {
 
       if (profile_directory) {
         navItems.push(
-          <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' />
+          <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
         );
 
         height += 48;
@@ -120,7 +120,7 @@ class GettingStarted extends ImmutablePureComponent {
       height += 34;
     } else if (profile_directory) {
       navItems.push(
-        <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' />
+        <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
       );
 
       height += 48;
diff --git a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js
index c5098052c..5914bbeaf 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js
@@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({
   },
 
   onLoad (value) {
-    return api().get('/api/v2/search', { params: { q: value } }).then(response => {
+    return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
       return (response.data.hashtags || []).map((tag) => {
         return { value: tag.name, label: `#${tag.name}` };
       });
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 4af157af1..e97f18f08 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from '../../video';
+import Audio from '../../audio';
 import scheduleIdleTask from '../../ui/util/schedule_idle_task';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
@@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
     }
 
     if (status.get('media_attachments').size > 0) {
-      if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
+      if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+        const attachment = status.getIn(['media_attachments', 0]);
+
+        media = (
+          <Audio
+            src={attachment.get('url')}
+            alt={attachment.get('description')}
+            duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
+            height={110}
+            preload
+          />
+        );
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
         const attachment = status.getIn(['media_attachments', 0]);
 
         media = (
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index ad4f75820..f78a9489a 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -84,28 +84,38 @@ const makeMapStateToProps = () => {
   const getDescendantsIds = createSelector([
     (_, { id }) => id,
     state => state.getIn(['contexts', 'replies']),
-  ], (statusId, contextReplies) => {
-    let descendantsIds = Immutable.List();
-    descendantsIds = descendantsIds.withMutations(mutable => {
-      const ids = [statusId];
+    state => state.get('statuses'),
+  ], (statusId, contextReplies, statuses) => {
+    let descendantsIds = [];
+    const ids = [statusId];
 
-      while (ids.length > 0) {
-        let id        = ids.shift();
-        const replies = contextReplies.get(id);
+    while (ids.length > 0) {
+      let id        = ids.shift();
+      const replies = contextReplies.get(id);
 
-        if (statusId !== id) {
-          mutable.push(id);
-        }
+      if (statusId !== id) {
+        descendantsIds.push(id);
+      }
 
-        if (replies) {
-          replies.reverse().forEach(reply => {
-            ids.unshift(reply);
-          });
-        }
+      if (replies) {
+        replies.reverse().forEach(reply => {
+          ids.unshift(reply);
+        });
       }
-    });
+    }
+
+    let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
+    if (insertAt !== -1) {
+      descendantsIds.forEach((id, idx) => {
+        if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
+          descendantsIds.splice(idx, 1);
+          descendantsIds.splice(insertAt, 0, id);
+          insertAt += 1;
+        }
+      });
+    }
 
-    return descendantsIds;
+    return Immutable.List(descendantsIds);
   });
 
   const mapStateToProps = (state, props) => {
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 042e44e43..8a4e89b3d 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -12,7 +12,18 @@ import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
 import BundleColumnError from './bundle_column_error';
-import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components';
+import {
+  Compose,
+  Notifications,
+  HomeTimeline,
+  CommunityTimeline,
+  PublicTimeline,
+  HashtagTimeline,
+  DirectTimeline,
+  FavouritedStatuses,
+  ListTimeline,
+  Directory,
+} from '../../ui/util/async-components';
 import Icon from 'mastodon/components/icon';
 import ComposePanel from './compose_panel';
 import NavigationPanel from './navigation_panel';
@@ -30,6 +41,7 @@ const componentMap = {
   'DIRECT': DirectTimeline,
   'FAVOURITES': FavouritedStatuses,
   'LIST': ListTimeline,
+  'DIRECTORY': Directory,
 };
 
 const messages = defineMessages({
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
index e0ef1a066..735e445e8 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import IconButton from 'mastodon/components/icon_button';
 import Button from 'mastodon/components/button';
 import Video from 'mastodon/features/video';
+import Audio from 'mastodon/features/audio';
 import Textarea from 'react-textarea-autosize';
 import UploadProgress from 'mastodon/features/compose/components/upload_progress';
 import CharacterCounter from 'mastodon/features/compose/components/character_counter';
@@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
               </div>
             )}
 
-            {['audio', 'video'].includes(media.get('type')) && (
+            {media.get('type') === 'video' && (
               <Video
                 preview={media.get('preview_url')}
                 blurhash={media.get('blurhash')}
                 src={media.get('url')}
                 detailed
+                inline
+                editable
+              />
+            )}
+
+            {media.get('type') === 'audio' && (
+              <Audio
+                src={media.get('url')}
+                duration={media.getIn(['meta', 'original', 'duration'], 0)}
+                height={150}
+                preload
                 editable
               />
             )}
diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js
index b481983dc..2b9bd3875 100644
--- a/app/javascript/mastodon/features/ui/components/link_footer.js
+++ b/app/javascript/mastodon/features/ui/components/link_footer.js
@@ -1,35 +1,72 @@
+import { connect } from 'react-redux';
 import React from 'react';
 import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { Link } from 'react-router-dom';
 import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state';
+import { logOut } from 'mastodon/utils/log_out';
+import { openModal } from 'mastodon/actions/modal';
 
-const LinkFooter = ({ withHotkeys }) => (
-  <div className='getting-started__footer'>
-    <ul>
-      {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
-      {withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
-      <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
-      <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
-      <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
-      <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
-      <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
-      <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
-      <li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
-    </ul>
-
-    <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: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
-      />
-    </p>
-  </div>
-);
-
-LinkFooter.propTypes = {
-  withHotkeys: PropTypes.bool,
-};
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default @injectIntl
+@connect(null, mapDispatchToProps)
+class LinkFooter extends React.PureComponent {
+
+  static propTypes = {
+    withHotkeys: PropTypes.bool,
+    onLogout: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleLogoutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
 
-export default LinkFooter;
+    return false;
+  }
+
+  render () {
+    const { withHotkeys } = this.props;
+
+    return (
+      <div className='getting-started__footer'>
+        <ul>
+          {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
+          {withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
+          <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
+          <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
+          <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
+          <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
+          <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
+          <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
+          <li><a href='/auth/sign_out' onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
+        </ul>
+
+        <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: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
+          />
+        </p>
+      </div>
+    );
+  }
+
+};
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js
index 64a40a9da..51e3ec037 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.js
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js
@@ -18,6 +18,7 @@ const NavigationPanel = () => (
     <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
+    {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>}
 
     <ListPanel />
 
@@ -25,7 +26,6 @@ const NavigationPanel = () => (
 
     <a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
     <a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>
-    {!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>}
 
     {showTrends && <div className='flex-spacer' />}
     {showTrends && <TrendsContainer />}
diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js
index b60a0216f..3819da3d8 100644
--- a/app/javascript/mastodon/features/ui/containers/notifications_container.js
+++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js
@@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
     const value = notification[key];
 
     if (typeof value === 'object') {
-      notification[key] = intl.formatMessage(value);
+      notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
     }
   }));
 
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index f0c3eff83..49c5c8d0e 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -47,6 +47,7 @@ import {
   PinnedStatuses,
   Lists,
   Search,
+  Directory,
 } from './util/async-components';
 import { me, forceSingleColumn } from '../../initial_state';
 import { previewState as previewMediaState } from './components/media_modal';
@@ -141,14 +142,24 @@ class SwitchingColumnsArea extends React.PureComponent {
     return location.state !== previewMediaState && location.state !== previewVideoState;
   }
 
-  handleResize = debounce(() => {
+  handleLayoutChange = debounce(() => {
     // The cached heights are no longer accurate, invalidate
     this.props.onLayoutChange();
-
-    this.setState({ mobile: isMobile(window.innerWidth) });
   }, 500, {
     trailing: true,
-  });
+  })
+
+  handleResize = () => {
+    const mobile = isMobile(window.innerWidth);
+
+    if (mobile !== this.state.mobile) {
+      this.handleLayoutChange.cancel();
+      this.props.onLayoutChange();
+      this.setState({ mobile });
+    } else {
+      this.handleLayoutChange();
+    }
+  }
 
   setRef = c => {
     this.node = c.getWrappedInstance();
@@ -178,6 +189,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
 
           <WrappedRoute path='/search' component={Search} content={children} />
+          <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
 
           <WrappedRoute path='/statuses/new' component={Compose} content={children} />
           <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 0a07aa75e..0084c1510 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -137,3 +137,11 @@ export function Search () {
 export function Tesseract () {
   return import(/*webpackChunkName: "tesseract" */'tesseract.js');
 }
+
+export function Audio () {
+  return import(/* webpackChunkName: "features/audio" */'../../audio');
+}
+
+export function Directory () {
+  return import(/* webpackChunkName: "features/directory" */'../../directory');
+}
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index da48c165e..5fe4e956f 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -21,7 +21,7 @@ const messages = defineMessages({
   exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
 });
 
-const formatTime = secondsNum => {
+export const formatTime = secondsNum => {
   let hours   = Math.floor(secondsNum / 3600);
   let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
   let seconds = secondsNum - (hours * 3600) - (minutes * 60);
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 246c9bd0e..9cb4b74a7 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -8,6 +8,14 @@
       {
         "defaultMessage": "An unexpected error occurred.",
         "id": "alert.unexpected.message"
+      },
+      {
+        "defaultMessage": "Rate limited",
+        "id": "alert.rate_limited.title"
+      },
+      {
+        "defaultMessage": "Please retry after {retry_time, time, medium}.",
+        "id": "alert.rate_limited.message"
       }
     ],
     "path": "app/javascript/mastodon/actions/alerts.json"
@@ -192,6 +200,10 @@
         "id": "media_gallery.toggle_visible"
       },
       {
+        "defaultMessage": "Not available",
+        "id": "status.uncached_media_warning"
+      },
+      {
         "defaultMessage": "Sensitive content",
         "id": "status.sensitive_warning"
       },
@@ -744,6 +756,27 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Play",
+        "id": "video.play"
+      },
+      {
+        "defaultMessage": "Pause",
+        "id": "video.pause"
+      },
+      {
+        "defaultMessage": "Mute sound",
+        "id": "video.mute"
+      },
+      {
+        "defaultMessage": "Unmute sound",
+        "id": "video.unmute"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/audio/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Blocked users",
         "id": "column.blocks"
       },
@@ -1099,24 +1132,28 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Uploading...",
-        "id": "upload_progress.label"
+        "defaultMessage": "Delete",
+        "id": "upload_form.undo"
+      },
+      {
+        "defaultMessage": "Edit",
+        "id": "upload_form.edit"
       }
     ],
-    "path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
+    "path": "app/javascript/mastodon/features/compose/components/upload.json"
   },
   {
     "descriptors": [
       {
-        "defaultMessage": "Delete",
-        "id": "upload_form.undo"
+        "defaultMessage": "Are you sure you want to log out?",
+        "id": "confirmations.logout.message"
       },
       {
-        "defaultMessage": "Edit",
-        "id": "upload_form.edit"
+        "defaultMessage": "Log out",
+        "id": "confirmations.logout.confirm"
       }
     ],
-    "path": "app/javascript/mastodon/features/compose/components/upload.json"
+    "path": "app/javascript/mastodon/features/compose/containers/navigation_container.json"
   },
   {
     "descriptors": [
@@ -1206,6 +1243,14 @@
       {
         "defaultMessage": "Compose new toot",
         "id": "navigation_bar.compose"
+      },
+      {
+        "defaultMessage": "Are you sure you want to log out?",
+        "id": "confirmations.logout.message"
+      },
+      {
+        "defaultMessage": "Log out",
+        "id": "confirmations.logout.confirm"
       }
     ],
     "path": "app/javascript/mastodon/features/compose/index.json"
@@ -1226,6 +1271,76 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Follow",
+        "id": "account.follow"
+      },
+      {
+        "defaultMessage": "Unfollow",
+        "id": "account.unfollow"
+      },
+      {
+        "defaultMessage": "Awaiting approval",
+        "id": "account.requested"
+      },
+      {
+        "defaultMessage": "Unblock @{name}",
+        "id": "account.unblock"
+      },
+      {
+        "defaultMessage": "Unmute @{name}",
+        "id": "account.unmute"
+      },
+      {
+        "defaultMessage": "Are you sure you want to unfollow {name}?",
+        "id": "confirmations.unfollow.message"
+      },
+      {
+        "defaultMessage": "Toots",
+        "id": "account.posts"
+      },
+      {
+        "defaultMessage": "Followers",
+        "id": "account.followers"
+      },
+      {
+        "defaultMessage": "Never",
+        "id": "account.never_active"
+      },
+      {
+        "defaultMessage": "Last active",
+        "id": "account.last_status"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/directory/components/account_card.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Browse profiles",
+        "id": "column.directory"
+      },
+      {
+        "defaultMessage": "Recently active",
+        "id": "directory.recently_active"
+      },
+      {
+        "defaultMessage": "New arrivals",
+        "id": "directory.new_arrivals"
+      },
+      {
+        "defaultMessage": "From {domain} only",
+        "id": "directory.local"
+      },
+      {
+        "defaultMessage": "From known fediverse",
+        "id": "directory.federated"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/directory/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Hidden domains",
         "id": "column.domain_blocks"
       },
@@ -1317,8 +1432,8 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Refresh",
-        "id": "trends.refresh"
+        "defaultMessage": "Trending now",
+        "id": "trends.trending_now"
       }
     ],
     "path": "app/javascript/mastodon/features/getting_started/components/trends.json"
@@ -1457,6 +1572,10 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Basic",
+        "id": "home.column_settings.basic"
+      },
+      {
         "defaultMessage": "Show boosts",
         "id": "home.column_settings.show_reblogs"
       },
@@ -1838,14 +1957,6 @@
         "id": "notifications.column_settings.push"
       },
       {
-        "defaultMessage": "Basic",
-        "id": "home.column_settings.basic"
-      },
-      {
-        "defaultMessage": "Update in real-time",
-        "id": "home.column_settings.update_live"
-      },
-      {
         "defaultMessage": "Quick filter bar",
         "id": "notifications.column_settings.filter_bar.category"
       },
@@ -1904,10 +2015,6 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "and {count, plural, one {# other} other {# others}}",
-        "id": "notification.and_n_others"
-      },
-      {
         "defaultMessage": "{name} followed you",
         "id": "notification.follow"
       },
@@ -2322,6 +2429,14 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Are you sure you want to log out?",
+        "id": "confirmations.logout.message"
+      },
+      {
+        "defaultMessage": "Log out",
+        "id": "confirmations.logout.confirm"
+      },
+      {
         "defaultMessage": "Invite people",
         "id": "getting_started.invite"
       },
@@ -2437,16 +2552,16 @@
         "id": "navigation_bar.lists"
       },
       {
+        "defaultMessage": "Profile directory",
+        "id": "getting_started.directory"
+      },
+      {
         "defaultMessage": "Preferences",
         "id": "navigation_bar.preferences"
       },
       {
         "defaultMessage": "Follows and followers",
         "id": "navigation_bar.follows_and_followers"
-      },
-      {
-        "defaultMessage": "Profile directory",
-        "id": "navigation_bar.profile_directory"
       }
     ],
     "path": "app/javascript/mastodon/features/ui/components/navigation_panel.json"
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 628ede3e3..260b43c53 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -16,6 +16,7 @@
   "account.follows.empty": "This user doesn't follow anyone yet.",
   "account.follows_you": "Follows you",
   "account.hide_reblogs": "Hide boosts from @{name}",
+  "account.last_status": "Last active",
   "account.link_verified_on": "Ownership of this link was checked on {date}",
   "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
   "account.media": "Media",
@@ -24,6 +25,7 @@
   "account.mute": "Mute @{name}",
   "account.mute_notifications": "Mute notifications from @{name}",
   "account.muted": "Muted",
+  "account.never_active": "Never",
   "account.posts": "Toots",
   "account.posts_with_replies": "Toots and replies",
   "account.report": "Report @{name}",
@@ -36,6 +38,8 @@
   "account.unfollow": "Unfollow",
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
+  "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
+  "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "autosuggest_hashtag.per_week": "{count} per week",
@@ -49,6 +53,7 @@
   "column.blocks": "Blocked users",
   "column.community": "Local timeline",
   "column.direct": "Direct messages",
+  "column.directory": "Browse profiles",
   "column.domain_blocks": "Hidden domains",
   "column.favourites": "Favourites",
   "column.follow_requests": "Follow requests",
@@ -99,6 +104,8 @@
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
+  "confirmations.logout.confirm": "Log out",
+  "confirmations.logout.message": "Are you sure you want to log out?",
   "confirmations.mute.confirm": "Mute",
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.redraft.confirm": "Delete & redraft",
@@ -107,6 +114,10 @@
   "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "directory.federated": "From known fediverse",
+  "directory.local": "From {domain} only",
+  "directory.new_arrivals": "New arrivals",
+  "directory.recently_active": "Recently active",
   "embed.instructions": "Embed this status on your website by copying the code below.",
   "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
@@ -162,7 +173,6 @@
   "home.column_settings.basic": "Basic",
   "home.column_settings.show_reblogs": "Show boosts",
   "home.column_settings.show_replies": "Show replies",
-  "home.column_settings.update_live": "Update in real-time",
   "intervals.full.days": "{number, plural, one {# day} other {# days}}",
   "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
   "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
@@ -255,10 +265,8 @@
   "navigation_bar.personal": "Personal",
   "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Preferences",
-  "navigation_bar.profile_directory": "Profile directory",
   "navigation_bar.public_timeline": "Federated timeline",
   "navigation_bar.security": "Security",
-  "notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
   "notification.favourite": "{name} favourited your status",
   "notification.follow": "{name} followed you",
   "notification.mention": "{name} mentioned you",
@@ -363,6 +371,7 @@
   "status.show_more": "Show more",
   "status.show_more_all": "Show more for all",
   "status.show_thread": "Show thread",
+  "status.uncached_media_warning": "Not available",
   "status.unmute_conversation": "Unmute conversation",
   "status.unpin": "Unpin from profile",
   "suggestions.dismiss": "Dismiss suggestion",
@@ -378,7 +387,7 @@
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
-  "trends.refresh": "Refresh",
+  "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media ({formats})",
diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js
index 089d920c3..c62ab0dfd 100644
--- a/app/javascript/mastodon/reducers/alerts.js
+++ b/app/javascript/mastodon/reducers/alerts.js
@@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) {
       key: state.size > 0 ? state.last().get('key') + 1 : 0,
       title: action.title,
       message: action.message,
+      message_values: action.message_values,
     }));
   case ALERT_DISMISS:
     return state.filterNot(item => item.get('key') === action.alert.key);
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 7b0cdd5a5..268237846 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -17,6 +17,7 @@ import {
   COMPOSE_SUGGESTIONS_CLEAR,
   COMPOSE_SUGGESTIONS_READY,
   COMPOSE_SUGGESTION_SELECT,
+  COMPOSE_SUGGESTION_TAGS_UPDATE,
   COMPOSE_TAG_HISTORY_UPDATE,
   COMPOSE_SENSITIVITY_CHANGE,
   COMPOSE_SPOILERNESS_CHANGE,
@@ -205,16 +206,36 @@ const expiresInFromExpiresAt = expires_at => {
   return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
 };
 
-const normalizeSuggestions = (state, { accounts, emojis, tags }) => {
+const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
+  prefix = prefix.toLowerCase();
+  if (suggestions.length < 4) {
+    const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase()));
+    return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
+  } else {
+    return suggestions;
+  }
+};
+
+const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => {
   if (accounts) {
     return accounts.map(item => ({ id: item.id, type: 'account' }));
   } else if (emojis) {
     return emojis.map(item => ({ ...item, type: 'emoji' }));
   } else {
-    return sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' })));
+    return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory'));
   }
 };
 
+const updateSuggestionTags = (state, token) => {
+  const prefix = token.slice(1);
+
+  const suggestions = state.get('suggestions').toJS();
+  return state.merge({
+    suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))),
+    suggestion_token: token,
+  });
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
@@ -328,6 +349,8 @@ export default function compose(state = initialState, action) {
     return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
   case COMPOSE_SUGGESTION_SELECT:
     return insertSuggestion(state, action.position, action.token, action.completion, action.path);
+  case COMPOSE_SUGGESTION_TAGS_UPDATE:
+    return updateSuggestionTags(state, action.token);
   case COMPOSE_TAG_HISTORY_UPDATE:
     return state.set('tagHistory', fromJS(action.tags));
   case TIMELINE_DELETE:
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
index 8db18c5dc..08e94022f 100644
--- a/app/javascript/mastodon/reducers/user_lists.js
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -20,6 +20,14 @@ import {
   MUTES_FETCH_SUCCESS,
   MUTES_EXPAND_SUCCESS,
 } from '../actions/mutes';
+import {
+  DIRECTORY_FETCH_REQUEST,
+  DIRECTORY_FETCH_SUCCESS,
+  DIRECTORY_FETCH_FAIL,
+  DIRECTORY_EXPAND_REQUEST,
+  DIRECTORY_EXPAND_SUCCESS,
+  DIRECTORY_EXPAND_FAIL,
+} from 'mastodon/actions/directory';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 const initialState = ImmutableMap({
@@ -74,6 +82,16 @@ export default function userLists(state = initialState, action) {
     return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
   case MUTES_EXPAND_SUCCESS:
     return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+  case DIRECTORY_FETCH_SUCCESS:
+    return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
+  case DIRECTORY_EXPAND_SUCCESS:
+    return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
+  case DIRECTORY_FETCH_REQUEST:
+  case DIRECTORY_EXPAND_REQUEST:
+    return state.setIn(['directory', 'isLoading'], true);
+  case DIRECTORY_FETCH_FAIL:
+  case DIRECTORY_EXPAND_FAIL:
+    return state.setIn(['directory', 'isLoading'], false);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/rtl.js b/app/javascript/mastodon/rtl.js
index 00870a15d..89bed6de8 100644
--- a/app/javascript/mastodon/rtl.js
+++ b/app/javascript/mastodon/rtl.js
@@ -20,6 +20,7 @@ export function isRtl(text) {
   text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, '');
   text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, '');
   text = text.replace(/\s+/g, '');
+  text = text.replace(/(\w\S+\.\w{2,}\S*)/g, '');
 
   const matches = text.match(rtlChars);
 
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index c87654547..6f1ce9602 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -128,6 +128,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => {
   base.forEach(item => {
     arr.push({
       message: item.get('message'),
+      message_values: item.get('message_values'),
       title: item.get('title'),
       key: item.get('key'),
       dismissAfter: 5000,
diff --git a/app/javascript/mastodon/utils/log_out.js b/app/javascript/mastodon/utils/log_out.js
new file mode 100644
index 000000000..b43417f4b
--- /dev/null
+++ b/app/javascript/mastodon/utils/log_out.js
@@ -0,0 +1,33 @@
+import Rails from 'rails-ujs';
+
+export const logOut = () => {
+  const form = document.createElement('form');
+
+  const methodInput = document.createElement('input');
+  methodInput.setAttribute('name', '_method');
+  methodInput.setAttribute('value', 'delete');
+  methodInput.setAttribute('type', 'hidden');
+  form.appendChild(methodInput);
+
+  const csrfToken = Rails.csrfToken();
+  const csrfParam = Rails.csrfParam();
+
+  if (csrfParam && csrfToken) {
+    const csrfInput = document.createElement('input');
+    csrfInput.setAttribute('name', csrfParam);
+    csrfInput.setAttribute('value', csrfToken);
+    csrfInput.setAttribute('type', 'hidden');
+    form.appendChild(csrfInput);
+  }
+
+  const submitButton = document.createElement('input');
+  submitButton.setAttribute('type', 'submit');
+  form.appendChild(submitButton);
+
+  form.method = 'post';
+  form.action = '/auth/sign_out';
+  form.style.display = 'none';
+
+  document.body.appendChild(form);
+  submitButton.click();
+};
diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss
index b4fb1d709..e25a80c04 100644
--- a/app/javascript/styles/mailer.scss
+++ b/app/javascript/styles/mailer.scss
@@ -457,6 +457,13 @@ h5 {
 .status {
   padding-bottom: 32px;
 
+  &--highlighted {
+    border: 1px solid lighten($ui-base-color, 8%);
+    border-radius: 4px;
+    padding-bottom: 16px;
+    margin-bottom: 16px;
+  }
+
   .status-header {
     td {
       font-size: 14px;
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index ee8a7d265..e7114ed07 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -104,7 +104,8 @@ html {
 .box-widget input[type="email"],
 .box-widget input[type="password"],
 .box-widget textarea,
-.statuses-grid .detailed-status {
+.statuses-grid .detailed-status,
+.audio-player {
   border: 1px solid lighten($ui-base-color, 8%);
 }
 
@@ -700,3 +701,10 @@ html {
 .compose-form .compose-form__warning {
   box-shadow: none;
 }
+
+.audio-player .video-player__controls button,
+.audio-player .video-player__time-sep,
+.audio-player .video-player__time-current,
+.audio-player .video-player__time-total {
+  color: $primary-text-color;
+}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 5c30c1295..dee3c3439 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -507,6 +507,7 @@
       flex: 1 1 auto;
       overflow: hidden;
       text-overflow: ellipsis;
+      white-space: nowrap;
     }
 
     strong {
@@ -515,8 +516,10 @@
 
     &__uses {
       flex: 0 0 auto;
-      width: 80px;
       text-align: right;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
     }
   }
 
@@ -948,7 +951,8 @@
   opacity: 1;
   animation: fade 150ms linear;
 
-  .video-player {
+  .video-player,
+  .audio-player {
     margin-top: 8px;
   }
 
@@ -1043,7 +1047,8 @@
       white-space: normal;
     }
 
-    .video-player {
+    .video-player,
+    .audio-player {
       margin-top: 8px;
       max-width: 250px;
     }
@@ -1154,7 +1159,8 @@
     }
   }
 
-  .video-player {
+  .video-player,
+  .audio-player {
     margin-top: 8px;
   }
 }
@@ -2089,13 +2095,23 @@ a.account__display-name {
     padding: 0;
   }
 
-  //.column {
-  //  margin-top: 0;
+  .directory__list {
+    display: grid;
+    grid-gap: 10px;
+    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
 
-  //  @media screen and (min-width: $no-gap-breakpoint) {
-  //    margin-top: 10px;
-  //  }
-  //}
+    @media screen and (max-width: $no-gap-breakpoint) {
+      display: block;
+    }
+  }
+
+  .directory__card {
+    margin-bottom: 0;
+  }
+
+  .filter-form {
+    display: flex;
+  }
 
   .autosuggest-textarea__textarea {
     font-size: 16px;
@@ -2130,7 +2146,8 @@ a.account__display-name {
       padding: 15px;
 
       .media-gallery,
-      .video-player {
+      .video-player,
+      .audio-player {
         margin-top: 15px;
       }
     }
@@ -2172,7 +2189,8 @@ a.account__display-name {
 
       .media-gallery,
       &__action-bar,
-      .video-player {
+      .video-player,
+      .audio-player {
         margin-top: 10px;
       }
     }
@@ -2765,6 +2783,15 @@ a.account__display-name {
     animation: fade 150ms linear;
     margin-top: 10px;
 
+    h4 {
+      font-size: 12px;
+      text-transform: uppercase;
+      color: $darker-text-color;
+      padding: 10px;
+      font-weight: 500;
+      border-bottom: 1px solid lighten($ui-base-color, 8%);
+    }
+
     @media screen and (max-height: 810px) {
       .trends__item:nth-child(3) {
         display: none;
@@ -3425,6 +3452,10 @@ a.status-card.compact:hover {
     height: auto;
   }
 
+  &--click-thru {
+    pointer-events: none;
+  }
+
   &--hidden {
     display: none;
   }
@@ -3453,6 +3484,12 @@ a.status-card.compact:hover {
         background: rgba($base-overlay-background, 0.8);
       }
     }
+
+    &:disabled {
+      .spoiler-button__overlay__label {
+        background: rgba($base-overlay-background, 0.5);
+      }
+    }
   }
 }
 
@@ -4968,70 +5005,63 @@ a.status-card.compact:hover {
 }
 /* End Media Gallery */
 
-/* Status Video Player */
-.status__video-player {
-  background: $base-overlay-background;
-  box-sizing: border-box;
-  cursor: default; /* May not be needed */
-  margin-top: 8px;
-  overflow: hidden;
-  position: relative;
-}
+.detailed,
+.fullscreen {
+  .video-player__volume__current,
+  .video-player__volume::before {
+    bottom: 27px;
+  }
 
-.status__video-player-video {
-  height: 100%;
-  object-fit: cover;
-  position: relative;
-  top: 50%;
-  transform: translateY(-50%);
-  width: 100%;
-  z-index: 1;
-}
+  .video-player__volume__handle {
+    bottom: 23px;
+  }
 
-.status__video-player-expand,
-.status__video-player-mute {
-  color: $primary-text-color;
-  opacity: 0.8;
-  position: absolute;
-  right: 4px;
-  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
 }
 
-.status__video-player-spoiler {
-  display: none;
-  color: $primary-text-color;
-  left: 4px;
-  position: absolute;
-  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
-  top: 4px;
-  z-index: 100;
+.audio-player {
+  box-sizing: border-box;
+  position: relative;
+  background: darken($ui-base-color, 8%);
+  border-radius: 4px;
+  padding-bottom: 44px;
 
-  &.status__video-player-spoiler--visible {
-    display: block;
+  &.editable {
+    border-radius: 0;
+    height: 100%;
   }
-}
 
-.status__video-player-expand {
-  bottom: 4px;
-  z-index: 100;
-}
+  &__waveform {
+    padding: 15px 0;
+    position: relative;
+    overflow: hidden;
 
-.status__video-player-mute {
-  top: 4px;
-  z-index: 5;
-}
+    &::before {
+      content: "";
+      display: block;
+      position: absolute;
+      border-top: 1px solid lighten($ui-base-color, 4%);
+      width: 100%;
+      height: 0;
+      left: 0;
+      top: calc(50% + 1px);
+    }
+  }
 
-.detailed,
-.fullscreen {
-  .video-player__volume__current,
-  .video-player__volume::before {
-    bottom: 27px;
+  &__progress-placeholder {
+    background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);
   }
 
-  .video-player__volume__handle {
-    bottom: 23px;
+  &__wave-placeholder {
+    background-color: lighten($ui-base-color, 16%);
   }
 
+  .video-player__controls {
+    padding: 0 15px;
+    padding-top: 10px;
+    background: darken($ui-base-color, 8%);
+    border-top: 1px solid lighten($ui-base-color, 4%);
+    border-radius: 0 0 4px 4px;
+  }
 }
 
 .video-player {
@@ -5040,9 +5070,11 @@ a.status-card.compact:hover {
   background: $base-shadow-color;
   max-width: 100%;
   border-radius: 4px;
+  box-sizing: border-box;
 
   &.editable {
     border-radius: 0;
+    height: 100% !important;
   }
 
   &:focus {
@@ -5325,28 +5357,137 @@ a.status-card.compact:hover {
   }
 }
 
-.media-spoiler-video {
-  background-size: cover;
-  background-repeat: no-repeat;
-  background-position: center;
-  cursor: pointer;
-  margin-top: 8px;
-  position: relative;
-  border: 0;
-  display: block;
-}
+.directory {
+  &__list {
+    width: 100%;
+    margin: 10px 0;
+    transition: opacity 100ms ease-in;
 
-.media-spoiler-video-play-icon {
-  border-radius: 100px;
-  color: rgba($primary-text-color, 0.8);
-  font-size: 36px;
-  left: 50%;
-  padding: 5px;
-  position: absolute;
-  top: 50%;
-  transform: translate(-50%, -50%);
+    &.loading {
+      opacity: 0.7;
+    }
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      margin: 0;
+    }
+  }
+
+  &__card {
+    box-sizing: border-box;
+    margin-bottom: 10px;
+
+    &__img {
+      height: 125px;
+      position: relative;
+      background: darken($ui-base-color, 12%);
+      overflow: hidden;
+
+      img {
+        display: block;
+        width: 100%;
+        height: 100%;
+        margin: 0;
+        object-fit: cover;
+      }
+    }
+
+    &__bar {
+      display: flex;
+      align-items: center;
+      background: lighten($ui-base-color, 4%);
+      padding: 10px;
+
+      &__name {
+        flex: 1 1 auto;
+        display: flex;
+        align-items: center;
+        text-decoration: none;
+        overflow: hidden;
+      }
+
+      &__relationship {
+        width: 23px;
+        min-height: 1px;
+        flex: 0 0 auto;
+      }
+
+      .avatar {
+        flex: 0 0 auto;
+        width: 48px;
+        height: 48px;
+        padding-top: 2px;
+
+        img {
+          width: 100%;
+          height: 100%;
+          display: block;
+          margin: 0;
+          border-radius: 4px;
+          background: darken($ui-base-color, 8%);
+          object-fit: cover;
+        }
+      }
+
+      .display-name {
+        margin-left: 15px;
+        text-align: left;
+
+        strong {
+          font-size: 15px;
+          color: $primary-text-color;
+          font-weight: 500;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+
+        span {
+          display: block;
+          font-size: 14px;
+          color: $darker-text-color;
+          font-weight: 400;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+      }
+    }
+
+    &__extra {
+      background: $ui-base-color;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      .accounts-table__count {
+        width: 33.33%;
+        flex: 0 0 auto;
+        padding: 15px 0;
+      }
+
+      .account__header__content {
+        box-sizing: border-box;
+        padding: 15px 10px;
+        border-bottom: 1px solid lighten($ui-base-color, 8%);
+        width: 100%;
+        min-height: 18px + 30px;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+
+        p {
+          display: none;
+
+          &:first-child {
+            display: inline;
+          }
+        }
+
+        br {
+          display: none;
+        }
+      }
+    }
+  }
 }
-/* End Video Player */
 
 .account-gallery__container {
   display: flex;
@@ -5422,6 +5563,73 @@ a.status-card.compact:hover {
       }
     }
   }
+
+  &.directory__section-headline {
+    background: darken($ui-base-color, 2%);
+    border-bottom-color: transparent;
+
+    a,
+    button {
+      &.active {
+        &::before {
+          display: none;
+        }
+
+        &::after {
+          border-color: transparent transparent darken($ui-base-color, 7%);
+        }
+      }
+    }
+  }
+}
+
+.filter-form {
+  background: $ui-base-color;
+
+  &__column {
+    padding: 10px 15px;
+  }
+
+  .radio-button {
+    display: block;
+  }
+}
+
+.radio-button {
+  font-size: 14px;
+  position: relative;
+  display: inline-block;
+  padding: 6px 0;
+  line-height: 18px;
+  cursor: default;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  cursor: pointer;
+
+  input[type=radio],
+  input[type=checkbox] {
+    display: none;
+  }
+
+  &__input {
+    display: inline-block;
+    position: relative;
+    border: 1px solid $ui-primary-color;
+    box-sizing: border-box;
+    width: 18px;
+    height: 18px;
+    flex: 0 0 auto;
+    margin-right: 10px;
+    top: -1px;
+    border-radius: 50%;
+    vertical-align: middle;
+
+    &.checked {
+      border-color: lighten($ui-highlight-color, 8%);
+      background: lighten($ui-highlight-color, 8%);
+    }
+  }
 }
 
 ::-webkit-scrollbar-thumb {
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 2b6794ee2..e769c495b 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -763,6 +763,24 @@
     }
   }
 
+  .directory__list {
+    display: grid;
+    grid-gap: 10px;
+    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      display: block;
+    }
+
+    .icon-button {
+      font-size: 18px;
+    }
+  }
+
+  .directory__card {
+    margin-bottom: 0;
+  }
+
   .card-grid {
     display: flex;
     flex-wrap: wrap;
diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss
index e4564f062..c0944d417 100644
--- a/app/javascript/styles/mastodon/dashboard.scss
+++ b/app/javascript/styles/mastodon/dashboard.scss
@@ -15,6 +15,8 @@
       padding: 20px;
       background: lighten($ui-base-color, 4%);
       border-radius: 4px;
+      box-sizing: border-box;
+      height: 100%;
     }
 
     & > a {
diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss
index f74c004e9..00d290883 100644
--- a/app/javascript/styles/mastodon/footer.scss
+++ b/app/javascript/styles/mastodon/footer.scss
@@ -128,7 +128,7 @@
       &:hover,
       &:focus,
       &:active {
-        svg path {
+        svg {
           fill: lighten($ui-base-color, 38%);
         }
       }
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index ac99124ea..16352340b 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -112,6 +112,15 @@ code {
       padding: 0.2em 0.4em;
       background: darken($ui-base-color, 12%);
     }
+
+    li {
+      list-style: disc;
+      margin-left: 18px;
+    }
+  }
+
+  ul.hint {
+    margin-bottom: 15px;
   }
 
   span.hint {
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index 1f2b40c15..345060462 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -70,7 +70,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
   end
 
   def delete_now!
-    RemoveStatusService.new.call(@status)
+    RemoveStatusService.new.call(@status, redraft: false)
   end
 
   def payload
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index a1d84de2f..cb2ac72d4 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -20,6 +20,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
     focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
     identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
     blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
+    discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
   }.freeze
 
   def self.default_key_transform
@@ -31,22 +32,23 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
   end
 
   def serializable_hash(options = nil)
+    named_contexts     = {}
+    context_extensions = {}
     options         = serialization_options(options)
-    serialized_hash = serializer.serializable_hash(options)
+    serialized_hash = serializer.serializable_hash(options.merge(named_contexts: named_contexts, context_extensions: context_extensions))
     serialized_hash = serialized_hash.select { |k, _| options[:fields].include?(k) } if options[:fields]
     serialized_hash = self.class.transform_key_casing!(serialized_hash, instance_options)
 
-    { '@context' => serialized_context }.merge(serialized_hash)
+    { '@context' => serialized_context(named_contexts, context_extensions) }.merge(serialized_hash)
   end
 
   private
 
-  def serialized_context
+  def serialized_context(named_contexts_map, context_extensions_map)
     context_array = []
 
-    serializer_options = serializer.send(:instance_options) || {}
-    named_contexts     = [:activitystreams] + serializer._named_contexts.keys + serializer_options.fetch(:named_contexts, {}).keys
-    context_extensions = serializer._context_extensions.keys + serializer_options.fetch(:context_extensions, {}).keys
+    named_contexts     = [:activitystreams] + named_contexts_map.keys
+    context_extensions = context_extensions_map.keys
 
     named_contexts.each do |key|
       context_array << NAMED_CONTEXT_MAP[key]
diff --git a/app/lib/activitypub/serializer.rb b/app/lib/activitypub/serializer.rb
index 07bd8c494..1fdc79310 100644
--- a/app/lib/activitypub/serializer.rb
+++ b/app/lib/activitypub/serializer.rb
@@ -27,4 +27,12 @@ class ActivityPub::Serializer < ActiveModel::Serializer
       _context_extensions[extension_name] = true
     end
   end
+
+  def serializable_hash(adapter_options = nil, options = {}, adapter_instance = self.class.serialization_adapter_instance)
+    unless adapter_options&.fetch(:named_contexts, nil).nil?
+      adapter_options[:named_contexts].merge!(_named_contexts)
+      adapter_options[:context_extensions].merge!(_context_extensions)
+    end
+    super(adapter_options, options, adapter_instance)
+  end
 end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 224d90660..4587664b8 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -78,7 +78,7 @@ class FeedManager
     reblog_key   = key(type, account_id, 'reblogs')
 
     # Remove any items past the MAX_ITEMS'th entry in our feed
-    redis.zremrangebyrank(timeline_key, '0', (-(FeedManager::MAX_ITEMS + 1)).to_s)
+    redis.zremrangebyrank(timeline_key, 0, -(FeedManager::MAX_ITEMS + 1))
 
     # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop
     # tracking anything after it for deduplication purposes.
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 9d874fe2c..42ccc6513 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -191,6 +191,9 @@ class Request
           end
         end
 
+        socks = []
+        addr_by_socket = {}
+
         addresses.each do |address|
           begin
             check_private_address(address)
@@ -200,30 +203,45 @@ class Request
 
             sock.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, 1)
 
-            begin
-              sock.connect_nonblock(sockaddr)
-            rescue IO::WaitWritable
-              if IO.select(nil, [sock], nil, Request::TIMEOUT[:connect])
-                begin
-                  sock.connect_nonblock(sockaddr)
-                rescue Errno::EISCONN
-                  # Yippee!
-                rescue
-                  sock.close
-                  raise
-                end
-              else
-                sock.close
-                raise HTTP::TimeoutError, "Connect timed out after #{Request::TIMEOUT[:connect]} seconds"
-              end
-            end
+            sock.connect_nonblock(sockaddr)
 
+            # If that hasn't raised an exception, we somehow managed to connect
+            # immediately, close pending sockets and return immediately
+            socks.each(&:close)
             return sock
+          rescue IO::WaitWritable
+            socks << sock
+            addr_by_socket[sock] = sockaddr
           rescue => e
             outer_e = e
           end
         end
 
+        until socks.empty?
+          _, available_socks, = IO.select(nil, socks, nil, Request::TIMEOUT[:connect])
+
+          if available_socks.nil?
+            socks.each(&:close)
+            raise HTTP::TimeoutError, "Connect timed out after #{Request::TIMEOUT[:connect]} seconds"
+          end
+
+          available_socks.each do |sock|
+            socks.delete(sock)
+
+            begin
+              sock.connect_nonblock(addr_by_socket[sock])
+            rescue Errno::EISCONN
+            rescue => e
+              sock.close
+              outer_e = e
+              next
+            end
+
+            socks.each(&:close)
+            return sock
+          end
+        end
+
         if outer_e
           raise outer_e
         else
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 8f3a4ab3a..b41004acc 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -5,6 +5,7 @@ class UserMailer < Devise::Mailer
 
   helper :application
   helper :instance
+  helper :statuses
 
   add_template_helper RoutingHelper
 
@@ -79,10 +80,11 @@ class UserMailer < Devise::Mailer
     end
   end
 
-  def warning(user, warning)
+  def warning(user, warning, status_ids = nil)
     @resource = user
     @warning  = warning
     @instance = Rails.configuration.x.local_domain
+    @statuses = Status.where(id: status_ids).includes(:account) if status_ids.is_a?(Array)
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email,
diff --git a/app/models/account.rb b/app/models/account.rb
index 9d938c55d..918b17430 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -51,7 +51,6 @@
 class Account < ApplicationRecord
   USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
   MENTION_RE  = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
-  MIN_FOLLOWERS_DISCOVERY = 10
 
   include AccountAssociations
   include AccountAvatar
@@ -104,11 +103,13 @@ class Account < ApplicationRecord
   scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
   scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
-  scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) }
+  scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
   scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
-  scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) }
+  scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
   scope :popular, -> { order('account_stats.followers_count desc') }
   scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
+  scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
+  scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
 
   delegate :email,
            :unconfirmed_email,
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
index bdbd342fb..c7da8b52c 100644
--- a/app/models/admin/account_action.rb
+++ b/app/models/admin/account_action.rb
@@ -19,20 +19,25 @@ class Admin::AccountAction
                 :report_id,
                 :warning_preset_id
 
-  attr_reader :warning, :send_email_notification
+  attr_reader :warning, :send_email_notification, :include_statuses
 
   def send_email_notification=(value)
     @send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
   end
 
+  def include_statuses=(value)
+    @include_statuses = ActiveModel::Type::Boolean.new.cast(value)
+  end
+
   def save!
     ApplicationRecord.transaction do
       process_action!
       process_warning!
     end
 
-    queue_email!
+    process_email!
     process_reports!
+    process_queue!
   end
 
   def report
@@ -110,7 +115,6 @@ class Admin::AccountAction
     authorize(target_account, :suspend?)
     log_action(:suspend, target_account)
     target_account.suspend!
-    queue_suspension_worker!
   end
 
   def text_for_warning
@@ -121,16 +125,22 @@ class Admin::AccountAction
     Admin::SuspensionWorker.perform_async(target_account.id)
   end
 
-  def queue_email!
-    return unless warnable?
+  def process_queue!
+    queue_suspension_worker! if type == 'suspend'
+  end
 
-    UserMailer.warning(target_account.user, warning).deliver_later!
+  def process_email!
+    UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable?
   end
 
   def warnable?
     send_email_notification && target_account.local?
   end
 
+  def status_ids
+    @report.status_ids if @report && include_statuses
+  end
+
   def warning_preset
     @warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present?
   end
diff --git a/app/models/feed.rb b/app/models/feed.rb
index 0e8943ff8..36e0c1e0a 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -9,6 +9,11 @@ class Feed
   end
 
   def get(limit, max_id = nil, since_id = nil, min_id = nil)
+    limit    = limit.to_i
+    max_id   = max_id.to_i if max_id.present?
+    since_id = since_id.to_i if since_id.present?
+    min_id   = min_id.to_i if min_id.present?
+
     from_redis(limit, max_id, since_id, min_id)
   end
 
diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb
index 933dfdaca..e09cc2594 100644
--- a/app/models/form/status_batch.rb
+++ b/app/models/form/status_batch.rb
@@ -34,7 +34,8 @@ class Form::StatusBatch
 
   def delete_statuses
     Status.where(id: status_ids).reorder(nil).find_each do |status|
-      RemovalWorker.perform_async(status.id)
+      status.discard
+      RemovalWorker.perform_async(status.id, redraft: false)
       Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
       log_action :destroy, status
     end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index d03751fd3..83d1858aa 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -28,12 +28,12 @@ class MediaAttachment < ApplicationRecord
 
   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
   VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
-  AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp).freeze
+  AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze
 
   IMAGE_MIME_TYPES             = %w(image/jpeg image/png image/gif).freeze
   VIDEO_MIME_TYPES             = %w(video/webm video/mp4 video/quicktime video/ogg).freeze
   VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze
-  AUDIO_MIME_TYPES             = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/3gpp).freeze
+  AUDIO_MIME_TYPES             = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
 
   BLURHASH_OPTIONS = {
     x_comp: 4,
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 93df11724..52dd3f67b 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -6,7 +6,7 @@ class RemoteFollow
 
   attr_accessor :acct, :addressable_template
 
-  validates :acct, presence: true
+  validates :acct, presence: true, domain: { acct: true }
 
   def initialize(attrs = {})
     @acct = normalize_acct(attrs[:acct])
@@ -21,7 +21,7 @@ class RemoteFollow
   end
 
   def subscribe_address_for(account)
-    addressable_template.expand(uri: account.local_username_and_domain).to_s
+    addressable_template.expand(uri: ActivityPub::TagManager.instance.uri_for(account)).to_s
   end
 
   def interact_address_for(status)
@@ -44,6 +44,8 @@ class RemoteFollow
     end
 
     [username, domain].compact.join('@')
+  rescue Addressable::URI::InvalidURIError
+    value
   end
 
   def fetch_template!
diff --git a/app/models/report.rb b/app/models/report.rb
index 5192ceef7..1e707ff1c 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -43,7 +43,7 @@ class Report < ApplicationRecord
   end
 
   def statuses
-    Status.where(id: status_ids).includes(:account, :media_attachments, :mentions)
+    Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions)
   end
 
   def media_attachments
diff --git a/app/models/status.rb b/app/models/status.rb
index de790027d..757deea06 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -25,15 +25,19 @@
 #  full_status_text       :text             default(""), not null
 #  poll_id                :bigint(8)
 #  content_type           :string
+#  deleted_at             :datetime
 #
 
 class Status < ApplicationRecord
   before_destroy :unlink_from_conversations
 
+  include Discard::Model
   include Paginable
   include Cacheable
   include StatusThreadingConcern
 
+  self.discard_column = :deleted_at
+
   # If `override_timestamps` is set at creation time, Snowflake ID creation
   # will be based on current time instead of `created_at`
   attr_accessor :override_timestamps
@@ -77,7 +81,7 @@ class Status < ApplicationRecord
 
   accepts_nested_attributes_for :poll
 
-  default_scope { recent }
+  default_scope { recent.kept }
 
   scope :recent, -> { reorder(id: :desc) }
   scope :remote, -> { where(local: false).where.not(uri: nil) }
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 945e3a3c6..135e0a030 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -7,14 +7,14 @@
 #  name                :string           default(""), not null
 #  created_at          :datetime         not null
 #  updated_at          :datetime         not null
-#  score               :integer
 #  usable              :boolean
 #  trendable           :boolean
 #  listable            :boolean
 #  reviewed_at         :datetime
 #  requested_review_at :datetime
 #  last_status_at      :datetime
-#  last_trend_at       :datetime
+#  max_score           :float
+#  max_score_at        :datetime
 #
 
 class Tag < ApplicationRecord
diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb
index e4ce988c1..e1b92b175 100644
--- a/app/models/trending_tags.rb
+++ b/app/models/trending_tags.rb
@@ -7,6 +7,8 @@ class TrendingTags
   THRESHOLD            = 5
   LIMIT                = 10
   REVIEW_THRESHOLD     = 3
+  MAX_SCORE_COOLDOWN   = 3.days.freeze
+  MAX_SCORE_HALFLIFE   = 6.hours.freeze
 
   class << self
     include Redisable
@@ -16,14 +18,75 @@ class TrendingTags
 
       increment_historical_use!(tag.id, at_time)
       increment_unique_use!(tag.id, account.id, at_time)
-      increment_vote!(tag, at_time)
+      increment_use!(tag.id, at_time)
 
       tag.update(last_status_at: Time.now.utc) if tag.last_status_at.nil? || tag.last_status_at < 12.hours.ago
-      tag.update(last_trend_at: Time.now.utc)  if trending?(tag) && (tag.last_trend_at.nil? || tag.last_trend_at < 12.hours.ago)
+    end
+
+    def update!(at_time = Time.now.utc)
+      tag_ids = redis.smembers("#{KEY}:used:#{at_time.beginning_of_day.to_i}") + redis.zrange(KEY, 0, -1)
+      tags    = Tag.where(id: tag_ids.uniq)
+
+      # First pass to calculate scores and update the set
+
+      tags.each do |tag|
+        expected  = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
+        expected  = 1.0 if expected.zero?
+        observed  = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
+        max_time  = tag.max_score_at
+        max_score = tag.max_score
+        max_score = 0 if max_time.nil? || max_time < (at_time - MAX_SCORE_COOLDOWN)
+
+        score = begin
+          if expected > observed || observed < THRESHOLD
+            0
+          else
+            ((observed - expected)**2) / expected
+          end
+        end
+
+        if score > max_score
+          max_score = score
+          max_time  = at_time
+
+          # Not interested in triggering any callbacks for this
+          tag.update_columns(max_score: max_score, max_score_at: max_time)
+        end
+
+        decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / MAX_SCORE_HALFLIFE.to_f))
+
+        if decaying_score.zero?
+          redis.zrem(KEY, tag.id)
+        else
+          redis.zadd(KEY, decaying_score, tag.id)
+        end
+      end
+
+      users_for_review = User.staff.includes(:account).to_a.select(&:allows_trending_tag_emails?)
+
+      # Second pass to notify about previously unreviewed trends
+
+      tags.each do |tag|
+        current_rank              = redis.zrevrank(KEY, tag.id)
+        needs_review_notification = tag.requires_review? && !tag.requested_review?
+        rank_passes_threshold     = current_rank.present? && current_rank <= REVIEW_THRESHOLD
+
+        next unless !tag.trendable? && rank_passes_threshold && needs_review_notification
+
+        tag.touch(:requested_review_at)
+
+        users_for_review.each do |user|
+          AdminMailer.new_trending_tag(user.account, tag).deliver_later!
+        end
+      end
+
+      # Trim older items
+
+      redis.zremrangebyrank(KEY, 0, -(LIMIT + 1))
     end
 
     def get(limit, filtered: true)
-      tag_ids = redis.zrevrange("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", 0, LIMIT - 1).map(&:to_i)
+      tag_ids = redis.zrevrange(KEY, 0, LIMIT - 1).map(&:to_i)
 
       tags = Tag.where(id: tag_ids)
       tags = tags.where(trendable: true) if filtered
@@ -33,8 +96,8 @@ class TrendingTags
     end
 
     def trending?(tag)
-      rank = redis.zrevrank("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", tag.id)
-      rank.present? && rank <= LIMIT
+      rank = redis.zrevrank(KEY, tag.id)
+      rank.present? && rank < LIMIT
     end
 
     private
@@ -51,31 +114,10 @@ class TrendingTags
       redis.expire(key, EXPIRE_HISTORY_AFTER)
     end
 
-    def increment_vote!(tag, at_time)
-      key      = "#{KEY}:#{at_time.beginning_of_day.to_i}"
-      expected = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
-      expected = 1.0 if expected.zero?
-      observed = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
-
-      if expected > observed || observed < THRESHOLD
-        redis.zrem(key, tag.id)
-      else
-        score    = ((observed - expected)**2) / expected
-        old_rank = redis.zrevrank(key, tag.id)
-
-        redis.zadd(key, score, tag.id)
-        request_review!(tag) if (old_rank.nil? || old_rank > REVIEW_THRESHOLD) && redis.zrevrank(key, tag.id) <= REVIEW_THRESHOLD && !tag.trendable? && tag.requires_review? && !tag.requested_review?
-      end
-
-      redis.expire(key, EXPIRE_TRENDS_AFTER)
-    end
-
-    def request_review!(tag)
-      return unless Setting.trends
-
-      tag.touch(:requested_review_at)
-
-      User.staff.includes(:account).find_each { |u| AdminMailer.new_trending_tag(u.account, tag).deliver_later! if u.allows_trending_tag_emails? }
+    def increment_use!(tag_id, at_time)
+      key = "#{KEY}:used:#{at_time.beginning_of_day.to_i}"
+      redis.sadd(key, tag_id)
+      redis.expire(key, EXPIRE_HISTORY_AFTER)
     end
   end
 end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index 0bd7aed2e..17df85de3 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -6,12 +6,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   context :security
 
   context_extensions :manually_approves_followers, :featured, :also_known_as,
-                     :moved_to, :property_value, :hashtag, :emoji, :identity_proof
+                     :moved_to, :property_value, :identity_proof,
+                     :discoverable
 
   attributes :id, :type, :following, :followers,
              :inbox, :outbox, :featured,
              :preferred_username, :name, :summary,
-             :url, :manually_approves_followers
+             :url, :manually_approves_followers,
+             :discoverable
 
   has_one :public_key, serializer: ActivityPub::PublicKeySerializer
 
@@ -136,6 +138,8 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   end
 
   class TagSerializer < ActivityPub::Serializer
+    context_extensions :hashtag
+
     include RoutingHelper
 
     attributes :type, :href, :name
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 067ba5c32..f1cebbcd4 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -1,8 +1,7 @@
 # frozen_string_literal: true
 
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
-  context_extensions :atom_uri, :conversation, :sensitive,
-                     :hashtag, :emoji, :focal_point, :blurhash
+  context_extensions :atom_uri, :conversation, :sensitive
 
   attributes :id, :type, :summary,
              :in_reply_to, :published, :url,
@@ -152,6 +151,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   class MediaAttachmentSerializer < ActivityPub::Serializer
+    context_extensions :blurhash, :focal_point
+
     include RoutingHelper
 
     attributes :type, :media_type, :url, :name, :blurhash
@@ -199,6 +200,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   class TagSerializer < ActivityPub::Serializer
+    context_extensions :hashtag
+
     include RoutingHelper
 
     attributes :type, :href, :name
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 3ecce8f0a..63b84a0b9 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
 
   attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at,
              :note, :url, :avatar, :avatar_static, :header, :header_static,
-             :followers_count, :following_count, :statuses_count
+             :followers_count, :following_count, :statuses_count, :last_status_at
 
   has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
   has_many :emojis, serializer: REST::CustomEmojiSerializer
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 603e27ed9..cef658e19 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -83,6 +83,7 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.fields                  = property_values || {}
     @account.also_known_as           = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
     @account.actor_type              = actor_type
+    @account.discoverable            = @json['discoverable'] || false
   end
 
   def set_fetchable_attributes!
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index c9a9a5a6e..31237337a 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -8,7 +8,7 @@ class BatchedRemoveStatusService < BaseService
   # Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones
   # Remove statuses from home feeds
   # Push delete events to streaming API for home feeds and public feeds
-  # @param [Status] statuses A preferably batched array of statuses
+  # @param [Enumerable<Status>] statuses A preferably batched array of statuses
   # @param [Hash] options
   # @option [Boolean] :skip_side_effects
   def call(statuses, **options)
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index b36471339..5d17f111b 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -49,7 +49,13 @@ class PostStatusService < BaseService
   def preprocess_attributes!
     if @text.blank? && @options[:spoiler_text].present?
      @text = '.'
-     @text = @media.find(&:video?) ? '📹' : '🖼' if @media.size > 0
+     if @media.find(&:video?) || @media.find(&:gifv?)
+       @text = '📹'
+     elsif @media.find(&:audio?)
+       @text = '🎵'
+     elsif @media.find(&:image?)
+       @text = '🖼'
+     end
     end
     @visibility   = @options[:visibility] || @account.user&.setting_default_privacy
     @visibility   = :unlisted if @visibility == :public && @account.silenced?
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index c19fa2126..b2f712089 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -4,6 +4,11 @@ class RemoveStatusService < BaseService
   include Redisable
   include Payloadable
 
+  # Delete a status
+  # @param   [Status] status
+  # @param   [Hash] options
+  # @option  [Boolean] :redraft
+  # @options [Boolean] :original_removed
   def call(status, **options)
     @payload  = Oj.dump(event: :delete, payload: status.id.to_s)
     @status   = status
@@ -25,6 +30,7 @@ class RemoveStatusService < BaseService
         remove_from_media if status.media_attachments.any?
         remove_from_direct if status.direct_visibility?
         remove_from_spam_check
+        remove_media
 
         @status.destroy!
       else
@@ -151,6 +157,12 @@ class RemoveStatusService < BaseService
     end
   end
 
+  def remove_media
+    return if @options[:redraft]
+
+    @status.media_attachments.destroy_all
+  end
+
   def remove_from_spam_check
     redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id)
   end
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 902af376c..85da7e921 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -61,6 +61,7 @@ class SuspendAccountService < BaseService
     return if !@account.local? || @account.user.nil?
 
     if @options[:including_user]
+      @options[:destroy] = true if !@account.user_confirmed? || @account.user_pending?
       @account.user.destroy
     else
       @account.user.disable!
diff --git a/app/validators/domain_validator.rb b/app/validators/domain_validator.rb
index ae07f1798..6e4a854ff 100644
--- a/app/validators/domain_validator.rb
+++ b/app/validators/domain_validator.rb
@@ -4,14 +4,22 @@ class DomainValidator < ActiveModel::EachValidator
   def validate_each(record, attribute, value)
     return if value.blank?
 
-    record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(value)
+    domain = begin
+      if options[:acct]
+        value.split('@').last
+      else
+        value
+      end
+    end
+
+    record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(domain)
   end
 
   private
 
   def compliant?(value)
     Addressable::URI.new.tap { |uri| uri.host = value }
-  rescue Addressable::URI::InvalidURIError
+  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
     false
   end
 end
diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb
index 96fbedcfc..9b5009966 100644
--- a/app/validators/email_mx_validator.rb
+++ b/app/validators/email_mx_validator.rb
@@ -14,6 +14,7 @@ class EmailMxValidator < ActiveModel::Validator
 
     return true if domain.nil?
 
+    domain    = TagManager.instance.normalize_domain(domain)
     hostnames = []
     ips       = []
 
@@ -29,6 +30,8 @@ class EmailMxValidator < ActiveModel::Validator
     end
 
     ips.empty? || on_blacklist?(hostnames + ips)
+  rescue Addressable::URI::InvalidURIError
+    true
   end
 
   def on_blacklist?(values)
diff --git a/app/views/admin/account_actions/new.html.haml b/app/views/admin/account_actions/new.html.haml
index 97286c8e5..20fbeef33 100644
--- a/app/views/admin/account_actions/new.html.haml
+++ b/app/views/admin/account_actions/new.html.haml
@@ -13,6 +13,10 @@
     .fields-group
       = f.input :send_email_notification, as: :boolean, wrapper: :with_label
 
+    - if params[:report_id].present?
+      .fields-group
+        = f.input :include_statuses, as: :boolean, wrapper: :with_label
+
     %hr.spacer/
 
     - unless @warning_presets.empty?
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 408d515ca..af7a59802 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -105,7 +105,7 @@
         %li
           = feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch)
         %li
-          = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_mode)
+          = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled)
         %li
           = feature_hint('LDAP', @ldap_enabled)
         %li
diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml
index 982dc5035..1d85aa75e 100644
--- a/app/views/admin/instances/index.html.haml
+++ b/app/views/admin/instances/index.html.haml
@@ -44,15 +44,16 @@
             - if !instance.domain_block.noop?
               = t("admin.domain_blocks.severity.#{instance.domain_block.severity}")
               - first_item = false
-            - if instance.domain_block.reject_media?
-              - unless first_item
-                &bull;
-              = t('admin.domain_blocks.rejecting_media')
-              - first_item = false
-            - if instance.domain_block.reject_reports?
-              - unless first_item
-                &bull;
-              = t('admin.domain_blocks.rejecting_reports')
+            - unless instance.domain_block.suspend?
+              - if instance.domain_block.reject_media?
+                - unless first_item
+                  &bull;
+                = t('admin.domain_blocks.rejecting_media')
+                - first_item = false
+              - if instance.domain_block.reject_reports?
+                - unless first_item
+                  &bull;
+                = t('admin.domain_blocks.rejecting_reports')
           - elsif whitelist_mode?
             = t('admin.accounts.whitelisted')
           - else
diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml
index 9376db7ff..6facc0a56 100644
--- a/app/views/admin/reports/_status.html.haml
+++ b/app/views/admin/reports/_status.html.haml
@@ -16,11 +16,14 @@
         - video = status.proper.media_attachments.first
         = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description
       - else
-        = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
+        = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
 
     .detailed-status__meta
       = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do
         %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+      - if status.discarded?
+        ·
+        %span.negative-hint= t('admin.statuses.deleted')
       ·
       - if status.reblog?
         = fa_icon('retweet fw')
diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml
index c3779d48c..d54a43c1e 100644
--- a/app/views/admin/tags/show.html.haml
+++ b/app/views/admin/tags/show.html.haml
@@ -38,8 +38,10 @@
 .table-wrapper
   %table.table
     %tbody
+      - total = @usage_by_domain.sum(&:last).to_f
+
       - @usage_by_domain.each do |(domain, count)|
         %tr
           %th= domain || site_hostname
-          %td= number_to_percentage((count / @tag.history[0][:uses].to_f) * 100)
+          %td= number_to_percentage((count / total) * 100, precision: 1)
           %td= number_with_delimiter count
diff --git a/app/views/application/_card.html.haml b/app/views/application/_card.html.haml
index 00254c40c..8719ce484 100644
--- a/app/views/application/_card.html.haml
+++ b/app/views/application/_card.html.haml
@@ -9,7 +9,7 @@
         = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
 
       .display-name
-        %span{id: "default_account_display_name", style: "display:none;"}= account.username
+        %span{ id: "default_account_display_name", style: "display: none" }= account.username
         %bdi
           %strong.emojify.p-name= display_name(account, custom_emojify: true)
         %span
diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml
index 90c8f9dd1..33e7c96fe 100644
--- a/app/views/application/_sidebar.html.haml
+++ b/app/views/application/_sidebar.html.haml
@@ -5,7 +5,7 @@
   .hero-widget__text
     %p= @instance_presenter.site_short_description.html_safe.presence || @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
 
-- if Setting.trends
+- if Setting.trends && !(user_signed_in? && !current_user.setting_trends)
   - trends = TrendingTags.get(3)
 
   - unless trends.empty?
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index 83384d737..e807c8d86 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -2,7 +2,7 @@
   = t('auth.register')
 
 - content_for :header_tags do
-  = render partial: 'shared/og'
+  = render partial: 'shared/og', locals: { description: description_for_sign_up }
 
 = simple_form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f|
   = render 'shared/error_messages', object: resource
diff --git a/app/views/auth/setup/show.html.haml b/app/views/auth/setup/show.html.haml
index 8bb44ca7f..c14fed56f 100644
--- a/app/views/auth/setup/show.html.haml
+++ b/app/views/auth/setup/show.html.haml
@@ -17,7 +17,4 @@
   .simple_form
     %p.hint= t('auth.setup.email_settings_hint_html', email: content_tag(:strong, @user.email))
 
-.form-footer
-  %ul.no-list
-    %li= link_to t('settings.account_settings'), edit_user_registration_path
-    %li= link_to t('auth.logout'), destroy_user_session_path, data: { method: :delete }
+.form-footer= render 'auth/shared/links'
diff --git a/app/views/auth/shared/_links.html.haml b/app/views/auth/shared/_links.html.haml
index 3c68ccd22..e6c3f7cca 100644
--- a/app/views/auth/shared/_links.html.haml
+++ b/app/views/auth/shared/_links.html.haml
@@ -1,12 +1,18 @@
 %ul.no-list
-  - if controller_name != 'sessions'
-    %li= link_to t('auth.login'), new_session_path(resource_name)
+  - if user_signed_in?
+    %li= link_to t('settings.account_settings'), edit_user_registration_path
+  - else
+    - if controller_name != 'sessions'
+      %li= link_to t('auth.login'), new_user_session_path
 
-  - if devise_mapping.registerable? && controller_name != 'registrations'
-    %li= link_to t('auth.register'), available_sign_up_path
+    - if controller_name != 'registrations'
+      %li= link_to t('auth.register'), available_sign_up_path
 
-  - if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations'
-    %li= link_to t('auth.forgot_password'), new_password_path(resource_name)
+    - if controller_name != 'passwords' && controller_name != 'registrations'
+      %li= link_to t('auth.forgot_password'), new_user_password_path
 
-  - if devise_mapping.confirmable? && controller_name != 'confirmations'
-    %li= link_to t('auth.didnt_get_confirmation'), new_confirmation_path(resource_name)
+  - if controller_name != 'confirmations'
+    %li= link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
+
+  - if user_signed_in? && controller_name != 'setup'
+    %li= link_to t('auth.logout'), destroy_user_session_path, data: { method: :delete }
diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml
index 6608a5dcb..dee99475a 100644
--- a/app/views/directories/index.html.haml
+++ b/app/views/directories/index.html.haml
@@ -14,58 +14,43 @@
   %h1= t('directories.explore_mastodon', title: site_title)
   %p= t('directories.explanation')
 
-.grid
-  .column-0
-    - if @accounts.empty?
-      = nothing_here
-    - else
-      .directory
-        %table.accounts-table
-          %tbody
-            - @accounts.each do |account|
-              %tr
-                %td= account_link_to account
-                %td.accounts-table__count.optional
-                  = number_to_human account.statuses_count, strip_insignificant_zeros: true
-                  %small= t('accounts.posts', count: account.statuses_count).downcase
-                %td.accounts-table__count.optional
-                  = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true)
-                  %small= t('accounts.followers', count: account.followers_count).downcase
-                %td.accounts-table__count
-                  - if account.last_status_at.present?
-                    %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
-                  - else
-                    \-
-                  %small= t('accounts.last_active')
-
-      = paginate @accounts
-
-  .column-1
-    - if user_signed_in?
-      .box-widget.notice-widget
-        - if current_account.discoverable?
-          - if current_account.followers_count < Account::MIN_FOLLOWERS_DISCOVERY
-            %p= t('directories.enabled_but_waiting', min_followers: Account::MIN_FOLLOWERS_DISCOVERY)
-          - else
-            %p= t('directories.enabled')
-        - else
-          %p= t('directories.how_to_enable')
-
-          = link_to settings_profile_path do
-            = t('settings.edit_profile')
-            = fa_icon 'chevron-right fw'
-
-    - if @tags.empty? && !user_signed_in?
-      .nothing-here
-    - else
-      - @tags.each do |tag|
-        .directory__tag{ class: tag.id == @tag&.id ? 'active' : nil }
-          = link_to explore_hashtag_path(tag) do
-            %h4
-              = fa_icon 'hashtag'
-              = tag.name
-              %small= t('directories.people', count: tag.accounts_count)
-
-            .avatar-stack
-              - tag.cached_sample_accounts.each do |account|
-                = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar'
+- if @accounts.empty?
+  = nothing_here
+- else
+  .directory__list
+    - @accounts.each do |account|
+      .directory__card
+        .directory__card__img
+          = image_tag account.header.url, alt: ''
+        .directory__card__bar
+          = link_to TagManager.instance.url_for(account), class: 'directory__card__bar__name' do
+            .avatar
+              = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
+
+            .display-name
+              %span{ id: "default_account_display_name", style: "display: none" }= account.username
+              %bdi
+                %strong.emojify.p-name= display_name(account, custom_emojify: true)
+              %span= acct(account)
+          .directory__card__bar__relationship.account__relationship
+            = minimal_account_action_button(account)
+
+        .directory__card__extra
+          .account__header__content.emojify= Formatter.instance.simplified_format(account, custom_emojify: true)
+
+        .directory__card__extra
+          .accounts-table__count
+            = number_to_human account.statuses_count, strip_insignificant_zeros: true
+            %small= t('accounts.posts', count: account.statuses_count).downcase
+          .accounts-table__count
+            = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true)
+            %small= t('accounts.followers', count: account.followers_count).downcase
+          .accounts-table__count
+            - if account.last_status_at.present?
+              %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
+            - else
+              = t('accounts.never_active')
+
+            %small= t('accounts.last_active')
+
+  = paginate @accounts
diff --git a/app/views/errors/400.html.haml b/app/views/errors/400.html.haml
new file mode 100644
index 000000000..11fbdd40c
--- /dev/null
+++ b/app/views/errors/400.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  = t('errors.400')
+
+- content_for :content do
+  = t('errors.400')
diff --git a/app/views/errors/406.html.haml b/app/views/errors/406.html.haml
new file mode 100644
index 000000000..0ef815df3
--- /dev/null
+++ b/app/views/errors/406.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  = t('errors.406')
+
+- content_for :content do
+  = t('errors.406')
diff --git a/app/views/errors/503.html.haml b/app/views/errors/503.html.haml
new file mode 100644
index 000000000..b0c895aa5
--- /dev/null
+++ b/app/views/errors/503.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  = t('errors.503')
+
+- content_for :content do
+  = t('errors.503')
diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml
index 57b5688bd..e992e5563 100644
--- a/app/views/notification_mailer/_status.html.haml
+++ b/app/views/notification_mailer/_status.html.haml
@@ -1,4 +1,5 @@
 - i ||= 0
+- highlighted ||= false
 
 %table.email-table{ cellspacing: 0, cellpadding: 0, dir: 'ltr' }
   %tbody
@@ -14,7 +15,7 @@
                       %table.column{ cellspacing: 0, cellpadding: 0 }
                         %tbody
                           %tr
-                            %td.column-cell.padded.status
+                            %td.column-cell.padded.status{ class: highlighted ? 'status--highlighted' : '' }
                               %table.status-header{ cellspacing: 0, cellpadding: 0 }
                                 %tbody
                                   %tr
@@ -32,5 +33,13 @@
                               %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
                                 = Formatter.instance.format(status)
 
+                                - if status.media_attachments.size > 0
+                                  %p
+                                    - status.media_attachments.each do |a|
+                                      - if status.local?
+                                        = link_to medium_url(a), medium_url(a)
+                                      - else
+                                        = link_to a.remote_url, a.remote_url
+
                               %p.status-footer
                                 = link_to l(status.created_at), web_url("statuses/#{status.id}")
diff --git a/app/views/settings/deletes/show.html.haml b/app/views/settings/deletes/show.html.haml
index b246f83a1..6e2ff31c5 100644
--- a/app/views/settings/deletes/show.html.haml
+++ b/app/views/settings/deletes/show.html.haml
@@ -2,15 +2,25 @@
   = t('settings.delete')
 
 = simple_form_for @confirmation, url: settings_delete_path, method: :delete do |f|
-  .warning
-    %strong
-      = fa_icon('warning')
-      = t('deletes.warning_title')
-    = t('deletes.warning_html')
+  %p.hint= t('deletes.warning.before')
 
-  %p.hint= t('deletes.description_html')
+  %ul.hint
+    - if current_user.confirmed? && current_user.approved?
+      %li.warning-hint= t('deletes.warning.irreversible')
+      %li.warning-hint= t('deletes.warning.username_unavailable')
+      %li.warning-hint= t('deletes.warning.data_removal')
+      %li.warning-hint= t('deletes.warning.caches')
+    - else
+      %li.positive-hint= t('deletes.warning.email_change_html', path: edit_user_registration_path)
+      %li.positive-hint= t('deletes.warning.email_reconfirmation_html', path: new_user_confirmation_path)
+      %li.positive-hint= t('deletes.warning.email_contact_html', email: Setting.site_contact_email)
+      %li.positive-hint= t('deletes.warning.username_available')
 
-  = f.input :password, placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' }, hint: t('deletes.confirm_password')
+  %p.hint= t('deletes.warning.more_details_html', terms_path: terms_path)
+
+  %hr.spacer/
+
+  = f.input :password, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, hint: t('deletes.confirm_password')
 
   .actions
     = f.button :button, t('deletes.proceed'), type: :submit, class: 'negative'
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 9f794ca6b..1f62e07d8 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -28,7 +28,7 @@
 
   - if Setting.profile_directory
     .fields-group
-      = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable_html', min_followers: Account::MIN_FOLLOWERS_DISCOVERY, path: explore_path), recommended: true
+      = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true
 
   %hr.spacer/
 
diff --git a/app/views/shared/_og.html.haml b/app/views/shared/_og.html.haml
index 67238fc8b..576f47a67 100644
--- a/app/views/shared/_og.html.haml
+++ b/app/views/shared/_og.html.haml
@@ -1,5 +1,5 @@
-- thumbnail = @instance_presenter.thumbnail
-- description = strip_tags(@instance_presenter.site_short_description.presence || @instance_presenter.site_description.presence || t('about.about_mastodon_html'))
+- thumbnail     = @instance_presenter.thumbnail
+- description ||= strip_tags(@instance_presenter.site_short_description.presence || @instance_presenter.site_description.presence || t('about.about_mastodon_html'))
 
 %meta{ name: 'description', content: description }/
 
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index 8686c2033..12f03ccdd 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -27,10 +27,14 @@
           = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
 
   - if !status.media_attachments.empty?
-    - if status.media_attachments.first.audio_or_video?
+    - if status.media_attachments.first.video?
       - video = status.media_attachments.first
       = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+    - elsif status.media_attachments.first.audio?
+      - audio = status.media_attachments.first
+      = react_component :audio, src: audio.file.url(:original), height: 130, alt: audio.description, preload: true, duration: audio.file.meta.dig(:original, :duration) do
+        = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
       = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 27f6fc227..fe1591bf9 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -31,10 +31,14 @@
           = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
 
   - if !status.media_attachments.empty?
-    - if status.media_attachments.first.audio_or_video?
+    - if status.media_attachments.first.video?
       - video = status.media_attachments.first
       = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+    - elsif status.media_attachments.first.audio?
+      - audio = status.media_attachments.first
+      = react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
+        = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
       = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index 72ea5e5d2..89dc2a75d 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -42,6 +42,14 @@
                               - unless @warning.text.blank?
                                 = Formatter.instance.linkify(@warning.text)
 
+                              - if !@statuses.nil? && !@statuses.empty?
+                                %p
+                                  %strong= t('user_mailer.warning.statuses')
+
+- if !@statuses.nil? && !@statuses.empty?
+  - @statuses.each_with_index do |status, i|
+    = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true
+
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
     %tr
@@ -50,7 +58,7 @@
           %table.content-section{ cellspacing: 0, cellpadding: 0 }
             %tbody
               %tr
-                %td.content-cell
+                %td.content-cell{ class: @statuses.empty? ? '' : 'content-start' }
                   %table.column{ cellspacing: 0, cellpadding: 0 }
                     %tbody
                       %tr
@@ -61,3 +69,20 @@
                                 %td.button-primary
                                   = link_to about_more_url do
                                     %span= t 'user_mailer.warning.review_server_policies'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              %p= t 'user_mailer.warning.get_in_touch', instance: @instance
diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb
index b4f2402cb..bb6610c79 100644
--- a/app/views/user_mailer/warning.text.erb
+++ b/app/views/user_mailer/warning.text.erb
@@ -7,3 +7,16 @@
 
 <% end %>
 <%= @warning.text %>
+<% if !@statuses.nil? && !@statuses.empty? %>
+<%= t('user_mailer.warning.statuses') %>
+
+<% @statuses.each do |status| %>
+
+<%= render 'notification_mailer/status', status: status %>
+---
+<% end %>
+<% else %>
+---
+<% end %>
+
+<%= t 'user_mailer.warning.get_in_touch', instance: @instance %>
diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb
index 19a660dd3..2a1eaa89b 100644
--- a/app/workers/removal_worker.rb
+++ b/app/workers/removal_worker.rb
@@ -3,8 +3,8 @@
 class RemovalWorker
   include Sidekiq::Worker
 
-  def perform(status_id)
-    RemoveStatusService.new.call(Status.find(status_id))
+  def perform(status_id, options = {})
+    RemoveStatusService.new.call(Status.with_discarded.find(status_id), **options.symbolize_keys)
   rescue ActiveRecord::RecordNotFound
     true
   end
diff --git a/app/workers/scheduler/trending_tags_scheduler.rb b/app/workers/scheduler/trending_tags_scheduler.rb
new file mode 100644
index 000000000..77f0d5747
--- /dev/null
+++ b/app/workers/scheduler/trending_tags_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Scheduler::TrendingTagsScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options unique: :until_executed, retry: 0
+
+  def perform
+    TrendingTags.update! if Setting.trends
+  end
+end
diff --git a/config/deploy.rb b/config/deploy.rb
index f0db50788..c4133e794 100644
--- a/config/deploy.rb
+++ b/config/deploy.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-lock '3.11.0'
+lock '3.11.1'
 
 set :repo_url, ENV.fetch('REPO', 'https://github.com/tootsuite/mastodon.git')
 set :branch, ENV.fetch('BRANCH', 'master')
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 3156547d8..d617a297a 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -83,7 +83,10 @@ Rails.application.configure do
   config.action_mailer.perform_caching = false
 
   # E-mails
-  config.action_mailer.default_options = { from: ENV.fetch('SMTP_FROM_ADDRESS', 'notifications@localhost') }
+  config.action_mailer.default_options = {
+    from: ENV.fetch('SMTP_FROM_ADDRESS', 'notifications@localhost'),
+    reply_to: ENV['SMTP_REPLY_TO']
+  }
 
   config.action_mailer.smtp_settings = {
     :port                 => ENV['SMTP_PORT'],
diff --git a/config/initializers/active_model_serializers.rb b/config/initializers/active_model_serializers.rb
index 329a5fb2c..0e69e1d96 100644
--- a/config/initializers/active_model_serializers.rb
+++ b/config/initializers/active_model_serializers.rb
@@ -3,22 +3,3 @@ ActiveModelSerializers.config.tap do |config|
 end
 
 ActiveSupport::Notifications.unsubscribe(ActiveModelSerializers::Logging::RENDER_EVENT)
-
-class ActiveModel::Serializer::Reflection
-  # We monkey-patch this method so that when we include associations in a serializer,
-  # the nested serializers can send information about used contexts upwards back to
-  # the root. We do this via instance_options because the nesting can be dynamic.
-  def build_association(parent_serializer, parent_serializer_options, include_slice = {})
-    serializer = options[:serializer]
-
-    parent_serializer_options.merge!(named_contexts: serializer._named_contexts, context_extensions: serializer._context_extensions) if serializer.respond_to?(:_named_contexts)
-
-    association_options = {
-      parent_serializer: parent_serializer,
-      parent_serializer_options: parent_serializer_options,
-      include_slice: include_slice,
-    }
-
-    ActiveModel::Serializer::Association.new(self, association_options)
-  end
-end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index be190f0f1..56f0fd2cf 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -58,6 +58,7 @@ en:
     media: Media
     moved_html: "%{name} has moved to %{new_profile_link}:"
     network_hidden: This information is not available
+    never_active: Never
     nothing_here: There is nothing here!
     people_followed_by: People whom %{name} follows
     people_who_follow: People who follow %{name}
@@ -512,6 +513,7 @@ en:
         delete: Delete
         nsfw_off: Mark as not sensitive
         nsfw_on: Mark as sensitive
+      deleted: Deleted
       failed_to_execute: Failed to execute
       media:
         title: Media
@@ -580,6 +582,10 @@ en:
     checkbox_agreement_without_rules_html: I agree to the <a href="%{terms_path}" target="_blank">terms of service</a>
     delete_account: Delete account
     delete_account_html: If you wish to delete your account, you can <a href="%{path}">proceed here</a>. You will be asked for confirmation.
+    description:
+      prefix_invited_by_user: "@%{name} invites you to join this server of Mastodon!"
+      prefix_sign_up: Sign up on Mastodon today!
+      suffix: With an account, you will be able to follow people, post updates and exchange messages with users from any Mastodon server and more!
     didnt_get_confirmation: Didn't receive confirmation instructions?
     forgot_password: Forgot your password?
     invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one.
@@ -633,23 +639,25 @@ en:
       x_months: "%{count}mo"
       x_seconds: "%{count}s"
   deletes:
-    bad_password_msg: Nice try, hackers! Incorrect password
+    bad_password_msg: The password you entered was incorrect
     confirm_password: Enter your current password to verify your identity
-    description_html: This will <strong>permanently, irreversibly</strong> remove content from your account and deactivate it. Your username will remain reserved to prevent future impersonations.
     proceed: Delete account
     success_msg: Your account was successfully deleted
-    warning_html: Only deletion of content from this particular server is guaranteed. Content that has been widely shared is likely to leave traces. Offline servers and servers that have unsubscribed from your updates will not update their databases.
-    warning_title: Disseminated content availability
+    warning:
+      before: 'Before proceeding, please read these notes carefully:'
+      caches: Content that has been cached by other servers may persist
+      data_removal: Your posts and other data will be permanently removed
+      email_change_html: You can <a href="%{path}">change your e-mail address</a> without deleting your account
+      email_contact_html: If it still doesn't arrive, you can e-mail <a href="mailto:%{email}">%{email}</a> for help
+      email_reconfirmation_html: If you are not receiving the confirmation e-mail, you can <a href="%{path}">request it again</a>
+      irreversible: You will not be able to restore or reactivate your account
+      more_details_html: For more details, see the <a href="%{terms_path}">privacy policy</a>.
+      username_available: Your username will become available again
+      username_unavailable: Your username will remain unavailable
   directories:
     directory: Profile directory
-    enabled: You are currently listed in the directory.
-    enabled_but_waiting: You have opted-in to be listed in the directory, but you do not have the minimum number of followers (%{min_followers}) to be listed yet.
     explanation: Discover users based on their interests
     explore_mastodon: Explore %{title}
-    how_to_enable: You are not currently opted-in to the directory. You can opt-in below. Use hashtags in your bio text to be listed under specific hashtags!
-    people:
-      one: "%{count} person"
-      other: "%{count} people"
   domain_blocks:
     blocked_domains: List of limited and blocked domains
     description: This is the list of servers that %{instance} limits or reject federation with.
@@ -670,8 +678,10 @@ en:
   domain_validator:
     invalid_domain: is not a valid domain name
   errors:
+    '400': The request you submitted was invalid or malformed.
     '403': You don't have permission to view this page.
     '404': The page you are looking for isn't here.
+    '406': This page is not available in the requested format.
     '410': The page you were looking for doesn't exist here anymore.
     '422':
       content: Security verification failed. Are you blocking cookies?
@@ -680,6 +690,7 @@ en:
     '500':
       content: We're sorry, but something went wrong on our end.
       title: This page is not correct
+    '503': The page could not be served due to a temporary server failure.
     noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the <a href="%{apps_path}">native apps</a> for Mastodon for your platform.
   existing_username_validator:
     not_found: could not find a local user with that username
@@ -1129,7 +1140,9 @@ en:
         disable: While your account is frozen, your account data remains intact, but you cannot perform any actions until it is unlocked.
         silence: While your account is limited, only people who are already following you will see your toots on this server, and you may be excluded from various public listings. However, others may still manually follow you.
         suspend: Your account has been suspended, and all of your toots and your uploaded media files have been irreversibly removed from this server, and servers where you had followers.
+      get_in_touch: You can reply to this e-mail to get in touch with the staff of %{instance}.
       review_server_policies: Review server policies
+      statuses: 'Specifically, for:'
       subject:
         disable: Your account %{acct} has been frozen
         none: Warning for %{acct}
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index df898c621..6c315b0ed 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -5,6 +5,7 @@ en:
       account_warning_preset:
         text: You can use toot syntax, such as URLs, hashtags and mentions
       admin_account_action:
+        include_statuses: The user will see which toots have caused the moderation action or warning
         send_email_notification: The user will receive an explanation of what happened with their account
         text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time
         type_html: Choose what to do with <strong>%{acct}</strong>
@@ -15,7 +16,7 @@ en:
         bot: This account mainly performs automated actions and might not be monitored
         context: One or multiple contexts where the filter should apply
         digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
-        discoverable_html: The <a href="%{path}" target="_blank">directory</a> lets people find accounts based on interests and activity. Requires at least %{min_followers} followers
+        discoverable: The profile directory is another way by which your account can reach a wider audience
         email: You will be sent a confirmation e-mail
         fields: You can have up to 4 items displayed as a table on your profile
         header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
@@ -65,6 +66,7 @@ en:
       account_warning_preset:
         text: Preset text
       admin_account_action:
+        include_statuses: Include reported toots in the e-mail
         send_email_notification: Notify the user per e-mail
         text: Custom warning
         type: Action
@@ -156,6 +158,7 @@ en:
         trending_tag: Send e-mail when an unreviewed hashtag is trending
       tag:
         listable: Allow this hashtag to appear in searches and on the profile directory
+        name: Hashtag
         trendable: Allow this hashtag to appear under trends
         usable: Allow toots to use this hashtag
     'no': 'No'
diff --git a/config/puma.rb b/config/puma.rb
index 6a96867d5..224be7903 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -1,3 +1,5 @@
+persistent_timeout ENV.fetch('PERSISTENT_TIMEOUT') { 20 }.to_i
+
 threads_count = ENV.fetch('MAX_THREADS') { 5 }.to_i
 threads threads_count, threads_count
 
diff --git a/config/routes.rb b/config/routes.rb
index 789b5f502..a7e65b034 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -6,6 +6,8 @@ require 'sidekiq-scheduler/web'
 Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base]
 
 Rails.application.routes.draw do
+  root 'home#index'
+
   mount LetterOpenerWeb::Engine, at: 'letter_opener' if Rails.env.development?
 
   authenticate :user, lambda { |u| u.admin? } do
@@ -336,6 +338,7 @@ Rails.application.routes.draw do
       end
 
       resource :domain_blocks, only: [:show, :create, :destroy]
+      resource :directory, only: [:show]
 
       resources :follow_requests, only: [:index] do
         member do
@@ -440,10 +443,6 @@ Rails.application.routes.draw do
   get '/about/blocks', to: 'about#blocks'
   get '/terms',        to: 'about#terms'
 
-  root 'home#index'
-
-  match '*unmatched_route',
-        via: :all,
-        to: 'application#raise_not_found',
-        format: false
+  match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false
+  match '*unmatched_route', via: :all, to: 'application#raise_not_found', format: false
 end
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 6ebe450b0..5de25de23 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -9,6 +9,9 @@
   scheduled_statuses_scheduler:
     every: '5m'
     class: Scheduler::ScheduledStatusesScheduler
+  trending_tags_scheduler:
+    every: '5m'
+    class: Scheduler::TrendingTagsScheduler
   media_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
     class: Scheduler::MediaCleanupScheduler
diff --git a/db/migrate/20190819134503_add_deleted_at_to_statuses.rb b/db/migrate/20190819134503_add_deleted_at_to_statuses.rb
new file mode 100644
index 000000000..5af109097
--- /dev/null
+++ b/db/migrate/20190819134503_add_deleted_at_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddDeletedAtToStatuses < ActiveRecord::Migration[5.2]
+  def change
+    add_column :statuses, :deleted_at, :datetime
+  end
+end
diff --git a/db/migrate/20190820003045_update_statuses_index.rb b/db/migrate/20190820003045_update_statuses_index.rb
new file mode 100644
index 000000000..5c2ea1f6a
--- /dev/null
+++ b/db/migrate/20190820003045_update_statuses_index.rb
@@ -0,0 +1,13 @@
+class UpdateStatusesIndex < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], where: 'deleted_at IS NULL', order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20190820 }
+    remove_index :statuses, name: :index_statuses_20180106
+  end
+
+  def down
+    safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20180106 }
+    remove_index :statuses, name: :index_statuses_20190820
+  end
+end
diff --git a/db/migrate/20190823221802_add_local_index_to_statuses.rb b/db/migrate/20190823221802_add_local_index_to_statuses.rb
new file mode 100644
index 000000000..deca25c35
--- /dev/null
+++ b/db/migrate/20190823221802_add_local_index_to_statuses.rb
@@ -0,0 +1,11 @@
+class AddLocalIndexToStatuses < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    add_index :statuses, [:id, :account_id], name: :index_statuses_local_20190824, algorithm: :concurrently, order: { id: :desc }, where: '(local OR (uri IS NULL)) AND deleted_at IS NULL AND visibility = 0 AND reblog_of_id IS NULL AND ((NOT reply) OR (in_reply_to_account_id = account_id))'
+  end
+
+  def down
+    remove_index :statuses, name: :index_statuses_local_20190824
+  end
+end
diff --git a/db/migrate/20190901035623_add_max_score_to_tags.rb b/db/migrate/20190901035623_add_max_score_to_tags.rb
new file mode 100644
index 000000000..f936e9871
--- /dev/null
+++ b/db/migrate/20190901035623_add_max_score_to_tags.rb
@@ -0,0 +1,6 @@
+class AddMaxScoreToTags < ActiveRecord::Migration[5.2]
+  def change
+    add_column :tags, :max_score, :float
+    add_column :tags, :max_score_at, :datetime
+  end
+end
diff --git a/db/post_migrate/20190901040524_remove_score_from_tags.rb b/db/post_migrate/20190901040524_remove_score_from_tags.rb
new file mode 100644
index 000000000..a1112700b
--- /dev/null
+++ b/db/post_migrate/20190901040524_remove_score_from_tags.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class RemoveScoreFromTags < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured do
+      remove_column :tags, :score, :int
+      remove_column :tags, :last_trend_at, :datetime
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7e62fe1f5..f15f33bea 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: 2019_08_15_225426) do
+ActiveRecord::Schema.define(version: 2019_09_01_040524) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -657,7 +657,9 @@ ActiveRecord::Schema.define(version: 2019_08_15_225426) do
     t.boolean "local_only"
     t.bigint "poll_id"
     t.string "content_type"
-    t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc }
+    t.datetime "deleted_at"
+    t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
+    t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
     t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
     t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id"
     t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"
@@ -675,14 +677,14 @@ ActiveRecord::Schema.define(version: 2019_08_15_225426) do
     t.string "name", default: "", null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
-    t.integer "score"
     t.boolean "usable"
     t.boolean "trendable"
     t.boolean "listable"
     t.datetime "reviewed_at"
     t.datetime "requested_review_at"
     t.datetime "last_status_at"
-    t.datetime "last_trend_at"
+    t.float "max_score"
+    t.datetime "max_score_at"
     t.index "lower((name)::text)", name: "index_tags_on_name_lower", unique: true
   end
 
diff --git a/dist/nginx.conf b/dist/nginx.conf
index 7c429bad4..b6591e897 100644
--- a/dist/nginx.conf
+++ b/dist/nginx.conf
@@ -19,7 +19,7 @@ server {
   listen [::]:443 ssl http2;
   server_name example.com;
 
-  ssl_protocols TLSv1.2;
+  ssl_protocols TLSv1.2 TLSv1.3;
   ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
   ssl_prefer_server_ciphers on;
   ssl_session_cache shared:SSL:10m;
diff --git a/package.json b/package.json
index 6f6730b9a..895245eed 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
   "name": "mastodon",
   "license": "AGPL-3.0-or-later",
   "engines": {
-    "node": ">=8.12 <12"
+    "node": ">=8.12 <13"
   },
   "scripts": {
     "postversion": "git push --tags",
@@ -61,14 +61,14 @@
   "private": true,
   "dependencies": {
     "@babel/core": "^7.4.5",
-    "@babel/plugin-proposal-class-properties": "^7.5.0",
+    "@babel/plugin-proposal-class-properties": "^7.5.5",
     "@babel/plugin-proposal-decorators": "^7.4.4",
     "@babel/plugin-proposal-object-rest-spread": "^7.4.4",
     "@babel/plugin-syntax-dynamic-import": "^7.2.0",
     "@babel/plugin-transform-react-inline-elements": "^7.2.0",
     "@babel/plugin-transform-react-jsx-self": "^7.2.0",
     "@babel/plugin-transform-react-jsx-source": "^7.5.0",
-    "@babel/plugin-transform-runtime": "^7.4.4",
+    "@babel/plugin-transform-runtime": "^7.5.5",
     "@babel/preset-env": "^7.5.5",
     "@babel/preset-react": "^7.0.0",
     "@babel/runtime": "^7.5.4",
@@ -137,7 +137,7 @@
     "react-motion": "^0.5.2",
     "react-notification": "^6.8.4",
     "react-overlays": "^0.8.3",
-    "react-redux": "^7.1.0",
+    "react-redux": "^7.1.1",
     "react-redux-loading-bar": "^4.0.8",
     "react-router-dom": "^4.1.1",
     "react-router-scroll-4": "^1.0.0-beta.1",
@@ -163,15 +163,16 @@
     "throng": "^4.0.0",
     "tiny-queue": "^0.2.1",
     "uuid": "^3.1.0",
+    "wavesurfer.js": "^3.0.0",
     "webpack": "^4.35.3",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.3.2",
-    "webpack-cli": "^3.3.6",
+    "webpack-cli": "^3.3.7",
     "webpack-merge": "^4.2.1",
     "websocket.js": "^0.1.12"
   },
   "devDependencies": {
-    "babel-eslint": "^10.0.2",
+    "babel-eslint": "^10.0.3",
     "babel-jest": "^24.8.0",
     "enzyme": "^3.10.0",
     "enzyme-adapter-react-16": "^1.14.0",
diff --git a/spec/controllers/admin/reported_statuses_controller_spec.rb b/spec/controllers/admin/reported_statuses_controller_spec.rb
index c358506d6..bd146b795 100644
--- a/spec/controllers/admin/reported_statuses_controller_spec.rb
+++ b/spec/controllers/admin/reported_statuses_controller_spec.rb
@@ -47,7 +47,7 @@ describe Admin::ReportedStatusesController do
       it 'removes a status' do
         allow(RemovalWorker).to receive(:perform_async)
         subject.call
-        expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first)
+        expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false)
       end
     end
 
diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb
index 1a08c10b7..6b06343ef 100644
--- a/spec/controllers/admin/statuses_controller_spec.rb
+++ b/spec/controllers/admin/statuses_controller_spec.rb
@@ -65,7 +65,7 @@ describe Admin::StatusesController do
       it 'removes a status' do
         allow(RemovalWorker).to receive(:perform_async)
         subject.call
-        expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first)
+        expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false)
       end
     end
 
diff --git a/spec/controllers/remote_follow_controller_spec.rb b/spec/controllers/remote_follow_controller_spec.rb
index 5088c2e65..d79dd2949 100644
--- a/spec/controllers/remote_follow_controller_spec.rb
+++ b/spec/controllers/remote_follow_controller_spec.rb
@@ -66,9 +66,7 @@ describe RemoteFollowController do
         end
 
         it 'redirects to the remote location' do
-          address = "http://example.com/follow_me?acct=test_user%40#{Rails.configuration.x.local_domain}"
-
-          expect(response).to redirect_to(address)
+          expect(response).to redirect_to("http://example.com/follow_me?acct=https%3A%2F%2F#{Rails.configuration.x.local_domain}%2Fusers%2Ftest_user")
         end
       end
     end
diff --git a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb
index 478f24585..2222a7559 100644
--- a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb
+++ b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb
@@ -50,7 +50,8 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
 
       describe 'when form_two_factor_confirmation parameter is not provided' do
         it 'raises ActionController::ParameterMissing' do
-          expect { post :create, params: {} }.to raise_error(ActionController::ParameterMissing)
+          post :create, params: {}
+          expect(response).to have_http_status(400)
         end
       end
 
diff --git a/spec/controllers/settings/two_factor_authentications_controller_spec.rb b/spec/controllers/settings/two_factor_authentications_controller_spec.rb
index 9f27222ad..f7c628756 100644
--- a/spec/controllers/settings/two_factor_authentications_controller_spec.rb
+++ b/spec/controllers/settings/two_factor_authentications_controller_spec.rb
@@ -112,7 +112,8 @@ describe Settings::TwoFactorAuthenticationsController do
       end
 
       it 'raises ActionController::ParameterMissing if code is missing' do
-        expect { post :destroy }.to raise_error(ActionController::ParameterMissing)
+        post :destroy
+        expect(response).to have_http_status(400)
       end
     end
 
diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb
index fbfc585cf..42da29860 100644
--- a/spec/lib/activitypub/activity/update_spec.rb
+++ b/spec/lib/activitypub/activity/update_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe ActivityPub::Activity::Update do
   end
 
   let(:actor_json) do
-    ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, key_transform: :camel_lower).as_json
+    ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter).as_json
   end
 
   let(:json) do
diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb
index 53c836494..ead3b3baa 100644
--- a/spec/mailers/previews/user_mailer_preview.rb
+++ b/spec/mailers/previews/user_mailer_preview.rb
@@ -42,6 +42,6 @@ class UserMailerPreview < ActionMailer::Preview
 
   # Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning
   def warning
-    UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence))
+    UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence), [Status.first.id])
   end
 end
diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb
index a3db60cfc..87fc28500 100644
--- a/spec/models/admin/account_action_spec.rb
+++ b/spec/models/admin/account_action_spec.rb
@@ -58,8 +58,8 @@ RSpec.describe Admin::AccountAction, type: :model do
       end.to change { Admin::ActionLog.count }.by 1
     end
 
-    it 'calls queue_email!' do
-      expect(account_action).to receive(:queue_email!)
+    it 'calls process_email!' do
+      expect(account_action).to receive(:process_email!)
       subject
     end
 
diff --git a/spec/models/form/status_batch_spec.rb b/spec/models/form/status_batch_spec.rb
index 00c790a11..f9c58c90f 100644
--- a/spec/models/form/status_batch_spec.rb
+++ b/spec/models/form/status_batch_spec.rb
@@ -41,12 +41,12 @@ describe Form::StatusBatch do
 
     it 'call RemovalWorker' do
       form.save
-      expect(RemovalWorker).to have_received(:perform_async).with(status.id)
+      expect(RemovalWorker).to have_received(:perform_async).with(status.id, redraft: false)
     end
 
     it 'do not call RemovalWorker' do
       form.save
-      expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id)
+      expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, redraft: false)
     end
   end
 end
diff --git a/spec/models/remote_follow_spec.rb b/spec/models/remote_follow_spec.rb
index ed2667b28..5b4c19b5b 100644
--- a/spec/models/remote_follow_spec.rb
+++ b/spec/models/remote_follow_spec.rb
@@ -61,7 +61,7 @@ RSpec.describe RemoteFollow do
     subject { remote_follow.subscribe_address_for(account) }
 
     it 'returns subscribe address' do
-      is_expected.to eq 'https://quitter.no/main/ostatussub?profile=alice%40cb6e6126.ngrok.io'
+      is_expected.to eq 'https://quitter.no/main/ostatussub?profile=https%3A%2F%2Fcb6e6126.ngrok.io%2Fusers%2Falice'
     end
   end
 end
diff --git a/spec/models/trending_tags_spec.rb b/spec/models/trending_tags_spec.rb
new file mode 100644
index 000000000..b6122c994
--- /dev/null
+++ b/spec/models/trending_tags_spec.rb
@@ -0,0 +1,68 @@
+require 'rails_helper'
+
+RSpec.describe TrendingTags do
+  describe '.record_use!' do
+    pending
+  end
+
+  describe '.update!' do
+    let!(:at_time) { Time.now.utc }
+    let!(:tag1) { Fabricate(:tag, name: 'Catstodon') }
+    let!(:tag2) { Fabricate(:tag, name: 'DogsOfMastodon') }
+    let!(:tag3) { Fabricate(:tag, name: 'OCs') }
+
+    before do
+      allow(Redis.current).to receive(:pfcount) do |key|
+        case key
+        when "activity:tags:#{tag1.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts"
+          2
+        when "activity:tags:#{tag1.id}:#{at_time.beginning_of_day.to_i}:accounts"
+          16
+        when "activity:tags:#{tag2.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts"
+          0
+        when "activity:tags:#{tag2.id}:#{at_time.beginning_of_day.to_i}:accounts"
+          4
+        when "activity:tags:#{tag3.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts"
+          13
+        end
+      end
+
+      Redis.current.zadd('trending_tags', 0.9, tag3.id)
+      Redis.current.sadd("trending_tags:used:#{at_time.beginning_of_day.to_i}", [tag1.id, tag2.id])
+
+      tag3.update(max_score: 0.9, max_score_at: (at_time - 1.day).beginning_of_day + 12.hours)
+
+      described_class.update!(at_time)
+    end
+
+    it 'calculates and re-calculates scores' do
+      expect(described_class.get(10, filtered: false)).to eq [tag1, tag3]
+    end
+
+    it 'omits hashtags below threshold' do
+      expect(described_class.get(10, filtered: false)).to_not include(tag2)
+    end
+
+    it 'decays scores' do
+      expect(Redis.current.zscore('trending_tags', tag3.id)).to be < 0.9
+    end
+  end
+
+  describe '.trending?' do
+    let(:tag) { Fabricate(:tag) }
+
+    before do
+      10.times { |i| Redis.current.zadd('trending_tags', i + 1, Fabricate(:tag).id) }
+    end
+
+    it 'returns true if the hashtag is within limit' do
+      Redis.current.zadd('trending_tags', 11, tag.id)
+      expect(described_class.trending?(tag)).to be true
+    end
+
+    it 'returns false if the hashtag is outside the limit' do
+      Redis.current.zadd('trending_tags', 0, tag.id)
+      expect(described_class.trending?(tag)).to be false
+    end
+  end
+end
diff --git a/yarn.lock b/yarn.lock
index ecc4e317c..4d601731d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,14 +2,7 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
-  integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==
-  dependencies:
-    "@babel/highlight" "^7.0.0"
-
-"@babel/code-frame@^7.5.5":
+"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5":
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d"
   integrity sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==
@@ -121,16 +114,16 @@
     "@babel/traverse" "^7.4.4"
     "@babel/types" "^7.4.4"
 
-"@babel/helper-create-class-features-plugin@^7.4.4", "@babel/helper-create-class-features-plugin@^7.5.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.0.tgz#02edb97f512d44ba23b3227f1bf2ed43454edac5"
-  integrity sha512-EAoMc3hE5vE5LNhMqDOwB1usHvmRjCDAnH8CD4PVkX9/Yr3W/tcz8xE8QvdZxfsFBDICwZnF2UTHIqslRpvxmA==
+"@babel/helper-create-class-features-plugin@^7.4.4", "@babel/helper-create-class-features-plugin@^7.5.5":
+  version "7.5.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.5.tgz#401f302c8ddbc0edd36f7c6b2887d8fa1122e5a4"
+  integrity sha512-ZsxkyYiRA7Bg+ZTRpPvB6AbOFKTFFK4LrvTet8lInm0V468MWCaSYJE+I7v2z2r8KNLtYiV+K5kTCnR7dvyZjg==
   dependencies:
     "@babel/helper-function-name" "^7.1.0"
-    "@babel/helper-member-expression-to-functions" "^7.0.0"
+    "@babel/helper-member-expression-to-functions" "^7.5.5"
     "@babel/helper-optimise-call-expression" "^7.0.0"
     "@babel/helper-plugin-utils" "^7.0.0"
-    "@babel/helper-replace-supers" "^7.4.4"
+    "@babel/helper-replace-supers" "^7.5.5"
     "@babel/helper-split-export-declaration" "^7.4.4"
 
 "@babel/helper-define-map@^7.5.5":
@@ -173,13 +166,6 @@
   dependencies:
     "@babel/types" "^7.4.4"
 
-"@babel/helper-member-expression-to-functions@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.0.0.tgz#8cd14b0a0df7ff00f009e7d7a436945f47c7a16f"
-  integrity sha512-avo+lm/QmZlv27Zsi0xEor2fKcqWG56D5ae9dzklpIaY7cQMK5N8VSpaNVPPagiqmy7LrEjK1IWdGMOqPu5csg==
-  dependencies:
-    "@babel/types" "^7.0.0"
-
 "@babel/helper-member-expression-to-functions@^7.5.5":
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.5.5.tgz#1fb5b8ec4453a93c439ee9fe3aeea4a84b76b590"
@@ -236,16 +222,6 @@
     "@babel/traverse" "^7.1.0"
     "@babel/types" "^7.0.0"
 
-"@babel/helper-replace-supers@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.4.4.tgz#aee41783ebe4f2d3ab3ae775e1cc6f1a90cefa27"
-  integrity sha512-04xGEnd+s01nY1l15EuMS1rfKktNF+1CkKmHoErDppjAAZL+IUBZpzT748x262HF7fibaQPhbvWUl5HeSt1EXg==
-  dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.0.0"
-    "@babel/helper-optimise-call-expression" "^7.0.0"
-    "@babel/traverse" "^7.4.4"
-    "@babel/types" "^7.4.4"
-
 "@babel/helper-replace-supers@^7.5.5":
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.5.5.tgz#f84ce43df031222d2bad068d2626cb5799c34bc2"
@@ -308,12 +284,7 @@
     esutils "^2.0.2"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.2.2", "@babel/parser@^7.3.4", "@babel/parser@^7.4.4", "@babel/parser@^7.4.5":
-  version "7.4.5"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.4.5.tgz#04af8d5d5a2b044a2a1bffacc1e5e6673544e872"
-  integrity sha512-9mUqkL1FF5T7f0WDFfAoDdiMVPWsdD1gZYzSnaXsxUCUqzuch/8of9G3VUSNiZmMBoRxT3neyVsqeiL/ZPcjew==
-
-"@babel/parser@^7.5.5":
+"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.2.2", "@babel/parser@^7.3.4", "@babel/parser@^7.4.4", "@babel/parser@^7.4.5", "@babel/parser@^7.5.5":
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.5.tgz#02f077ac8817d3df4a832ef59de67565e71cca4b"
   integrity sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==
@@ -327,12 +298,12 @@
     "@babel/helper-remap-async-to-generator" "^7.1.0"
     "@babel/plugin-syntax-async-generators" "^7.2.0"
 
-"@babel/plugin-proposal-class-properties@^7.5.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.0.tgz#5bc6a0537d286fcb4fd4e89975adbca334987007"
-  integrity sha512-9L/JfPCT+kShiiTTzcnBJ8cOwdKVmlC1RcCf9F0F9tERVrM4iWtWnXtjWCRqNm2la2BxO1MPArWNsU9zsSJWSQ==
+"@babel/plugin-proposal-class-properties@^7.5.5":
+  version "7.5.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz#a974cfae1e37c3110e71f3c6a2e48b8e71958cd4"
+  integrity sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.5.0"
+    "@babel/helper-create-class-features-plugin" "^7.5.5"
     "@babel/helper-plugin-utils" "^7.0.0"
 
 "@babel/plugin-proposal-decorators@^7.4.4":
@@ -674,10 +645,10 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.0.0"
 
-"@babel/plugin-transform-runtime@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.4.4.tgz#a50f5d16e9c3a4ac18a1a9f9803c107c380bce08"
-  integrity sha512-aMVojEjPszvau3NRg+TIH14ynZLvPewH4xhlCW1w6A3rkxTS1m4uwzRclYR9oS+rl/dr+kT+pzbfHuAWP/lc7Q==
+"@babel/plugin-transform-runtime@^7.5.5":
+  version "7.5.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.5.5.tgz#a6331afbfc59189d2135b2e09474457a8e3d28bc"
+  integrity sha512-6Xmeidsun5rkwnGfMOp6/z9nSzWpHFNVr2Jx7kwoq4mVatQfQx5S56drBgEHF+XQbKOdIaOiMIINvp/kAwMN+w==
   dependencies:
     "@babel/helper-module-imports" "^7.0.0"
     "@babel/helper-plugin-utils" "^7.0.0"
@@ -811,10 +782,10 @@
   dependencies:
     regenerator-runtime "^0.12.0"
 
-"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.4":
-  version "7.5.4"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.4.tgz#cb7d1ad7c6d65676e66b47186577930465b5271b"
-  integrity sha512-Na84uwyImZZc3FKf4aUF1tysApzwf3p2yuFBIyBfbzT5glzKTdvYI4KVW4kcgjrzoGUjC7w3YyCHcJKaRxsr2Q==
+"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5":
+  version "7.5.5"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132"
+  integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==
   dependencies:
     regenerator-runtime "^0.13.2"
 
@@ -836,22 +807,7 @@
     "@babel/parser" "^7.4.4"
     "@babel/types" "^7.4.4"
 
-"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.1.5", "@babel/traverse@^7.3.4", "@babel/traverse@^7.4.4", "@babel/traverse@^7.4.5":
-  version "7.4.5"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.4.5.tgz#4e92d1728fd2f1897dafdd321efbff92156c3216"
-  integrity sha512-Vc+qjynwkjRmIFGxy0KYoPj4FdVDxLej89kMHFsWScq999uX+pwcX4v9mWRjW0KcAYTPAuVQl2LKP1wEVLsp+A==
-  dependencies:
-    "@babel/code-frame" "^7.0.0"
-    "@babel/generator" "^7.4.4"
-    "@babel/helper-function-name" "^7.1.0"
-    "@babel/helper-split-export-declaration" "^7.4.4"
-    "@babel/parser" "^7.4.5"
-    "@babel/types" "^7.4.4"
-    debug "^4.1.0"
-    globals "^11.1.0"
-    lodash "^4.17.11"
-
-"@babel/traverse@^7.5.5":
+"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.1.5", "@babel/traverse@^7.3.4", "@babel/traverse@^7.4.4", "@babel/traverse@^7.4.5", "@babel/traverse@^7.5.5":
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.5.5.tgz#f664f8f368ed32988cd648da9f72d5ca70f165bb"
   integrity sha512-MqB0782whsfffYfSjH4TM+LMjrJnhCNEDMDIjeTpl+ASaUvxcjoiVCo/sM1GhS1pHOXYfWVCYneLjMckuUxDaQ==
@@ -1754,17 +1710,17 @@ axobject-query@^2.0.2:
   dependencies:
     ast-types-flow "0.0.7"
 
-babel-eslint@^10.0.2:
-  version "10.0.2"
-  resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.2.tgz#182d5ac204579ff0881684b040560fdcc1558456"
-  integrity sha512-UdsurWPtgiPgpJ06ryUnuaSXC2s0WoSZnQmEpbAH65XZSdwowgN5MvyP7e88nW07FYXv72erVtpBkxyDVKhH1Q==
+babel-eslint@^10.0.3:
+  version "10.0.3"
+  resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.3.tgz#81a2c669be0f205e19462fed2482d33e4687a88a"
+  integrity sha512-z3U7eMY6r/3f3/JB9mTsLjyxrv0Yb1zb8PCWCLpguxfCzBIZUwy23R1t/XKewP+8mEN2Ck8Dtr4q20z6ce6SoA==
   dependencies:
     "@babel/code-frame" "^7.0.0"
     "@babel/parser" "^7.0.0"
     "@babel/traverse" "^7.0.0"
     "@babel/types" "^7.0.0"
-    eslint-scope "3.7.1"
     eslint-visitor-keys "^1.0.0"
+    resolve "^1.12.0"
 
 babel-jest@^24.8.0:
   version "24.8.0"
@@ -3833,14 +3789,6 @@ eslint-plugin-react@~7.14.3:
     prop-types "^15.7.2"
     resolve "^1.10.1"
 
-eslint-scope@3.7.1:
-  version "3.7.1"
-  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8"
-  integrity sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=
-  dependencies:
-    esrecurse "^4.1.0"
-    estraverse "^4.1.1"
-
 eslint-scope@^4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848"
@@ -3858,14 +3806,16 @@ eslint-scope@^5.0.0:
     estraverse "^4.1.1"
 
 eslint-utils@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512"
-  integrity sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q==
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab"
+  integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q==
+  dependencies:
+    eslint-visitor-keys "^1.0.0"
 
 eslint-visitor-keys@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
-  integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
+  integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
 
 eslint@^2.7.0:
   version "2.13.1"
@@ -6764,9 +6714,9 @@ mississippi@^3.0.0:
     through2 "^2.0.0"
 
 mixin-deep@^1.2.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
-  integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
+  integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
   dependencies:
     for-in "^1.0.2"
     is-extendable "^1.0.1"
@@ -8484,10 +8434,10 @@ react-intl@^2.9.0:
     intl-relativeformat "^2.1.0"
     invariant "^2.1.1"
 
-react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6:
-  version "16.8.6"
-  resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16"
-  integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==
+react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0:
+  version "16.9.0"
+  resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb"
+  integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==
 
 react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
   version "3.0.4"
@@ -8539,17 +8489,17 @@ react-redux-loading-bar@^4.0.8:
     prop-types "^15.6.2"
     react-lifecycles-compat "^3.0.2"
 
-react-redux@^7.1.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.0.tgz#72af7cf490a74acdc516ea9c1dd80e25af9ea0b2"
-  integrity sha512-hyu/PoFK3vZgdLTg9ozbt7WF3GgX5+Yn3pZm5/96/o4UueXA+zj08aiSC9Mfj2WtD1bvpIb3C5yvskzZySzzaw==
+react-redux@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.1.tgz#ce6eee1b734a7a76e0788b3309bf78ff6b34fa0a"
+  integrity sha512-QsW0vcmVVdNQzEkrgzh2W3Ksvr8cqpAv5FhEk7tNEft+5pp7rXxAudTz3VOPawRkLIepItpkEIyLcN/VVXzjTg==
   dependencies:
-    "@babel/runtime" "^7.4.5"
+    "@babel/runtime" "^7.5.5"
     hoist-non-react-statics "^3.3.0"
     invariant "^2.2.4"
     loose-envify "^1.4.0"
     prop-types "^15.7.2"
-    react-is "^16.8.6"
+    react-is "^16.9.0"
 
 react-router-dom@^4.1.1:
   version "4.3.1"
@@ -9042,10 +8992,10 @@ resolve@1.1.7:
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
   integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=
 
-resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.3.2, resolve@^1.5.0, resolve@^1.8.1:
-  version "1.11.1"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.1.tgz#ea10d8110376982fef578df8fc30b9ac30a07a3e"
-  integrity sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==
+resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.12.0, resolve@^1.3.2, resolve@^1.5.0, resolve@^1.8.1:
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.12.0.tgz#3fc644a35c84a48554609ff26ec52b66fa577df6"
+  integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==
   dependencies:
     path-parse "^1.0.6"
 
@@ -10474,6 +10424,11 @@ watchpack@^1.5.0:
     graceful-fs "^4.1.2"
     neo-async "^2.5.0"
 
+wavesurfer.js@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/wavesurfer.js/-/wavesurfer.js-3.0.0.tgz#35f36d76d59c749dca453cf4e10ee0ec49f454f8"
+  integrity sha512-DANu206c6gb9pSUbYFevsSiXMy8+Ri+CNtqm0UsouUdsn9fVQRtYs8uxzBtXK+rUPlIc6FlO54DU8uWeW3lDzw==
+
 wbuf@^1.1.0, wbuf@^1.7.3:
   version "1.7.3"
   resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
@@ -10518,10 +10473,10 @@ webpack-bundle-analyzer@^3.3.2:
     opener "^1.5.1"
     ws "^6.0.0"
 
-webpack-cli@^3.3.6:
-  version "3.3.6"
-  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.6.tgz#2c8c399a2642133f8d736a359007a052e060032c"
-  integrity sha512-0vEa83M7kJtxK/jUhlpZ27WHIOndz5mghWL2O53kiDoA9DIxSKnfqB92LoqEn77cT4f3H2cZm1BMEat/6AZz3A==
+webpack-cli@^3.3.7:
+  version "3.3.7"
+  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.7.tgz#77c8580dd8e92f69d635e0238eaf9d9c15759a91"
+  integrity sha512-OhTUCttAsr+IZSMVwGROGRHvT+QAs8H6/mHIl4SvhAwYywjiylYjpwybGx7WQ9Hkb45FhjtsymkwiRRbGJ1SZQ==
   dependencies:
     chalk "2.4.2"
     cross-spawn "6.0.5"