diff --git a/CHANGELOG.md b/CHANGELOG.md
index 539fec5317ef6befd4ae53186f145fbdbc7e64e9..a17fbf8f06385677eb05801b422998bb6613b9ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,65 @@ Changelog
 
 All notable changes to this project will be documented in this file.
 
+## [2.9.3] - 2019-08-10
+### Added
+
+- Add GIF and WebP support for custom emojis ([Gargron](https://github.com/tootsuite/mastodon/pull/11519))
+- Add logout link to dropdown menu in web UI ([koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/11353))
+- Add indication that text search is unavailable in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11112), [ThibG](https://github.com/tootsuite/mastodon/pull/11202))
+- Add `suffix` to `Mastodon::Version` to help forks ([clarfon](https://github.com/tootsuite/mastodon/pull/11407))
+- Add on-hover animation to animated custom emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11348), [ThibG](https://github.com/tootsuite/mastodon/pull/11404), [ThibG](https://github.com/tootsuite/mastodon/pull/11522))
+- Add custom emoji support in profile metadata labels ([ThibG](https://github.com/tootsuite/mastodon/pull/11350))
+
+### Changed
+
+- Change default interface of web and streaming from 0.0.0.0 to 127.0.0.1 ([Gargron](https://github.com/tootsuite/mastodon/pull/11302), [zunda](https://github.com/tootsuite/mastodon/pull/11378), [Gargron](https://github.com/tootsuite/mastodon/pull/11351), [zunda](https://github.com/tootsuite/mastodon/pull/11326))
+- Change the retry limit of web push notifications ([highemerly](https://github.com/tootsuite/mastodon/pull/11292))
+- Change ActivityPub deliveries to not retry HTTP 501 errors ([Gargron](https://github.com/tootsuite/mastodon/pull/11233))
+- Change language detection to include hashtags as words ([Gargron](https://github.com/tootsuite/mastodon/pull/11341))
+- Change terms and privacy policy pages to always be accessible ([Gargron](https://github.com/tootsuite/mastodon/pull/11334))
+- Change robots tag to include `noarchive` when user opts out of indexing ([Kjwon15](https://github.com/tootsuite/mastodon/pull/11421))
+
+### Fixed
+
+- Fix account domain block not clearing out notifications ([Gargron](https://github.com/tootsuite/mastodon/pull/11393))
+- Fix incorrect locale sometimes being detected for browser ([Gargron](https://github.com/tootsuite/mastodon/pull/8657))
+- Fix crash when saving invalid domain name ([Gargron](https://github.com/tootsuite/mastodon/pull/11528))
+- Fix pinned statuses REST API returning pagination headers ([Gargron](https://github.com/tootsuite/mastodon/pull/11526))
+- Fix "cancel follow request" button having unreadable text in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11521))
+- Fix image uploads being blank when canvas read access is blocked ([ThibG](https://github.com/tootsuite/mastodon/pull/11499))
+- Fix avatars not being animated on hover when not logged in ([ThibG](https://github.com/tootsuite/mastodon/pull/11349))
+- Fix overzealous sanitization of HTML lists ([ThibG](https://github.com/tootsuite/mastodon/pull/11354))
+- Fix block crashing when a follow request exists ([ThibG](https://github.com/tootsuite/mastodon/pull/11288))
+- Fix backup service crashing when an attachment is missing ([ThibG](https://github.com/tootsuite/mastodon/pull/11241))
+- Fix account moderation action always sending e-mail notification ([Gargron](https://github.com/tootsuite/mastodon/pull/11242))
+- Fix swiping columns on mobile sometimes failing in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11200))
+- Fix wrong actor URI being serialized into poll updates ([ThibG](https://github.com/tootsuite/mastodon/pull/11194))
+- Fix statsd UDP sockets not being cleaned up in Sidekiq ([Gargron](https://github.com/tootsuite/mastodon/pull/11230))
+- Fix expiration date of filters being set to "never" when editing them ([ThibG](https://github.com/tootsuite/mastodon/pull/11204))
+- Fix support for MP4 files that are actually M4V files ([Gargron](https://github.com/tootsuite/mastodon/pull/11210))
+- Fix `alerts` not being typecast correctly in push subscription in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/11343))
+- Fix some notices staying on unrelated pages ([ThibG](https://github.com/tootsuite/mastodon/pull/11364))
+- Fix unboosting sometimes preventing a boost from reappearing on feed ([ThibG](https://github.com/tootsuite/mastodon/pull/11405), [Gargron](https://github.com/tootsuite/mastodon/pull/11450))
+- Fix only one middle dot being recognized in hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/11345), [ThibG](https://github.com/tootsuite/mastodon/pull/11363))
+- Fix unnecessary SQL query performed on unauthenticated requests ([Gargron](https://github.com/tootsuite/mastodon/pull/11179))
+- Fix incorrect timestamp displayed on featured tags ([Kjwon15](https://github.com/tootsuite/mastodon/pull/11477))
+- Fix privacy dropdown active state when dropdown is placed on top of it ([ThibG](https://github.com/tootsuite/mastodon/pull/11495))
+- Fix filters not being applied to poll options ([ThibG](https://github.com/tootsuite/mastodon/pull/11174))
+- Fix keyboard navigation on various dropdowns ([ThibG](https://github.com/tootsuite/mastodon/pull/11511), [ThibG](https://github.com/tootsuite/mastodon/pull/11492), [ThibG](https://github.com/tootsuite/mastodon/pull/11491))
+- Fix keyboard navigation in modals ([ThibG](https://github.com/tootsuite/mastodon/pull/11493))
+- Fix image conversation being non-deterministic due to timestamps ([Gargron](https://github.com/tootsuite/mastodon/pull/11408))
+- Fix web UI performance ([ThibG](https://github.com/tootsuite/mastodon/pull/11211), [ThibG](https://github.com/tootsuite/mastodon/pull/11234))
+- Fix scrolling to compose form when not necessary in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11246), [ThibG](https://github.com/tootsuite/mastodon/pull/11182))
+- Fix save button being enabled when list title is empty in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11475))
+- Fix poll expiration not being pre-filled on delete & redraft in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11203))
+- Fix content warning sometimes being set when not requested in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11206))
+
+### Security
+
+- Fix invites not being disabled upon account suspension ([ThibG](https://github.com/tootsuite/mastodon/pull/11412))
+- Fix blocked domains still being able to fill database with account records ([Gargron](https://github.com/tootsuite/mastodon/pull/11219))
+
 ## [2.9.2] - 2019-06-22
 ### Added
 
diff --git a/Dockerfile b/Dockerfile
index 3acbc9d4ce403bbc0d74710d22dc16f55c621a0f..d8c7e0f0c53ef2de2588aac1b8489e51439cafe1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -112,6 +112,7 @@ ENV NODE_ENV="production"
 
 # Tell rails to serve static files
 ENV RAILS_SERVE_STATIC_FILES="true"
+ENV BIND="0.0.0.0"
 
 # Set the run user
 USER mastodon
diff --git a/Gemfile.lock b/Gemfile.lock
index 43d98bbdc602f7a7bfa84a85d92ed1c3edb30df2..bda915dfada1079d7c415b6cca7aae0aa9bfeeff 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -231,7 +231,7 @@ GEM
     fugit (1.1.6)
       et-orbi (~> 1.1, >= 1.1.6)
       raabro (~> 1.1)
-    fuubar (2.4.0)
+    fuubar (2.4.1)
       rspec-core (~> 3.0)
       ruby-progressbar (~> 1.4)
     get_process_mem (0.2.3)
diff --git a/Procfile b/Procfile
index b18e4b6be55cd92e9313933845aeced388c0fd60..d48b0373b05233df9cb41d2d1a0b33a7feb16529 100644
--- a/Procfile
+++ b/Procfile
@@ -1,2 +1,14 @@
-web: bundle exec puma -C config/puma.rb
+web: if [ "$RUN_STREAMING" != "true" ]; then BIND=0.0.0.0 bundle exec puma -C config/puma.rb; else BIND=0.0.0.0 node ./streaming; fi
 worker: bundle exec sidekiq
+
+# For the streaming API, you need a separate app that shares Postgres and Redis:
+#
+# heroku create
+# heroku buildpacks:add heroku/nodejs
+# heroku config:set RUN_STREAMING=true
+# heroku addons:attach <main-app>::DATABASE
+# heroku addons:attach <main-app>::REDIS
+#
+# and let the main app use the separate app:
+#
+# heroku config:set STREAMING_API_BASE_URL=wss://<streaming-app>.herokuapp.com -a <main-app>
diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb
index 8ce413f8a41284c924d67e65e3af66d5b9d4ee0a..f5983a5a5f489d119c373e9c32e814ec2afa633c 100644
--- a/app/chewy/statuses_index.rb
+++ b/app/chewy/statuses_index.rb
@@ -51,7 +51,7 @@ class StatusesIndex < Chewy::Index
       field :id, type: 'long'
       field :account_id, type: 'long'
 
-      field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).join("\n\n") } do
+      field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status_preloadable_poll.options : []).join("\n\n") } do
         field :stemmed, type: 'text', analyzer: 'content'
       end
 
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 52a51fd62ecd70919ea31daaec41aa5020b88778..9f608a851c6467df8f26d59a7dc6145b79f26b04 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -5,6 +5,8 @@ class AboutController < ApplicationController
 
   before_action :set_instance_presenter, only: [:show, :more, :terms]
 
+  skip_before_action :check_user_permissions, only: [:more, :terms]
+
   def show
     @hide_navbar = true
   end
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index 377cac8adcfe61b7f65ce8c192237ddda976236f..7129656dabfe5e9674cff9cfd82800a468ec8d45 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -17,7 +17,7 @@ module Admin
 
       if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
         @domain_block.save
-        flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
+        flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
         @domain_block.errors[:domain].clear
         render :new
       else
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 8cd8f8e79966b19afc354ccc3baaa8bc273086ec..13cb4caf1203c03b018fa42bf152fbd3a8494b63 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -3,7 +3,8 @@
 class Api::V1::Accounts::StatusesController < Api::BaseController
   before_action -> { authorize_if_got_token! :read, :'read:statuses' }
   before_action :set_account
-  after_action :insert_pagination_headers
+
+  after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
 
   respond_to :json
 
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 9274d85a93e3e88757332a5a49de30496810ce52..6b8411402beebba71d71eb87d7fdf03065f25ebc 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -91,11 +91,15 @@ class ApplicationController < ActionController::Base
   end
 
   def current_account
-    @current_account ||= current_user.try(:account)
+    return @current_account if defined?(@current_account)
+
+    @current_account = current_user&.account
   end
 
   def current_session
-    @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
+    return @current_session if defined?(@current_session)
+
+    @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present?
   end
 
   def current_theme
@@ -126,11 +130,7 @@ class ApplicationController < ActionController::Base
   def respond_with_error(code)
     respond_to do |format|
       format.any  { head code }
-
-      format.html do
-        set_locale
-        render "errors/#{code}", layout: 'error', status: code
-      end
+      format.html { render "errors/#{code}", layout: 'error', status: code }
     end
   end
 
diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb
index 145549bcd286ac40f16294ae725199fb926dc1f3..b43859d9d626be53b13af0a3f93fda55ad5fdd36 100644
--- a/app/controllers/concerns/localized.rb
+++ b/app/controllers/concerns/localized.rb
@@ -4,16 +4,19 @@ module Localized
   extend ActiveSupport::Concern
 
   included do
-    before_action :set_locale
+    around_action :set_locale
   end
 
   private
 
   def set_locale
-    I18n.locale = default_locale
-    I18n.locale = current_user.locale if user_signed_in?
-  rescue I18n::InvalidLocale
-    I18n.locale = default_locale
+    locale   = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
+    locale ||= session[:locale] ||= default_locale
+    locale   = default_locale unless I18n.available_locales.include?(locale.to_sym)
+
+    I18n.with_locale(locale) do
+      yield
+    end
   end
 
   def default_locale
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index fdb3a0962a2ac4f53f7ec443be4ea60c3e33c771..de5280305e90cd00877ea029b03a8047653cfe09 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -39,7 +39,7 @@ class InvitesController < ApplicationController
   private
 
   def invites
-    Invite.where(user: current_user).order(id: :desc)
+    current_user.invites.order(id: :desc)
   end
 
   def resource_params
diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
index d87117a50a846c3a31023db36c2ef47cc63020a4..02652a36c988bd67e6aecbeb4a0ac6971d0a71ce 100644
--- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
@@ -14,7 +14,7 @@ module Settings
 
       def create
         if current_user.validate_and_consume_otp!(confirmation_params[:code])
-          flash[:notice] = I18n.t('two_factor_authentication.enabled_success')
+          flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
 
           current_user.otp_required_for_login = true
           @recovery_codes = current_user.generate_otp_backup_codes!
diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
index c78166c6542e722b09091f2338c272f62145cb10..874bf532ba567f2cfeac6f109003c51fc89a0e13 100644
--- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
@@ -10,7 +10,7 @@ module Settings
       def create
         @recovery_codes = current_user.generate_otp_backup_codes!
         current_user.save!
-        flash[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
+        flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
         render :index
       end
     end
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index d3369d8851754d5913dba53df1f9296c54e7d984..d27901c1bb4e321739684379530dac64bb31f94d 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -140,7 +140,7 @@ export function submitCompose(routerHistory) {
       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
       media_ids: media.map(item => item.get('id')),
       sensitive: getState().getIn(['compose', 'sensitive']),
-      spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
+      spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
       visibility: getState().getIn(['compose', 'privacy']),
       poll: getState().getIn(['compose', 'poll'], null),
       local_only: !getState().getIn(['compose', 'federation']),
diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js
index 0445a5e10c6f72753935cdf2fa4fbed3f4650fd7..34a33a65465ee2e058389ff0d006ccaa6a4ee727 100644
--- a/app/javascript/mastodon/actions/domain_blocks.js
+++ b/app/javascript/mastodon/actions/domain_blocks.js
@@ -23,6 +23,7 @@ export function blockDomain(domain) {
     api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
       const at_domain = '@' + domain;
       const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
+
       dispatch(blockDomainSuccess(domain, accounts));
     }).catch(err => {
       dispatch(blockDomainFail(domain, err));
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 5badb0c49eae2ea7c40a1d337ea6258ebc4ad631..5e7e78e698484674d3b7814d6d820f4839b0ad6e 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -22,7 +22,7 @@ export function normalizeAccount(account) {
   if (account.fields) {
     account.fields = account.fields.map(pair => ({
       ...pair,
-      name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
+      name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap),
       value_emojified: emojify(pair.value, emojiMap),
       value_plain: unescapeHTML(pair.value),
     }));
@@ -56,7 +56,7 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.hidden = normalOldStatus.get('hidden');
   } else {
     const spoilerText   = normalStatus.spoiler_text || '';
-    const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+    const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
     const emojiMap      = makeEmojiMap(normalStatus);
 
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js
index 80e15c28ec17889413039375509900ffdfbddc50..3d0299db58af52132cede0f3ac219a1c6c298605 100644
--- a/app/javascript/mastodon/actions/modal.js
+++ b/app/javascript/mastodon/actions/modal.js
@@ -9,8 +9,9 @@ export function openModal(type, props) {
   };
 };
 
-export function closeModal() {
+export function closeModal(type) {
   return {
     type: MODAL_CLOSE,
+    modalType: type,
   };
 };
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 88788eec9f2d0a3a327db522011462dfca82039f..56c952cb05866b3a0dbeaf97d4f06be7d4647842 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -11,7 +11,7 @@ import { saveSettings } from './settings';
 import { defineMessages } from 'react-intl';
 import { List as ImmutableList } from 'immutable';
 import { unescapeHTML } from '../utils/html';
-import { getFilters, regexFromFilters } from '../selectors';
+import { getFiltersRegex } from '../selectors';
 
 export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -43,13 +43,13 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
     const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
     const showAlert    = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
     const playSound    = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
-    const filters      = getFilters(getState(), { contextType: 'notifications' });
+    const filters      = getFiltersRegex(getState(), { contextType: 'notifications' });
 
     let filtered = false;
 
     if (notification.type === 'mention') {
-      const dropRegex   = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
-      const regex       = regexFromFilters(filters);
+      const dropRegex   = filters[0];
+      const regex       = filters[1];
       const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
 
       if (dropRegex && dropRegex.test(searchIndex)) {
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 7c06670eb538f4efdf0fe85024d31787d7cf6b81..0974fdd15e6a5de30e318bade3ccbe4a73b58da4 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -48,7 +48,7 @@ export function submitSearch() {
         dispatch(importFetchedStatuses(response.data.statuses));
       }
 
-      dispatch(fetchSearchSuccess(response.data));
+      dispatch(fetchSearchSuccess(response.data, value));
       dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
     }).catch(error => {
       dispatch(fetchSearchFail(error));
@@ -62,10 +62,11 @@ export function fetchSearchRequest() {
   };
 };
 
-export function fetchSearchSuccess(results) {
+export function fetchSearchSuccess(results, searchTerm) {
   return {
     type: SEARCH_FETCH_SUCCESS,
     results,
+    searchTerm,
   };
 };
 
diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js
index 51e2e6a7a80c83e5cfd0da880af2b863672890f0..eb8dd7dc8eb9c9501c0ce0be83e33f41d5c35c81 100644
--- a/app/javascript/mastodon/components/button.js
+++ b/app/javascript/mastodon/components/button.js
@@ -12,6 +12,7 @@ export default class Button extends React.PureComponent {
     secondary: PropTypes.bool,
     size: PropTypes.number,
     className: PropTypes.string,
+    title: PropTypes.string,
     style: PropTypes.object,
     children: PropTypes.node,
   };
@@ -54,6 +55,7 @@ export default class Button extends React.PureComponent {
         onClick={this.handleClick}
         ref={this.setRef}
         style={style}
+        title={this.props.title}
       >
         {this.props.text || this.props.children}
       </button>
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index 6b9dd6f81b1746a2974b9c101f297b68290f86d2..70ef82789e54c897123fc86bf9cc5da782d1adf4 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
+import { autoPlayGif } from 'mastodon/initial_state';
 
 export default class DisplayName extends React.PureComponent {
 
@@ -10,6 +11,47 @@ export default class DisplayName extends React.PureComponent {
     localDomain: PropTypes.string,
   };
 
+  _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');
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
   render () {
     const { others, localDomain } = this.props;
 
@@ -39,7 +81,7 @@ export default class DisplayName extends React.PureComponent {
     }
 
     return (
-      <span className='display-name'>
+      <span className='display-name' ref={this.setRef}>
         {displayName} {suffix}
       </span>
     );
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 91b65a02fb60da2e1c9f896bc01bd030504f2197..d423378c119f8ff1cd9c20accda0c8b15b22deb7 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -45,7 +45,9 @@ class DropdownMenu extends React.PureComponent {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('keydown', this.handleKeyDown, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
-    if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus();
+    if (this.focusedItem && this.props.openedViaKeyboard) {
+      this.focusedItem.focus();
+    }
     this.setState({ mounted: true });
   }
 
@@ -81,6 +83,18 @@ class DropdownMenu extends React.PureComponent {
         element.focus();
       }
       break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = items[index-1] || items[items.length-1];
+      } else {
+        element = items[index+1] || items[0];
+      }
+      if (element) {
+        element.focus();
+        e.preventDefault();
+        e.stopPropagation();
+      }
+      break;
     case 'Home':
       element = items[0];
       if (element) {
@@ -93,11 +107,14 @@ class DropdownMenu extends React.PureComponent {
         element.focus();
       }
       break;
+    case 'Escape':
+      this.props.onClose();
+      break;
     }
   }
 
-  handleItemKeyDown = e => {
-    if (e.key === 'Enter') {
+  handleItemKeyPress = e => {
+    if (e.key === 'Enter' || e.key === ' ') {
       this.handleClick(e);
     }
   }
@@ -122,11 +139,11 @@ class DropdownMenu extends React.PureComponent {
       return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
     }
 
-    const { text, href = '#' } = option;
+    const { text, href = '#', target = '_blank', method } = option;
 
     return (
       <li className='dropdown-menu__item' key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleItemKeyDown} data-index={i}>
+        <a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
           {text}
         </a>
       </li>
@@ -193,25 +210,41 @@ export default class Dropdown extends React.PureComponent {
     } else {
       const { top } = target.getBoundingClientRect();
       const placement = top * 2 < innerHeight ? 'bottom' : 'top';
-
       this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
     }
   }
 
   handleClose = () => {
+    if (this.activeElement) {
+      this.activeElement.focus();
+      this.activeElement = null;
+    }
     this.props.onClose(this.state.id);
   }
 
-  handleKeyDown = e => {
+  handleMouseDown = () => {
+    if (!this.state.open) {
+      this.activeElement = document.activeElement;
+    }
+  }
+
+  handleButtonKeyDown = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleMouseDown();
+      break;
+    }
+  }
+
+  handleKeyPress = (e) => {
     switch(e.key) {
     case ' ':
     case 'Enter':
       this.handleClick(e);
+      e.stopPropagation();
       e.preventDefault();
       break;
-    case 'Escape':
-      this.handleClose();
-      break;
     }
   }
 
@@ -249,7 +282,7 @@ export default class Dropdown extends React.PureComponent {
     const open = this.state.id === openDropdownId;
 
     return (
-      <div onKeyDown={this.handleKeyDown}>
+      <div>
         <IconButton
           icon={icon}
           title={title}
@@ -258,6 +291,9 @@ export default class Dropdown extends React.PureComponent {
           size={size}
           ref={this.setTargetRef}
           onClick={this.handleClick}
+          onMouseDown={this.handleMouseDown}
+          onKeyDown={this.handleButtonKeyDown}
+          onKeyPress={this.handleKeyPress}
         />
 
         <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index 9d8a8d06b733d379f90bc829c10cb331caabbb09..40167505239b41a6c179d930a9a614ece8ad2c54 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -12,6 +12,9 @@ export default class IconButton extends React.PureComponent {
     title: PropTypes.string.isRequired,
     icon: PropTypes.string.isRequired,
     onClick: PropTypes.func,
+    onMouseDown: PropTypes.func,
+    onKeyDown: PropTypes.func,
+    onKeyPress: PropTypes.func,
     size: PropTypes.number,
     active: PropTypes.bool,
     pressed: PropTypes.bool,
@@ -42,6 +45,24 @@ export default class IconButton extends React.PureComponent {
     }
   }
 
+  handleKeyPress = (e) => {
+    if (this.props.onKeyPress && !this.props.disabled) {
+      this.props.onKeyPress(e);
+    }
+  }
+
+  handleMouseDown = (e) => {
+    if (!this.props.disabled && this.props.onMouseDown) {
+      this.props.onMouseDown(e);
+    }
+  }
+
+  handleKeyDown = (e) => {
+    if (!this.props.disabled && this.props.onKeyDown) {
+      this.props.onKeyDown(e);
+    }
+  }
+
   render () {
     const style = {
       fontSize: `${this.props.size}px`,
@@ -84,6 +105,9 @@ export default class IconButton extends React.PureComponent {
           title={title}
           className={classes}
           onClick={this.handleClick}
+          onMouseDown={this.handleMouseDown}
+          onKeyDown={this.handleKeyDown}
+          onKeyPress={this.handleKeyPress}
           style={style}
           tabIndex={tabIndex}
           disabled={disabled}
@@ -103,6 +127,9 @@ export default class IconButton extends React.PureComponent {
             title={title}
             className={classes}
             onClick={this.handleClick}
+            onMouseDown={this.handleMouseDown}
+            onKeyDown={this.handleKeyDown}
+            onKeyPress={this.handleKeyPress}
             style={style}
             tabIndex={tabIndex}
             disabled={disabled}
diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js
index ef1156571f149fe38235fe9711b1cf7776b413a4..5d4f4bbe138ecc907e98716e9f7fb181155e69ee 100644
--- a/app/javascript/mastodon/components/modal_root.js
+++ b/app/javascript/mastodon/components/modal_root.js
@@ -21,8 +21,30 @@ export default class ModalRoot extends React.PureComponent {
     }
   }
 
+  handleKeyDown = (e) => {
+    if (e.key === 'Tab') {
+      const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
+      const index = focusable.indexOf(e.target);
+
+      let element;
+
+      if (e.shiftKey) {
+        element = focusable[index - 1] || focusable[focusable.length - 1];
+      } else {
+        element = focusable[index + 1] || focusable[0];
+      }
+
+      if (element) {
+        element.focus();
+        e.stopPropagation();
+        e.preventDefault();
+      }
+    }
+  }
+
   componentDidMount () {
     window.addEventListener('keyup', this.handleKeyUp, false);
+    window.addEventListener('keydown', this.handleKeyDown, false);
   }
 
   componentWillReceiveProps (nextProps) {
@@ -52,6 +74,7 @@ export default class ModalRoot extends React.PureComponent {
 
   componentWillUnmount () {
     window.removeEventListener('keyup', this.handleKeyUp);
+    window.removeEventListener('keydown', this.handleKeyDown);
   }
 
   getSiblings = () => {
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 06f5b4aad77aca383526028e4b4bae30e2a6218c..ba6d3c296261d0e783dd7c7135b22a15e5a3cada 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -7,6 +7,7 @@ import Permalink from './permalink';
 import classnames from 'classnames';
 import PollContainer from 'mastodon/containers/poll_container';
 import Icon from 'mastodon/components/icon';
+import { autoPlayGif } from 'mastodon/initial_state';
 
 const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
 
@@ -71,12 +72,35 @@ export default class StatusContent extends React.PureComponent {
     }
   }
 
+  _updateStatusEmojis () {
+    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._updateStatusLinks();
+    this._updateStatusEmojis();
   }
 
   componentDidUpdate () {
     this._updateStatusLinks();
+    this._updateStatusEmojis();
   }
 
   onMentionClick = (mention, e) => {
@@ -95,6 +119,14 @@ export default class StatusContent extends React.PureComponent {
     }
   }
 
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
   handleMouseDown = (e) => {
     this.startXY = [e.clientX, e.clientY];
   }
@@ -133,11 +165,6 @@ export default class StatusContent extends React.PureComponent {
     }
   }
 
-  handleCollapsedClick = (e) => {
-    e.preventDefault();
-    this.setState({ collapsed: !this.state.collapsed });
-  }
-
   setRef = (c) => {
     this.node = c;
   }
@@ -202,45 +229,26 @@ export default class StatusContent extends React.PureComponent {
       );
     } else if (this.props.onClick) {
       const output = [
-        <div
-          ref={this.setRef}
-          tabIndex='0'
-          key='content'
-          className={classNames}
-          style={directionStyle}
-          dangerouslySetInnerHTML={content}
-          lang={status.get('language')}
-          onMouseDown={this.handleMouseDown}
-          onMouseUp={this.handleMouseUp}
-        />,
+        <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+          <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')} />}
+        </div>,
       ];
 
       if (this.state.collapsed) {
         output.push(readMoreButton);
       }
 
-      if (status.get('poll')) {
-        output.push(<PollContainer pollId={status.get('poll')} />);
-      }
-
       return output;
     } else {
-      const output = [
-        <div
-          tabIndex='0'
-          ref={this.setRef}
-          className='status__content'
-          style={directionStyle}
-          dangerouslySetInnerHTML={content}
-          lang={status.get('language')}
-        />,
-      ];
-
-      if (status.get('poll')) {
-        output.push(<PollContainer pollId={status.get('poll')} />);
-      }
+      return (
+        <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
+          <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
 
-      return output;
+          {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
+        </div>
+      );
     }
   }
 
diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js
index 73c8a1e530e7b0b8e285b2e7d3cca9c91c3659c4..f79b192029e5de0d666c1682a81ce8d604e8f35d 100644
--- a/app/javascript/mastodon/containers/dropdown_menu_container.js
+++ b/app/javascript/mastodon/containers/dropdown_menu_container.js
@@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
     }) : openDropdownMenu(id, dropdownPlacement, keyboard));
   },
   onClose(id) {
-    dispatch(closeModal());
+    dispatch(closeModal('ACTIONS'));
     dispatch(closeDropdownMenu(id));
   },
 });
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index e5b60e33e15f4711b2f2f0f2e5c32fa9c992017a..ac97bad7126cd9e2c8379a90620b933aff58a990 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -15,6 +15,7 @@ import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' },
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@@ -79,6 +80,47 @@ class Header extends ImmutablePureComponent {
     return !location.pathname.match(/\/(followers|following)\/?$/);
   }
 
+  _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');
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
   render () {
     const { account, intl, domain, identity_proofs } = this.props;
 
@@ -107,7 +149,7 @@ class Header extends ImmutablePureComponent {
       if (!account.get('relationship')) { // Wait until the relationship is loaded
         actionBtn = '';
       } else if (account.getIn(['relationship', 'requested'])) {
-        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
       } else if (!account.getIn(['relationship', 'blocking'])) {
         actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
       } else if (account.getIn(['relationship', 'blocking'])) {
@@ -200,7 +242,7 @@ class Header extends ImmutablePureComponent {
     const acct            = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
 
     return (
-      <div className={classNames('account__header', { inactive: !!account.get('moved') })}>
+      <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
         <div className='account__header__image'>
           <div className='account__header__info'>
             {info}
diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js
index 077226d70ba535c321a6f49f6bd91e45e864663e..d0303dbfbbf9007d0985bcdb16e7931fe19da9f1 100644
--- a/app/javascript/mastodon/features/compose/components/action_bar.js
+++ b/app/javascript/mastodon/features/compose/components/action_bar.js
@@ -15,6 +15,7 @@ const messages = defineMessages({
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
   filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
+  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
 });
 
 export default @injectIntl
@@ -42,6 +43,8 @@ class ActionBar extends React.PureComponent {
     menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
     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' });
 
     return (
       <div className='compose__action-bar'>
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 8c4e39bfe33f7d8132492647fd5bd7e4a114bb09..fbb5a1d5eb0a2d6d4c8fb43f3b12b595205ab630 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -119,7 +119,10 @@ class ComposeForm extends ImmutablePureComponent {
 
   handleFocus = () => {
     if (this.composeForm && !this.props.singleColumn) {
-      this.composeForm.scrollIntoView();
+      const { left, right } = this.composeForm.getBoundingClientRect();
+      if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+        this.composeForm.scrollIntoView();
+      }
     }
   }
 
@@ -190,12 +193,12 @@ class ComposeForm extends ImmutablePureComponent {
     }
 
     return (
-      <div className='compose-form' ref={this.setRef}>
+      <div className='compose-form'>
         <WarningContainer />
 
         <ReplyIndicatorContainer />
 
-        <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}>
+        <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
           <AutosuggestInput
             placeholder={intl.formatMessage(messages.spoiler_placeholder)}
             value={this.props.spoilerText}
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index d02a55be00e4cd80de028b249fc86fd3a4920acb..7cbfe463add1e278d77522cec57efdef766727d4 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -73,6 +73,19 @@ class PrivacyDropdownMenu extends React.PureComponent {
         this.props.onChange(element.getAttribute('data-index'));
       }
       break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.node.childNodes[index - 1] || this.node.lastChild;
+      } else {
+        element = this.node.childNodes[index + 1] || this.node.firstChild;
+      }
+      if (element) {
+        element.focus();
+        this.props.onChange(element.getAttribute('data-index'));
+        e.preventDefault();
+        e.stopPropagation();
+      }
+      break;
     case 'Home':
       element = this.node.firstChild;
       if (element) {
@@ -180,6 +193,9 @@ class PrivacyDropdown extends React.PureComponent {
       }
     } else {
       const { top } = target.getBoundingClientRect();
+      if (this.state.open && this.activeElement) {
+        this.activeElement.focus();
+      }
       this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
       this.setState({ open: !this.state.open });
     }
@@ -202,7 +218,25 @@ class PrivacyDropdown extends React.PureComponent {
     }
   }
 
+  handleMouseDown = () => {
+    if (!this.state.open) {
+      this.activeElement = document.activeElement;
+    }
+  }
+
+  handleButtonKeyDown = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleMouseDown();
+      break;
+    }
+  }
+
   handleClose = () => {
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus();
+    }
     this.setState({ open: false });
   }
 
@@ -229,7 +263,7 @@ class PrivacyDropdown extends React.PureComponent {
 
     return (
       <div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
-        <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
+        <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })}>
           <IconButton
             className='privacy-dropdown__value-icon'
             icon={valueOption.icon}
@@ -239,6 +273,8 @@ class PrivacyDropdown extends React.PureComponent {
             active={open}
             inverted
             onClick={this.handleToggle}
+            onMouseDown={this.handleMouseDown}
+            onKeyDown={this.handleButtonKeyDown}
             style={{ height: null, lineHeight: '27px' }}
           />
         </div>
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index e0966f52c432b6f5f3b2f7dc377008d54a483d57..2f338dd2469a49856d8c66b96e55e4591e46d7d6 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -7,6 +7,7 @@ import StatusContainer from '../../../containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Hashtag from '../../../components/hashtag';
 import Icon from 'mastodon/components/icon';
+import { searchEnabled } from '../../../initial_state';
 
 const messages = defineMessages({
   dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
@@ -20,6 +21,7 @@ class SearchResults extends ImmutablePureComponent {
     suggestions: ImmutablePropTypes.list.isRequired,
     fetchSuggestions: PropTypes.func.isRequired,
     dismissSuggestion: PropTypes.func.isRequired,
+    searchTerm: PropTypes.string,
     intl: PropTypes.object.isRequired,
   };
 
@@ -28,7 +30,7 @@ class SearchResults extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, results, suggestions, dismissSuggestion } = this.props;
+    const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
 
     if (results.isEmpty() && !suggestions.isEmpty()) {
       return (
@@ -76,6 +78,16 @@ class SearchResults extends ImmutablePureComponent {
           {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
         </div>
       );
+    } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
+      statuses = (
+        <div className='search-results__section'>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
+          <div className='search-results__info'>
+            <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
+          </div>
+        </div>
+      );
     }
 
     if (results.get('hashtags') && results.get('hashtags').size > 0) {
diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js
index f9637861ae7b437c632f10f0967590f0e98df3b3..e4d5f3420787c81184c529bc951e843243a46cfb 100644
--- a/app/javascript/mastodon/features/compose/containers/search_results_container.js
+++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js
@@ -5,6 +5,7 @@ import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestion
 const mapStateToProps = state => ({
   results: state.getIn(['search', 'results']),
   suggestions: state.getIn(['suggestions', 'items']),
+  searchTerm: state.getIn(['search', 'searchTerm']),
 });
 
 const mapDispatchToProps = dispatch => ({
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index 988cea2530d1e6f7a9b799dc4c3141343eba3583..fa91b50149fadcd4204b1024026fda6271eef34b 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -29,7 +29,7 @@ const emojify = (str, customEmojis = {}) => {
         // if you want additional emoji handler, add statements below which set replacement and return true.
         if (shortname in customEmojis) {
           const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
-          replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
+          replacement = `<img draggable="false" class="emojione custom-emoji" alt="${shortname}" title="${shortname}" src="${filename}" data-original="${customEmojis[shortname].url}" data-static="${customEmojis[shortname].static_url}" />`;
           return true;
         }
         return false;
diff --git a/app/javascript/mastodon/features/list_editor/components/edit_list_form.js b/app/javascript/mastodon/features/list_editor/components/edit_list_form.js
index 3dc59c12e765b08951a401e687b3b3543eeda060..3ccab12a800c7891ffde4ceeb3ba1f12b2d2cb6c 100644
--- a/app/javascript/mastodon/features/list_editor/components/edit_list_form.js
+++ b/app/javascript/mastodon/features/list_editor/components/edit_list_form.js
@@ -11,7 +11,7 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   value: state.getIn(['listEditor', 'title']),
-  disabled: !state.getIn(['listEditor', 'isChanged']),
+  disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
 });
 
 const mapDispatchToProps = dispatch => ({
diff --git a/app/javascript/mastodon/features/lists/components/new_list_form.js b/app/javascript/mastodon/features/lists/components/new_list_form.js
index 7392466401780ac0c80822d32cf9291cd54986f1..7faf50be81c4842f3458faad47b6f3391591fa1b 100644
--- a/app/javascript/mastodon/features/lists/components/new_list_form.js
+++ b/app/javascript/mastodon/features/lists/components/new_list_form.js
@@ -66,7 +66,7 @@ class NewListForm extends React.PureComponent {
         </label>
 
         <IconButton
-          disabled={disabled}
+          disabled={disabled || !value}
           icon='plus'
           title={title}
           onClick={this.handleClick}
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 981eb9d58b8d824b780c72ecebd13c083befa8eb..0422111ae38e1a1e76a361e26f216dae888fa3b1 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import { createSelector } from 'reselect';
 import { fetchStatus } from '../../actions/statuses';
 import MissingIndicator from '../../components/missing_indicator';
 import DetailedStatus from './components/detailed_status';
@@ -63,39 +64,58 @@ const messages = defineMessages({
 const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
 
-  const mapStateToProps = (state, props) => {
-    const status = getStatus(state, { id: props.params.statusId });
+  const getAncestorsIds = createSelector([
+    (_, { id }) => id,
+    state => state.getIn(['contexts', 'inReplyTos']),
+  ], (statusId, inReplyTos) => {
     let ancestorsIds = Immutable.List();
+    ancestorsIds = ancestorsIds.withMutations(mutable => {
+      let id = statusId;
+
+      while (id) {
+        mutable.unshift(id);
+        id = inReplyTos.get(id);
+      }
+    });
+
+    return ancestorsIds;
+  });
+
+  const getDescendantsIds = createSelector([
+    (_, { id }) => id,
+    state => state.getIn(['contexts', 'replies']),
+  ], (statusId, contextReplies) => {
     let descendantsIds = Immutable.List();
+    descendantsIds = descendantsIds.withMutations(mutable => {
+      const ids = [statusId];
 
-    if (status) {
-      ancestorsIds = ancestorsIds.withMutations(mutable => {
-        let id = status.get('in_reply_to_id');
+      while (ids.length > 0) {
+        let id        = ids.shift();
+        const replies = contextReplies.get(id);
 
-        while (id) {
-          mutable.unshift(id);
-          id = state.getIn(['contexts', 'inReplyTos', id]);
+        if (statusId !== id) {
+          mutable.push(id);
         }
-      });
 
-      descendantsIds = descendantsIds.withMutations(mutable => {
-        const ids = [status.get('id')];
+        if (replies) {
+          replies.reverse().forEach(reply => {
+            ids.unshift(reply);
+          });
+        }
+      }
+    });
 
-        while (ids.length > 0) {
-          let id        = ids.shift();
-          const replies = state.getIn(['contexts', 'replies', id]);
+    return descendantsIds;
+  });
 
-          if (status.get('id') !== id) {
-            mutable.push(id);
-          }
+  const mapStateToProps = (state, props) => {
+    const status = getStatus(state, { id: props.params.statusId });
+    let ancestorsIds = Immutable.List();
+    let descendantsIds = Immutable.List();
 
-          if (replies) {
-            replies.reverse().forEach(reply => {
-              ids.unshift(reply);
-            });
-          }
-        }
-      });
+    if (status) {
+      ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
+      descendantsIds = getDescendantsIds(state, { id: status.get('id') });
     }
 
     return {
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 756db3c61ad825f0994e61cbcb26f121501c3a05..042e44e43e9f46cb42518941a7544a25b41688bb 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -110,6 +110,11 @@ class ColumnsArea extends ImmutablePureComponent {
     // React-router does this for us, but too late, feeling laggy.
     document.querySelector(currentLinkSelector).classList.remove('active');
     document.querySelector(nextLinkSelector).classList.add('active');
+
+    if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
+      this.context.router.history.push(getLink(this.pendingIndex));
+      this.pendingIndex = null;
+    }
   }
 
   handleAnimationEnd = () => {
@@ -160,7 +165,6 @@ class ColumnsArea extends ImmutablePureComponent {
     const { shouldAnimate } = this.state;
 
     const columnIndex = getIndex(this.context.router.history.location.pathname);
-    this.pendingIndex = null;
 
     if (singleColumn) {
       const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 405c62032427a5a846fae7ab78c3016a549bb7f0..77353528281f8e01af9dc8e2c43a8abf177137ac 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -199,6 +199,12 @@ const expandMentions = status => {
   return fragment.innerHTML;
 };
 
+const expiresInFromExpiresAt = expires_at => {
+  if (!expires_at) return 24 * 3600;
+  const delta = (new Date(expires_at).getTime() - Date.now()) / 1000;
+  return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
@@ -228,6 +234,7 @@ export default function compose(state = initialState, action) {
       }
     });
   case COMPOSE_SPOILER_TEXT_CHANGE:
+    if (!state.get('spoiler')) return state;
     return state
       .set('spoiler_text', action.text)
       .set('idempotencyKey', uuid());
@@ -363,7 +370,7 @@ export default function compose(state = initialState, action) {
         map.set('poll', ImmutableMap({
           options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
           multiple: action.status.getIn(['poll', 'multiple']),
-          expires_in: 24 * 3600,
+          expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
         }));
       }
     });
diff --git a/app/javascript/mastodon/reducers/conversations.js b/app/javascript/mastodon/reducers/conversations.js
index 9564bffcd2be16e8aeba3d3a5603068b7af6d46c..39065823928ae2d8e1f0fe0984ce8cc73cc04d7f 100644
--- a/app/javascript/mastodon/reducers/conversations.js
+++ b/app/javascript/mastodon/reducers/conversations.js
@@ -8,6 +8,8 @@ import {
   CONVERSATIONS_UPDATE,
   CONVERSATIONS_READ,
 } from '../actions/conversations';
+import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
+import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
 import compareId from '../compare_id';
 
 const initialState = ImmutableMap({
@@ -74,6 +76,10 @@ const expandNormalizedConversations = (state, conversations, next, isLoadingRece
   });
 };
 
+const filterConversations = (state, accountIds) => {
+  return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId))));
+};
+
 export default function conversations(state = initialState, action) {
   switch (action.type) {
   case CONVERSATIONS_FETCH_REQUEST:
@@ -96,6 +102,11 @@ export default function conversations(state = initialState, action) {
 
       return item;
     }));
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterConversations(state, [action.relationship.id]);
+  case DOMAIN_BLOCK_SUCCESS:
+    return filterConversations(state, action.accounts);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js
index 599a2443e0d5ed56a58407177e89f8f306ca8f04..a30da2db1b4ffe54409842127c03288c070e965b 100644
--- a/app/javascript/mastodon/reducers/modal.js
+++ b/app/javascript/mastodon/reducers/modal.js
@@ -10,7 +10,7 @@ export default function modal(state = initialState, action) {
   case MODAL_OPEN:
     return { modalType: action.modalType, modalProps: action.modalProps };
   case MODAL_CLOSE:
-    return initialState;
+    return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 4d9604de9ee3eafe22e2ee489453ef3088a2e5a6..33fe86e002e2e92a51ce148df9e4336dc577a3bd 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -11,6 +11,7 @@ import {
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_MUTE_SUCCESS,
 } from '../actions/accounts';
+import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
 import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 import compareId from '../compare_id';
@@ -77,8 +78,8 @@ const expandNormalizedNotifications = (state, notifications, next) => {
   });
 };
 
-const filterNotifications = (state, relationship) => {
-  return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id));
+const filterNotifications = (state, accountIds) => {
+  return state.update('items', list => list.filterNot(item => item !== null && accountIds.includes(item.get('account'))));
 };
 
 const updateTop = (state, top) => {
@@ -108,9 +109,11 @@ export default function notifications(state = initialState, action) {
   case NOTIFICATIONS_EXPAND_SUCCESS:
     return expandNormalizedNotifications(state, action.notifications, action.next);
   case ACCOUNT_BLOCK_SUCCESS:
-    return filterNotifications(state, action.relationship);
+    return filterNotifications(state, [action.relationship.id]);
   case ACCOUNT_MUTE_SUCCESS:
-    return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state;
+    return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
+  case DOMAIN_BLOCK_SUCCESS:
+    return filterNotifications(state, action.accounts);
   case NOTIFICATIONS_CLEAR:
     return state.set('items', ImmutableList()).set('hasMore', false);
   case TIMELINE_DELETE:
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 4758defb1b9eef853e013a22aad432493ab8f271..77b7f588c5864099516ff41cd8c1c6402a692b25 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -16,6 +16,7 @@ const initialState = ImmutableMap({
   submitted: false,
   hidden: false,
   results: ImmutableMap(),
+  searchTerm: '',
 });
 
 export default function search(state = initialState, action) {
@@ -40,7 +41,7 @@ export default function search(state = initialState, action) {
       accounts: ImmutableList(action.results.accounts.map(item => item.id)),
       statuses: ImmutableList(action.results.statuses.map(item => item.id)),
       hashtags: fromJS(action.results.hashtags),
-    })).set('submitted', true);
+    })).set('submitted', true).set('searchTerm', action.searchTerm);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/suggestions.js b/app/javascript/mastodon/reducers/suggestions.js
index 9f4b89d58659714dd370557ff1736124dc07b91a..834be728f1e2e8bff298d9306c37f985bf8b603e 100644
--- a/app/javascript/mastodon/reducers/suggestions.js
+++ b/app/javascript/mastodon/reducers/suggestions.js
@@ -4,6 +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 { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
 const initialState = ImmutableMap({
@@ -24,6 +26,11 @@ export default function suggestionsReducer(state = initialState, action) {
     return state.set('isLoading', false);
   case SUGGESTIONS_DISMISS:
     return state.update('items', list => list.filterNot(id => id === action.id));
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return state.update('items', list => list.filterNot(id => id === action.relationship.id));
+  case DOMAIN_BLOCK_SUCCESS:
+    return state.update('items', list => list.filterNot(id => action.accounts.includes(id)));
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index ff6c7fdfb3fabefa317d0e51fa994d0751102ae6..c87654547d47feac1cb3c17a4fbec89514cd0b67 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -1,5 +1,5 @@
 import { createSelector } from 'reselect';
-import { List as ImmutableList } from 'immutable';
+import { List as ImmutableList, is } from 'immutable';
 import { me } from '../initial_state';
 
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
@@ -36,12 +36,10 @@ const toServerSideType = columnType => {
   }
 };
 
-export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
-
 const escapeRegExp = string =>
   string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
 
-export const regexFromFilters = filters => {
+const regexFromFilters = filters => {
   if (filters.size === 0) {
     return null;
   }
@@ -63,6 +61,27 @@ export const regexFromFilters = filters => {
   }).join('|'), 'i');
 };
 
+// Memoize the filter regexps for each valid server contextType
+const makeGetFiltersRegex = () => {
+  let memo = {};
+
+  return (state, { contextType }) => {
+    if (!contextType) return ImmutableList();
+
+    const serverSideType = toServerSideType(contextType);
+    const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
+
+    if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) {
+      const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
+      const regex = regexFromFilters(filters);
+      memo[serverSideType] = { filters: filters, results: [dropRegex, regex] };
+    }
+    return memo[serverSideType].results;
+  };
+};
+
+export const getFiltersRegex = makeGetFiltersRegex();
+
 export const makeGetStatus = () => {
   return createSelector(
     [
@@ -70,10 +89,10 @@ export const makeGetStatus = () => {
       (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
-      getFilters,
+      getFiltersRegex,
     ],
 
-    (statusBase, statusReblog, accountBase, accountReblog, filters) => {
+    (statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
       if (!statusBase) {
         return null;
       }
@@ -84,12 +103,12 @@ export const makeGetStatus = () => {
         statusReblog = null;
       }
 
-      const dropRegex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters.filter(filter => filter.get('irreversible')));
+      const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
       if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
         return null;
       }
 
-      const regex     = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters);
+      const regex     = (accountReblog || accountBase).get('id') !== me && filtersRegex[1];
       const filtered  = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'));
 
       return statusBase.withMutations(map => {
diff --git a/app/javascript/mastodon/utils/resize_image.js b/app/javascript/mastodon/utils/resize_image.js
index bbdbc865e9ede08f8ff823c321beb18a7adb4ba1..a8ec5f3fa767286042c9da1084dca64e960584af 100644
--- a/app/javascript/mastodon/utils/resize_image.js
+++ b/app/javascript/mastodon/utils/resize_image.js
@@ -67,6 +67,14 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) =
 
   context.drawImage(img, 0, 0, width, height);
 
+  // The Tor Browser and maybe other browsers may prevent reading from canvas
+  // and return an all-white image instead. Assume reading failed if the resized
+  // image is perfectly white.
+  const imageData = context.getImageData(0, 0, width, height);
+  if (imageData.every(value => value === 255)) {
+    throw 'Failed to read from canvas';
+  }
+
   canvas.toBlob(resolve, type);
 });
 
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 3135636cfb58427473da0ef5a4057b29a31e9003..132a9766ec7870013c326cf668ff0191beb6aa7f 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -44,6 +44,12 @@ function main() {
     }
   };
 
+  const getEmojiAnimationHandler = (swapTo) => {
+    return ({ target }) => {
+      target.src = target.getAttribute(swapTo);
+    };
+  };
+
   ready(() => {
     const locale = document.documentElement.lang;
 
@@ -116,6 +122,9 @@ function main() {
       document.head.appendChild(scrollbarWidthStyle);
       scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0);
     }
+
+    delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
+    delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
   });
 
   delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
@@ -178,7 +187,7 @@ function main() {
     return ({ target }) => {
       const swapSrc = target.getAttribute(swapTo);
       //only change the img source if autoplay is off and the image src is actually different
-      if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) {
+      if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
         target.src = swapSrc;
       }
     };
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 4a0d64ad380e85004f33da7b675d7e49095e1113..e413b00131b157767ea400be0d3430968541573f 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3996,6 +3996,11 @@ a.status-card.compact:hover {
   }
 }
 
+.search-results__info {
+  padding: 10px;
+  color: $secondary-text-color;
+}
+
 .modal-root {
   position: relative;
   transition: opacity 0.3s linear;
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index d77cdb3a3cc206d68dcfa479dcab6156305ae573..ca3d890a813f7a503a38762ace96313f966131eb 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -35,7 +35,7 @@ class FeedManager
   end
 
   def unpush_from_home(account, status)
-    return false unless remove_from_feed(:home, account.id, status)
+    return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
     redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
     true
   end
@@ -53,7 +53,7 @@ class FeedManager
   end
 
   def unpush_from_list(list, status)
-    return false unless remove_from_feed(:list, list.id, status)
+    return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
     redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
     true
   end
@@ -105,7 +105,7 @@ class FeedManager
     oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
 
     from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
-      remove_from_feed(:home, into_account.id, status)
+      remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?)
     end
   end
 
@@ -220,7 +220,8 @@ class FeedManager
     status         = status.reblog if status.reblog?
 
     !combined_regex.match(Formatter.instance.plaintext(status)).nil? ||
-      (status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?)
+      (status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?) ||
+      (status.preloadable_poll && !combined_regex.match(status.preloadable_poll.options.join("\n\n")).nil?)
   end
 
   # Adds a status to an account's feed, returning true if a status was
@@ -274,10 +275,11 @@ class FeedManager
   # with reblogs, and returning true if a status was removed. As with
   # `add_to_feed`, this does not trigger push updates, so callers must
   # do so if appropriate.
-  def remove_from_feed(timeline_type, account_id, status)
+  def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true)
     timeline_key = key(timeline_type, account_id)
+    reblog_key   = key(timeline_type, account_id, 'reblogs')
 
-    if status.reblog?
+    if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs)
       # 1. If the reblogging status is not in the feed, stop.
       status_rank = redis.zrevrank(timeline_key, status.id)
       return false if status_rank.nil?
@@ -286,6 +288,7 @@ class FeedManager
       reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
 
       redis.srem(reblog_set_key, status.id)
+      redis.zrem(reblog_key, status.reblog_of_id)
       # 3. Re-insert another reblog or original into the feed if one
       # remains in the set. We could pick a random element, but this
       # set should generally be small, and it seems ideal to show the
@@ -293,12 +296,14 @@ class FeedManager
       other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min
 
       redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog
+      redis.zadd(reblog_key, other_reblog, status.reblog_of_id) if other_reblog
 
       # 4. Remove the reblogging status from the feed (as normal)
       # (outside conditional)
     else
       # If the original is getting deleted, no use for reblog references
       redis.del(key(timeline_type, account_id, "reblogs:#{status.id}"))
+      redis.zrem(reblog_key, status.id)
     end
 
     redis.zrem(timeline_key, status.id)
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 8a1aad41a7d559933d13d6e59924d24eae0a22d6..6b5a8563ad829dcd883b01454d90fdb467af3300 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -137,11 +137,7 @@ class Formatter
   def encode_custom_emojis(html, emojis, animate = false)
     return html if emojis.empty?
 
-    emoji_map = if animate
-                  emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url) }
-                else
-                  emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url(:static)) }
-                end
+    emoji_map = emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] }
 
     i                     = -1
     tag_open_index        = nil
@@ -157,7 +153,14 @@ class Formatter
         emoji     = emoji_map[shortcode]
 
         if emoji
-          replacement = "<img draggable=\"false\" class=\"emojione\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(emoji)}\" />"
+          original_url, static_url = emoji
+          replacement = begin
+            if animate
+              "<img draggable=\"false\" class=\"emojione\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(original_url)}\" />"
+            else
+              "<img draggable=\"false\" class=\"emojione custom-emoji\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(static_url)}\" data-original=\"#{original_url}\" data-static=\"#{static_url}\" />"
+            end
+          end
           before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : ''
           html        = before_html + replacement + html[i + 1..-1]
           i          += replacement.size - (shortcode.size + 2) - 1
diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb
index 1e90af42d7abfd5b6664a45577eb8e8046de2f3c..6f9511a54192a70cbb646ec371f6d7e2102e36f2 100644
--- a/app/lib/language_detector.rb
+++ b/app/lib/language_detector.rb
@@ -69,7 +69,7 @@ class LanguageDetector
     new_text = remove_html(text)
     new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
     new_text.gsub!(Account::MENTION_RE, '')
-    new_text.gsub!(Tag::HASHTAG_RE, '')
+    new_text.gsub!(Tag::HASHTAG_RE) { |string| string.gsub(/[#_]/, '#' => '', '_' => ' ').gsub(/[a-z][A-Z]|[a-zA-Z][\d]/) { |s| s.insert(1, ' ') }.downcase }
     new_text.gsub!(/:#{CustomEmoji::SHORTCODE_RE_FRAGMENT}:/, '')
     new_text.gsub!(/\s+/, ' ')
     new_text
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index e82a2a33aa1ad456160ee4801d80f5aa5806bb6a..aba8ce9f615cbb611b8f70ec3f6599918b2ce0df 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -25,6 +25,8 @@ class Sanitize
       case env[:node_name]
       when 'li'
         env[:node].traverse do |node|
+          next unless %w(p ul ol li).include?(node.name)
+
           node.add_next_sibling('<br>') if node.next_sibling
           node.replace(node.children) unless node.text?
         end
diff --git a/app/lib/sidekiq_error_handler.rb b/app/lib/sidekiq_error_handler.rb
index 23785cf055837cfca43beb24594ef32a73ea716e..8eb6b942dba17302880dcb87fd490652e14bc813 100644
--- a/app/lib/sidekiq_error_handler.rb
+++ b/app/lib/sidekiq_error_handler.rb
@@ -3,9 +3,11 @@
 class SidekiqErrorHandler
   def call(*)
     yield
-  rescue Mastodon::HostValidationError => e
-    Rails.logger.error "#{e.class}: #{e.message}"
-    Rails.logger.error e.backtrace.join("\n")
+  rescue Mastodon::HostValidationError
     # Do not retry
+  ensure
+    socket = Thread.current[:statsd_socket]
+    socket&.close
+    Thread.current[:statsd_socket] = nil
   end
 end
diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb
index 7c0d60379eb60b6a1c358daf980da0bee5126f68..3aaffde9a952d3bcd6eb568273b5beb127314477 100644
--- a/app/models/account_domain_block.rb
+++ b/app/models/account_domain_block.rb
@@ -15,7 +15,7 @@ class AccountDomainBlock < ApplicationRecord
   include DomainNormalizable
 
   belongs_to :account
-  validates :domain, presence: true, uniqueness: { scope: :account_id }
+  validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
 
   after_commit :remove_blocking_cache
   after_commit :remove_relationship_cache
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
index 84c3f880d2556efd5005cd59dea1d273f604af6f..bdbd342fb3ccf3fb0444a831125b72d90ef5f8e9 100644
--- a/app/models/admin/account_action.rb
+++ b/app/models/admin/account_action.rb
@@ -17,10 +17,13 @@ class Admin::AccountAction
                 :type,
                 :text,
                 :report_id,
-                :warning_preset_id,
-                :send_email_notification
+                :warning_preset_id
 
-  attr_reader :warning
+  attr_reader :warning, :send_email_notification
+
+  def send_email_notification=(value)
+    @send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
+  end
 
   def save!
     ApplicationRecord.transaction do
diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb
index 24f5968de41c42f05892a946d5029ae7b317e7bd..7c78bb45698dccf8e9036cc3e2eb53272ee2ba43 100644
--- a/app/models/concerns/attachmentable.rb
+++ b/app/models/concerns/attachmentable.rb
@@ -60,7 +60,9 @@ module Attachmentable
   end
 
   def calculated_content_type(attachment)
-    Paperclip.run('file', '-b --mime :file', file: attachment.queued_for_write[:original].path).split(/[:;\s]+/).first.chomp
+    content_type = Paperclip.run('file', '-b --mime :file', file: attachment.queued_for_write[:original].path).split(/[:;\s]+/).first.chomp
+    content_type = 'video/mp4' if content_type == 'video/x-m4v'
+    content_type
   rescue Terrapin::CommandLineError
     ''
   end
diff --git a/app/models/concerns/domain_normalizable.rb b/app/models/concerns/domain_normalizable.rb
index fb84058fc273f71328f53ae6822cda3e866fa1d4..c00b3142fa73f99129562b4c2d262633329160de 100644
--- a/app/models/concerns/domain_normalizable.rb
+++ b/app/models/concerns/domain_normalizable.rb
@@ -4,7 +4,7 @@ module DomainNormalizable
   extend ActiveSupport::Concern
 
   included do
-    before_validation :normalize_domain
+    before_save :normalize_domain
   end
 
   private
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index e73cd9bd2753e0d037afccbcc000d8e7a3ab1000..def1b3faa162057c89a80601ac45df7f5a3ae9c1 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -27,13 +27,15 @@ class CustomEmoji < ApplicationRecord
     :(#{SHORTCODE_RE_FRAGMENT}):
     (?=[^[:alnum:]:]|$)/x
 
+  IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze
+
   has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
 
   has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
 
   before_validation :downcase_domain
 
-  validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { less_than: LIMIT }
+  validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT }
   validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
 
   scope :local,      -> { where(domain: nil) }
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 342207a55dfebe51e4cf63259477091e17206287..382562fb843a36fba848f8a365cd0b9afb1062c8 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -35,6 +35,13 @@ class CustomFilter < ApplicationRecord
   before_validation :clean_up_contexts
   after_commit :remove_cache
 
+  def expires_in
+    return @expires_in if defined?(@expires_in)
+    return nil if expires_at.nil?
+
+    [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at }
+  end
+
   private
 
   def clean_up_contexts
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 25d3b87ef51d9e3acb75e6a9c8e66368804fd9b3..c230fb14bda41a021f62e5eb4eb1ae4dd8fa2c09 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -17,7 +17,7 @@ class DomainBlock < ApplicationRecord
 
   enum severity: [:silence, :suspend, :noop]
 
-  validates :domain, presence: true, uniqueness: true
+  validates :domain, presence: true, uniqueness: true, domain: true
 
   has_many :accounts, foreign_key: :domain, primary_key: :domain
   delegate :count, to: :accounts, prefix: true
diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb
index 0fcd36477b927942e5e02dca35db7dcfdafe8999..bc70dea254a5407da4344a6adfc4e022ad3410ad 100644
--- a/app/models/email_domain_block.rb
+++ b/app/models/email_domain_block.rb
@@ -12,7 +12,7 @@
 class EmailDomainBlock < ApplicationRecord
   include DomainNormalizable
 
-  validates :domain, presence: true, uniqueness: true
+  validates :domain, presence: true, uniqueness: true, domain: true
 
   def self.block?(email)
     _, domain = email.split('@', 2)
diff --git a/app/models/invite.rb b/app/models/invite.rb
index fe23224625f8d15ca4eff0c41f68ec2f5ecec1d4..02ab8e0b2170926fc0e1125163615643f8b5115f 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -17,7 +17,7 @@
 class Invite < ApplicationRecord
   include Expireable
 
-  belongs_to :user
+  belongs_to :user, inverse_of: :invites
   has_many :users, inverse_of: :invite
 
   scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
@@ -25,7 +25,7 @@ class Invite < ApplicationRecord
   before_validation :set_code
 
   def valid_for_use?
-    (max_uses.nil? || uses < max_uses) && !expired?
+    (max_uses.nil? || uses < max_uses) && !expired? && !(user.nil? || user.disabled?)
   end
 
   private
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 30d9a985125cdc194e987319353d76d504848a11..b1c5589478fb194f88a2b11f1a930af328459465 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -113,7 +113,7 @@ class MediaAttachment < ApplicationRecord
   has_attached_file :file,
                     styles: ->(f) { file_styles f },
                     processors: ->(f) { file_processors f },
-                    convert_options: { all: '-quality 90 -strip' }
+                    convert_options: { all: '-quality 90 -strip +set modify-date +set create-date' }
 
   validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
   validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
diff --git a/app/models/status.rb b/app/models/status.rb
index 673de911f43609cf5620d7014345662232ae4fc5..acbebf0cb4caa38e9cf8310d195ffbf54eb5a8db 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -78,7 +78,7 @@ class Status < ApplicationRecord
   default_scope { recent }
 
   scope :recent, -> { reorder(id: :desc) }
-  scope :remote, -> { where(local: false).or(where.not(uri: nil)) }
+  scope :remote, -> { where(local: false).where.not(uri: nil) }
   scope :local,  -> { where(local: true).or(where(uri: nil)) }
 
   scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 7db76d157b00044f20a6770e6ec367f8aea942b9..b371d59c1be396a5070e12d913aff4b6fe616e27 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -17,10 +17,10 @@ class Tag < ApplicationRecord
   has_many :featured_tags, dependent: :destroy, inverse_of: :tag
   has_one :account_tag_stat, dependent: :destroy
 
-  HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_·][[:word:]_]*'
+  HASHTAG_NAME_RE = '([[:word:]_][[:word:]_·]*[[:alpha:]_·][[:word:]_·]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)'
   HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
 
-  validates :name, presence: true, uniqueness: true, format: { with: /\A#{HASHTAG_NAME_RE}\z/i }
+  validates :name, presence: true, uniqueness: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
 
   scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
   scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
diff --git a/app/models/user.rb b/app/models/user.rb
index b26ccafa2f5a5b10ce8253358c27bcb9f939dc56..2ff886531a30d2632c1d8a6c5bfe9c92281d126c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -73,6 +73,7 @@ class User < ApplicationRecord
 
   has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
   has_many :backups, inverse_of: :user
+  has_many :invites, inverse_of: :user
 
   has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
   accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
diff --git a/app/serializers/activitypub/update_poll_serializer.rb b/app/serializers/activitypub/update_poll_serializer.rb
index b894f309ff42a521602261ea27b6fa1534b4cb23..1d47b976435afd7c116e76d6d03776437145962b 100644
--- a/app/serializers/activitypub/update_poll_serializer.rb
+++ b/app/serializers/activitypub/update_poll_serializer.rb
@@ -14,7 +14,7 @@ class ActivityPub::UpdatePollSerializer < ActivityPub::Serializer
   end
 
   def actor
-    ActivityPub::TagManager.instance.uri_for(object)
+    ActivityPub::TagManager.instance.uri_for(object.account)
   end
 
   def to
diff --git a/app/serializers/rest/web_push_subscription_serializer.rb b/app/serializers/rest/web_push_subscription_serializer.rb
index 7fd952a567bf62d1f37ab030b25d3e551ab0e2f5..194cc0a8c7e40c20a75700692cd883142324e58b 100644
--- a/app/serializers/rest/web_push_subscription_serializer.rb
+++ b/app/serializers/rest/web_push_subscription_serializer.rb
@@ -4,7 +4,7 @@ class REST::WebPushSubscriptionSerializer < ActiveModel::Serializer
   attributes :id, :endpoint, :alerts, :server_key
 
   def alerts
-    object.data&.dig('alerts') || {}
+    (object.data&.dig('alerts') || {}).each_with_object({}) { |(k, v), h| h[k] = ActiveModel::Type::Boolean.new.cast(v) }
   end
 
   def server_key
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 05c017bdf5a41e40d47b5de41c7c65d2f4779dab..3857e7c16d58991b3f6fc2f55adbe187e45f6ad1 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -15,6 +15,8 @@ class ActivityPub::ProcessAccountService < BaseService
     @domain      = domain
     @collections = {}
 
+    return if auto_suspend?
+
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
         @account        = Account.find_remote(@username, @domain)
@@ -55,7 +57,7 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.domain       = @domain
     @account.private_key  = nil
     @account.suspended_at = domain_block.created_at if auto_suspend?
-    @account.silenced_at = domain_block.created_at if auto_silence?
+    @account.silenced_at  = domain_block.created_at if auto_silence?
   end
 
   def update_account
diff --git a/app/services/after_block_domain_from_account_service.rb b/app/services/after_block_domain_from_account_service.rb
index a87c2e792c928f6039917cb0f4557e1d918a2783..f50bde261d299025620da1d6ef1ff5f5cffbeb3f 100644
--- a/app/services/after_block_domain_from_account_service.rb
+++ b/app/services/after_block_domain_from_account_service.rb
@@ -10,12 +10,24 @@ class AfterBlockDomainFromAccountService < BaseService
     @account = account
     @domain  = domain
 
+    clear_notifications!
+    remove_follows!
     reject_existing_followers!
     reject_pending_follow_requests!
   end
 
   private
 
+  def remove_follows!
+    @account.active_relationships.where(account: Account.where(domain: @domain)).includes(:target_account).reorder(nil).find_each do |follow|
+      UnfollowService.new.call(@account, follow.target_account)
+    end
+  end
+
+  def clear_notifications!
+    Notification.where(account: @account).where(from_account: Account.where(domain: @domain)).in_batches.delete_all
+  end
+
   def reject_existing_followers!
     @account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow|
       reject_follow!(follow)
diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb
index 706db0d6334d9a82fbbc52bcc90ef7fb7f4050f5..2a0e10a79a60e294edb490ed928b58e6541e3908 100644
--- a/app/services/after_block_service.rb
+++ b/app/services/after_block_service.rb
@@ -2,43 +2,25 @@
 
 class AfterBlockService < BaseService
   def call(account, target_account)
-    clear_home_feed(account, target_account)
-    clear_notifications(account, target_account)
-    clear_conversations(account, target_account)
+    @account        = account
+    @target_account = target_account
+
+    clear_home_feed!
+    clear_notifications!
+    clear_conversations!
   end
 
   private
 
-  def clear_home_feed(account, target_account)
-    FeedManager.instance.clear_from_timeline(account, target_account)
+  def clear_home_feed!
+    FeedManager.instance.clear_from_timeline(@account, @target_account)
   end
 
-  def clear_conversations(account, target_account)
-    AccountConversation.where(account: account)
-                       .where('? = ANY(participant_account_ids)', target_account.id)
-                       .in_batches
-                       .destroy_all
+  def clear_conversations!
+    AccountConversation.where(account: @account).where('? = ANY(participant_account_ids)', @target_account.id).in_batches.destroy_all
   end
 
-  def clear_notifications(account, target_account)
-    Notification.where(account: account)
-                .joins(:follow)
-                .where(activity_type: 'Follow', follows: { account_id: target_account.id })
-                .delete_all
-
-    Notification.where(account: account)
-                .joins(mention: :status)
-                .where(activity_type: 'Mention', statuses: { account_id: target_account.id })
-                .delete_all
-
-    Notification.where(account: account)
-                .joins(:favourite)
-                .where(activity_type: 'Favourite', favourites: { account_id: target_account.id })
-                .delete_all
-
-    Notification.where(account: account)
-                .joins(:status)
-                .where(activity_type: 'Status', statuses: { account_id: target_account.id })
-                .delete_all
+  def clear_notifications!
+    Notification.where(account: @account).where(from_account: @target_account).in_batches.delete_all
   end
 end
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
index 4cfa22ab893604277313a921bc1c208de4092c2f..12e4fa8b441ccccac5dfd91a6422239421b87655 100644
--- a/app/services/backup_service.rb
+++ b/app/services/backup_service.rb
@@ -142,5 +142,7 @@ class BackupService < BaseService
         io.write(buffer)
       end
     end
+  rescue Errno::ENOENT
+    Rails.logger.warn "Could not backup file #{filename}: file not found"
   end
 end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index 9050a48585cea9cb18984661a91249cd5a35ab44..0d9a6eccda47e47ed190285c467b79e2964d7e36 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -8,7 +8,7 @@ class BlockService < BaseService
 
     UnfollowService.new.call(account, target_account) if account.following?(target_account)
     UnfollowService.new.call(target_account, account) if target_account.following?(account)
-    RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
+    RejectFollowService.new.call(target_account, account) if target_account.requested?(account)
 
     block = account.block!(target_account)
 
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 0305e2d621afc6f0ee1765480d312c5d37ba9ca4..17fbb9cb4fa3dc68af2e9993291d378aa676e4de 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -13,7 +13,7 @@ class FollowService < BaseService
     target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
 
     raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
-    raise Mastodon::NotPermittedError  if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved?
+    raise Mastodon::NotPermittedError  if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? || source_account.domain_blocking?(target_account.domain)
 
     if source_account.following?(target_account)
       # We're already following this account, but we'll call follow! again to
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index 57c9ccfe1b8c503fe5bbe44e9f47a87222b2c870..e557706da5c12a6d06798f8a77163b6188c0f2cd 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -48,7 +48,7 @@ class ResolveAccountService < BaseService
       return
     end
 
-    return if links_missing?
+    return if links_missing? || auto_suspend?
     return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
 
     RedisLock.acquire(lock_options) do |lock|
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index a5ce3dbd9b303d446fab39b6169cfcf4219774c5..5dd01da52582b3440aa675b3955b1c78b2d59a73 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -66,6 +66,7 @@ class SuspendAccountService < BaseService
       @account.user.destroy
     else
       @account.user.disable!
+      @account.user.invites.where(uses: 0).destroy_all
     end
   end
 
diff --git a/app/validators/domain_validator.rb b/app/validators/domain_validator.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ae07f17980b1c894e52b78025c8c1d86112d44c1
--- /dev/null
+++ b/app/validators/domain_validator.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+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)
+  end
+
+  private
+
+  def compliant?(value)
+    Addressable::URI.new.tap { |uri| uri.host = value }
+  rescue Addressable::URI::InvalidURIError
+    false
+  end
+end
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index 950e618477b8b006456da96f472ae2cbb501e9f3..8f8647536b1969ce81b8580efc92ae35a351e77f 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -5,7 +5,7 @@
   %meta{ name: 'description', content: account_description(@account) }/
 
   - if @account.user&.setting_noindex
-    %meta{ name: 'robots', content: 'noindex' }/
+    %meta{ name: 'robots', content: 'noindex, noarchive' }/
 
   %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/
   %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
@@ -75,7 +75,7 @@
               - if featured_tag.last_status_at.nil?
                 = t('accounts.nothing_here')
               - else
-                %time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
+                %time.formatted{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
           .trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true
 
     = render 'application/sidebar'
diff --git a/app/views/statuses/show.html.haml b/app/views/statuses/show.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..0f22d106b560c12a79a8f2cace6da89d93dfe445
--- /dev/null
+++ b/app/views/statuses/show.html.haml
@@ -0,0 +1,24 @@
+- content_for :page_title do
+  = t('statuses.title', name: display_name(@account), quote: truncate(@status.spoiler_text.presence || @status.text, length: 50, omission: '…', escape: false))
+
+- content_for :header_tags do
+  - if @account.user&.setting_noindex
+    %meta{ name: 'robots', content: 'noindex, noarchive' }/
+
+  %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: short_account_status_url(@account, @status), format: 'json') }/
+  %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@status) }/
+
+  = opengraph 'og:site_name', site_title
+  = opengraph 'og:type', 'article'
+  = opengraph 'og:title', "#{display_name(@account)} (@#{@account.local_username_and_domain})"
+  = opengraph 'og:url', short_account_status_url(@account, @status)
+
+  = render 'og_description', activity: @status
+  = render 'og_image', activity: @status, account: @account
+
+.grid
+  .column-0
+    .activity-stream.h-entry
+      = render partial: 'status', locals: { status: @status, include_threads: true }
+  .column-1
+    = render 'application/sidebar'
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index 0e81c4f6853f6756772246d043d3855174bad39c..1a6567512493a6f6f678e9fbf2aba884c4aeba4d 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -3,7 +3,7 @@
 
 - content_for :header_tags do
   - if @account.user&.setting_noindex
-    %meta{ name: 'robots', content: 'noindex' }/
+    %meta{ name: 'robots', content: 'noindex, noarchive' }/
 
   %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/
   %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
index 5e4c391f0d79977ad2d9fb0887f2515345ef0692..00b5c6b7e7fac89174b41afa455236b930669932 100644
--- a/app/workers/activitypub/delivery_worker.rb
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -51,7 +51,7 @@ class ActivityPub::DeliveryWorker
   end
 
   def response_error_unsalvageable?(response)
-    (400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)
+    response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
   end
 
   def failure_tracker
diff --git a/app/workers/web/push_notification_worker.rb b/app/workers/web/push_notification_worker.rb
index 8e8a359735674ea753a6b0fed60b5ab71f90cf9a..90104397581aacdcf9f21c58f518c1849bc2c6e0 100644
--- a/app/workers/web/push_notification_worker.rb
+++ b/app/workers/web/push_notification_worker.rb
@@ -3,7 +3,7 @@
 class Web::PushNotificationWorker
   include Sidekiq::Worker
 
-  sidekiq_options backtrace: true
+  sidekiq_options backtrace: true, retry: 5
 
   def perform(subscription_id, notification_id)
     subscription = ::Web::PushSubscription.find(subscription_id)
diff --git a/config/application.rb b/config/application.rb
index 4534ede497646d19ab48b1ca314a939a2e10b879..f49deffbb6d4e14b78c5c10ff5087fe4a2e403e6 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -114,6 +114,9 @@ module Mastodon
       Doorkeeper::AuthorizationsController.layout 'modal'
       Doorkeeper::AuthorizedApplicationsController.layout 'admin'
       Doorkeeper::Application.send :include, ApplicationExtension
+      Devise::FailureApp.send :include, AbstractController::Callbacks
+      Devise::FailureApp.send :include, HttpAcceptLanguage::EasyAccess
+      Devise::FailureApp.send :include, Localized
     end
   end
 end
diff --git a/config/initializers/instrumentation.rb b/config/initializers/instrumentation.rb
deleted file mode 100644
index 8483f2be2e4caf502fe10dc12298eb32b514442d..0000000000000000000000000000000000000000
--- a/config/initializers/instrumentation.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-instrumentation_hostname = ENV.fetch('INSTRUMENTATION_HOSTNAME') { 'localhost' }
-
-ActiveSupport::Notifications.subscribe(/process_action.action_controller/) do |*args|
-  event      = ActiveSupport::Notifications::Event.new(*args)
-  controller = event.payload[:controller]
-  action     = event.payload[:action]
-  format     = event.payload[:format] || 'all'
-  format     = 'all' if format == '*/*'
-  status     = event.payload[:status]
-  key        = "#{controller}.#{action}.#{format}.#{instrumentation_hostname}"
-
-  ActiveSupport::Notifications.instrument :performance, action: :measure, measurement: "#{key}.total_duration", value: event.duration
-  ActiveSupport::Notifications.instrument :performance, action: :measure, measurement: "#{key}.db_time", value: event.payload[:db_runtime]
-  ActiveSupport::Notifications.instrument :performance, action: :measure, measurement: "#{key}.view_time", value: event.payload[:view_runtime]
-  ActiveSupport::Notifications.instrument :performance, measurement: "#{key}.status.#{status}"
-end
diff --git a/config/initializers/statsd.rb b/config/initializers/statsd.rb
index ce83fd9de2d484aec51dbcc223081d427559a3de..93ea1d1e4a7d1fbacb2300da4427b7cc2098ab3b 100644
--- a/config/initializers/statsd.rb
+++ b/config/initializers/statsd.rb
@@ -3,10 +3,10 @@
 if ENV['STATSD_ADDR'].present?
   host, port = ENV['STATSD_ADDR'].split(':')
 
-  statsd = ::Statsd.new(host, port)
-  statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }
+  $statsd = ::Statsd.new(host, port)
+  $statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }
 
-  ::NSA.inform_statsd(statsd) do |informant|
+  ::NSA.inform_statsd($statsd) do |informant|
     informant.collect(:action_controller, :web)
     informant.collect(:active_record, :db)
     informant.collect(:active_support_cache, :cache)
diff --git a/config/locales/en.yml b/config/locales/en.yml
index eadbde44363176075a7684c2f95f829a12690b31..6fe82a412e1b2b09b9c664cecbca279e7bd24a9e 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -588,6 +588,8 @@ en:
     people:
       one: "%{count} person"
       other: "%{count} people"
+  domain_validator:
+    invalid_domain: is not a valid domain name
   errors:
     '403': You don't have permission to view this page.
     '404': The page you are looking for isn't here.
diff --git a/config/puma.rb b/config/puma.rb
index 1afdb1c6dfbc0900051690c33a2013482398132a..6a96867d54d27e04f2090bc4191131da39b8a76c 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -2,9 +2,9 @@ threads_count = ENV.fetch('MAX_THREADS') { 5 }.to_i
 threads threads_count, threads_count
 
 if ENV['SOCKET']
-  bind 'unix://' + ENV['SOCKET']
+  bind "unix://#{ENV['SOCKET']}"
 else
-  port ENV.fetch('PORT') { 3000 }
+  bind "tcp://#{ENV.fetch('BIND', '127.0.0.1')}:#{ENV.fetch('PORT', 3000)}"
 end
 
 environment ENV.fetch('RAILS_ENV') { 'development' }
diff --git a/docker-compose.yml b/docker-compose.yml
index 93d47f1a0102702e108cf39c00f27f6b77d97ad4..7406849663e61280d44fcbfda16176692eaaee65 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -38,7 +38,7 @@ services:
     image: tootsuite/mastodon
     restart: always
     env_file: .env.production
-    command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000 -b '0.0.0.0'"
+    command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
     networks:
       - external_network
       - internal_network
@@ -58,7 +58,7 @@ services:
     image: tootsuite/mastodon
     restart: always
     env_file: .env.production
-    command: yarn start
+    command: node ./streaming
     networks:
       - external_network
       - internal_network
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 47eac2432b0278119411d2c21c945f9e8303efbe..99d709c980da5b516829314b91c727e0181c4de1 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -13,23 +13,23 @@ module Mastodon
     end
 
     def patch
-      2
+      3
     end
 
-    def pre
-      nil
+    def flags
+      ''
     end
 
-    def flags
+    def suffix
       ''
     end
 
     def to_a
-      [major, minor, patch, pre].compact
+      [major, minor, patch].compact
     end
 
     def to_s
-      [to_a.join('.'), flags].join
+      [to_a.join('.'), flags, suffix].join
     end
 
     def repository
diff --git a/spec/controllers/concerns/localized_spec.rb b/spec/controllers/concerns/localized_spec.rb
index 76c3de1183d430093dc76038e9e728392c8c4fda..7635d10e19f9dc384d2d922e7b731e070dee0f52 100644
--- a/spec/controllers/concerns/localized_spec.rb
+++ b/spec/controllers/concerns/localized_spec.rb
@@ -7,16 +7,10 @@ describe ApplicationController, type: :controller do
     include Localized
 
     def success
-      head 200
+      render plain: I18n.locale, status: 200
     end
   end
 
-  around do |example|
-    current_locale = I18n.locale
-    example.run
-    I18n.locale = current_locale
-  end
-
   before do
     routes.draw { get 'success' => 'anonymous#success' }
   end
@@ -25,19 +19,19 @@ describe ApplicationController, type: :controller do
     it 'sets available and preferred language' do
       request.headers['Accept-Language'] = 'ca-ES, fa'
       get 'success'
-      expect(I18n.locale).to eq :fa
+      expect(response.body).to eq 'fa'
     end
 
     it 'sets available and compatible language if none of available languages are preferred' do
       request.headers['Accept-Language'] = 'fa-IR'
       get 'success'
-      expect(I18n.locale).to eq :fa
+      expect(response.body).to eq 'fa'
     end
 
     it 'sets default locale if none of available languages are compatible' do
       request.headers['Accept-Language'] = ''
       get 'success'
-      expect(I18n.locale).to eq :en
+      expect(response.body).to eq 'en'
     end
   end
 
@@ -48,7 +42,7 @@ describe ApplicationController, type: :controller do
       sign_in(user)
       get 'success'
 
-      expect(I18n.locale).to eq :ca
+      expect(response.body).to eq 'ca'
     end
   end
 
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 5f8eb86a8040b0573a73001278ed855f52296422..b996997b1367e51bff7b83bfc15ea47085122772 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -149,6 +149,14 @@ RSpec.describe FeedManager do
           status = Fabricate(:status, text: 'shiitake', account: jeff)
           expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
         end
+
+        it 'returns true if phrase is contained in a poll option' do
+          alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true)
+          alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
+          alice.follow!(jeff)
+          status = Fabricate(:status, text: 'what do you prefer', poll: Fabricate(:poll, options: %w(farts POP TARts)), account: jeff)
+          expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+        end
       end
     end
 
@@ -239,6 +247,23 @@ RSpec.describe FeedManager do
         expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false
       end
 
+      it 'saves a new reblog of a recently-reblogged status when previous reblog has been deleted' do
+        account = Fabricate(:account)
+        reblogged = Fabricate(:status)
+        old_reblog = Fabricate(:status, reblog: reblogged)
+
+        # The first reblog should be accepted
+        expect(FeedManager.instance.push_to_home(account, old_reblog)).to be true
+
+        # The first reblog should be successfully removed
+        expect(FeedManager.instance.unpush_from_home(account, old_reblog)).to be true
+
+        reblog = Fabricate(:status, reblog: reblogged)
+
+        # The second reblog should be accepted
+        expect(FeedManager.instance.push_to_home(account, reblog)).to be true
+      end
+
       it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do
         account   = Fabricate(:account)
         reblogged = Fabricate(:status)
diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb
index 96d2fc7e06c247dd15d6643960bc2bfb015605f2..b8108a247716ced195b017e3aac3ffb2403ee37c 100644
--- a/spec/lib/formatter_spec.rb
+++ b/spec/lib/formatter_spec.rb
@@ -261,7 +261,7 @@ RSpec.describe Formatter do
       let(:text) { ':coolcat: Beep boop' }
 
       it 'converts the shortcode to an image tag' do
-        is_expected.to match(/<img draggable="false" class="emojione" alt=":coolcat:"/)
+        is_expected.to match(/<img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
       end
     end
   end
@@ -330,7 +330,7 @@ RSpec.describe Formatter do
           let(:text) { ':coolcat: Beep boop' }
 
           it 'converts the shortcode to an image tag' do
-            is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
 
@@ -338,7 +338,7 @@ RSpec.describe Formatter do
           let(:text) { 'Beep :coolcat: boop' }
 
           it 'converts the shortcode to an image tag' do
-            is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
 
@@ -354,7 +354,7 @@ RSpec.describe Formatter do
           let(:text) { 'Beep boop :coolcat:' }
 
           it 'converts the shortcode to an image tag' do
-            is_expected.to match(/boop <img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
       end
@@ -377,7 +377,7 @@ RSpec.describe Formatter do
           let(:text) { '<p>:coolcat: Beep boop<br />' }
 
           it 'converts the shortcode to an image tag' do
-            is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
 
@@ -385,7 +385,7 @@ RSpec.describe Formatter do
           let(:text) { '<p>Beep :coolcat: boop</p>' }
 
           it 'converts the shortcode to an image tag' do
-            is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
 
@@ -401,7 +401,7 @@ RSpec.describe Formatter do
           let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
 
           it 'converts the shortcode to an image tag' do
-            is_expected.to match(/<br><img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
       end
@@ -500,7 +500,7 @@ RSpec.describe Formatter do
           let(:text) { ':coolcat: Beep boop' }
 
           it 'converts the shortcode to an image tag' do
-            is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
 
@@ -508,7 +508,7 @@ RSpec.describe Formatter do
           let(:text) { 'Beep :coolcat: boop' }
 
           it 'converts the shortcode to an image tag' do
-            is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
 
@@ -524,7 +524,7 @@ RSpec.describe Formatter do
           let(:text) { 'Beep boop :coolcat:' }
 
           it 'converts the shortcode to an image tag' do
-            is_expected.to match(/boop <img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
       end
@@ -551,7 +551,7 @@ RSpec.describe Formatter do
           let(:text) { '<p>:coolcat: Beep boop<br />' }
 
           it 'converts shortcode to image tag' do
-            is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
 
@@ -559,7 +559,7 @@ RSpec.describe Formatter do
           let(:text) { '<p>Beep :coolcat: boop</p>' }
 
           it 'converts shortcode to image tag' do
-            is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
 
@@ -575,7 +575,7 @@ RSpec.describe Formatter do
           let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
 
           it 'converts shortcode to image tag' do
-            is_expected.to match(/<br><img draggable="false" class="emojione" alt=":coolcat:"/)
+            is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
           end
         end
       end
diff --git a/spec/lib/language_detector_spec.rb b/spec/lib/language_detector_spec.rb
index 0cb70605ad411536b59c366cb4b8c7f25784c89a..b7ba0f6c4f4ac9e924176b40991d8f73e7739668 100644
--- a/spec/lib/language_detector_spec.rb
+++ b/spec/lib/language_detector_spec.rb
@@ -32,11 +32,11 @@ describe LanguageDetector do
       expect(result).to eq 'Our website is and also'
     end
 
-    it 'strips #hashtags from strings before detection' do
-      string = 'Hey look at all the #animals and #fish'
+    it 'converts #hashtags back to normal text before detection' do
+      string = 'Hey look at all the #animals and #FishAndChips'
 
       result = described_class.instance.send(:prepare_text, string)
-      expect(result).to eq 'Hey look at all the and'
+      expect(result).to eq 'Hey look at all the animals and fish and chips'
     end
   end
 
diff --git a/spec/lib/sanitize_config_spec.rb b/spec/lib/sanitize_config_spec.rb
index bb3cf6f0b238a2c324110a8ae3953a2309d88139..54bd8693cc871103b6f45026c536843701019a3f 100644
--- a/spec/lib/sanitize_config_spec.rb
+++ b/spec/lib/sanitize_config_spec.rb
@@ -22,5 +22,9 @@ describe Sanitize::Config do
     it 'converts ul inside ul' do
       expect(Sanitize.fragment('<ul><li>Foo</li><li><ul><li>Bar</li><li>Baz</li></ul></li></ul>', subject)).to eq '<p>Foo<br>Bar<br>Baz</p>'
     end
+
+    it 'keep links in lists' do
+      expect(Sanitize.fragment('<p>Check out:</p><ul><li><a href="https://joinmastodon.org" rel="nofollow noopener" target="_blank">joinmastodon.org</a></li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><p><a href="https://joinmastodon.org" rel="nofollow noopener" target="_blank">joinmastodon.org</a><br>Bar</p>'
+    end
   end
 end
diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb
index 0ba1dccb33c218b892c60c76878dbccb7fe986be..30abfb86bf4d882dd13add846b47c0e4408c4676 100644
--- a/spec/models/invite_spec.rb
+++ b/spec/models/invite_spec.rb
@@ -3,27 +3,33 @@ require 'rails_helper'
 RSpec.describe Invite, type: :model do
   describe '#valid_for_use?' do
     it 'returns true when there are no limitations' do
-      invite = Invite.new(max_uses: nil, expires_at: nil)
+      invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
       expect(invite.valid_for_use?).to be true
     end
 
     it 'returns true when not expired' do
-      invite = Invite.new(max_uses: nil, expires_at: 1.hour.from_now)
+      invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now)
       expect(invite.valid_for_use?).to be true
     end
 
     it 'returns false when expired' do
-      invite = Invite.new(max_uses: nil, expires_at: 1.hour.ago)
+      invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.ago)
       expect(invite.valid_for_use?).to be false
     end
 
     it 'returns true when uses still available' do
-      invite = Invite.new(max_uses: 250, uses: 249, expires_at: nil)
+      invite = Fabricate(:invite, max_uses: 250, uses: 249, expires_at: nil)
       expect(invite.valid_for_use?).to be true
     end
 
     it 'returns false when maximum uses reached' do
-      invite = Invite.new(max_uses: 250, uses: 250, expires_at: nil)
+      invite = Fabricate(:invite, max_uses: 250, uses: 250, expires_at: nil)
+      expect(invite.valid_for_use?).to be false
+    end
+
+    it 'returns false when invite creator has been disabled' do
+      invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
+      SuspendAccountService.new.call(invite.user.account)
       expect(invite.valid_for_use?).to be false
     end
   end
diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb
index 1ca50cc29f69b2d386deb5e776d18ae3371c6e00..9a30ceaa527d34e23d354b9467d1262fa5c147d0 100644
--- a/spec/models/tag_spec.rb
+++ b/spec/models/tag_spec.rb
@@ -31,7 +31,47 @@ RSpec.describe Tag, type: :model do
     end
 
     it 'matches #aesthetic' do
-      expect(subject.match('this is #aesthetic')).to_not be_nil
+      expect(subject.match('this is #aesthetic').to_s).to eq ' #aesthetic'
+    end
+
+    it 'matches digits at the start' do
+      expect(subject.match('hello #3d').to_s).to eq ' #3d'
+    end
+
+    it 'matches digits in the middle' do
+      expect(subject.match('hello #l33ts35k').to_s).to eq ' #l33ts35k'
+    end
+
+    it 'matches digits at the end' do
+      expect(subject.match('hello #world2016').to_s).to eq ' #world2016'
+    end
+
+    it 'matches underscores at the beginning' do
+      expect(subject.match('hello #_test').to_s).to eq ' #_test'
+    end
+
+    it 'matches underscores at the end' do
+      expect(subject.match('hello #test_').to_s).to eq ' #test_'
+    end
+
+    it 'matches underscores in the middle' do
+      expect(subject.match('hello #one_two_three').to_s).to eq ' #one_two_three'
+    end
+
+    it 'matches middle dots' do
+      expect(subject.match('hello #one·two·three').to_s).to eq ' #one·two·three'
+    end
+
+    it 'does not match middle dots at the start' do
+      expect(subject.match('hello #·one·two·three')).to be_nil
+    end
+
+    it 'does not match middle dots at the end' do
+      expect(subject.match('hello #one·two·three·').to_s).to eq ' #one·two·three'
+    end
+
+    it 'does not match purely-numeric hashtags' do
+      expect(subject.match('hello #0123456')).to be_nil
     end
   end
 
diff --git a/spec/serializers/activitypub/update_poll_spec.rb b/spec/serializers/activitypub/update_poll_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f9e035eab581aa654cf1b9c14aa3697fe9759ca7
--- /dev/null
+++ b/spec/serializers/activitypub/update_poll_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ActivityPub::UpdatePollSerializer do
+  let(:account) { Fabricate(:account) }
+  let(:poll)    { Fabricate(:poll, account: account) }
+  let!(:status) { Fabricate(:status, account: account, poll: poll) }
+
+  before(:each) do
+    @serialization = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::UpdatePollSerializer, adapter: ActivityPub::Adapter)
+  end
+
+  subject { JSON.parse(@serialization.to_json) }
+
+  it 'has a Update type' do
+    expect(subject['type']).to eql('Update')
+  end
+
+  it 'has an object with Question type' do
+    expect(subject['object']['type']).to eql('Question')
+  end
+
+  it 'has the correct actor URI set' do
+    expect(subject['actor']).to eql(ActivityPub::TagManager.instance.uri_for(account))
+  end
+end
diff --git a/spec/workers/activitypub/distribute_poll_update_worker_spec.rb b/spec/workers/activitypub/distribute_poll_update_worker_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7eb6119fd8091f791729952d0450e816cf4dfa67
--- /dev/null
+++ b/spec/workers/activitypub/distribute_poll_update_worker_spec.rb
@@ -0,0 +1,22 @@
+require 'rails_helper'
+
+describe ActivityPub::DistributePollUpdateWorker do
+  subject { described_class.new }
+
+  let(:account)  { Fabricate(:account) }
+  let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') }
+  let(:poll)     { Fabricate(:poll, account: account) }
+  let!(:status)  { Fabricate(:status, account: account, poll: poll) }
+
+  describe '#perform' do
+    before do
+      allow(ActivityPub::DeliveryWorker).to receive(:push_bulk)
+      follower.follow!(account)
+    end
+
+    it 'delivers to followers' do
+      subject.perform(status.id)
+      expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
+    end
+  end
+end
diff --git a/streaming/index.js b/streaming/index.js
index 10df210a3dd5792ec4df33415f548f92916033ff..68e19621aed813d12e3e5e3b025823f746bd25db 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -678,7 +678,7 @@ const attachServerWithConfig = (server, onSuccess) => {
       }
     });
   } else {
-    server.listen(+process.env.PORT || 4000, process.env.BIND || '0.0.0.0', () => {
+    server.listen(+process.env.PORT || 4000, process.env.BIND || '127.0.0.1', () => {
       if (onSuccess) {
         onSuccess(`${server.address().address}:${server.address().port}`);
       }