about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock4
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb5
-rw-r--r--app/controllers/admin/instances_controller.rb44
-rw-r--r--app/controllers/api/base_controller.rb2
-rw-r--r--app/controllers/api/v1/instances/peers_controller.rb2
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/helpers/statuses_helper.rb20
-rw-r--r--app/javascript/core/admin.js28
-rw-r--r--app/javascript/mastodon/components/autosuggest_input.js8
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js8
-rw-r--r--app/javascript/mastodon/components/status_content.js18
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js10
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/reply_indicator.js6
-rw-r--r--app/javascript/mastodon/rtl.js32
-rw-r--r--app/javascript/styles/mailer.scss12
-rw-r--r--app/javascript/styles/mastodon/components.scss2
-rw-r--r--app/javascript/styles/mastodon/forms.scss15
-rw-r--r--app/lib/activitypub/activity/create.rb4
-rw-r--r--app/models/account.rb6
-rw-r--r--app/models/concerns/domain_materializable.rb13
-rw-r--r--app/models/domain_allow.rb1
-rw-r--r--app/models/domain_block.rb1
-rw-r--r--app/models/form/admin_settings.rb2
-rw-r--r--app/models/instance.rb63
-rw-r--r--app/models/instance_filter.rb31
-rw-r--r--app/models/report.rb1
-rw-r--r--app/models/unavailable_domain.rb2
-rw-r--r--app/models/user.rb3
-rw-r--r--app/policies/domain_block_policy.rb4
-rw-r--r--app/presenters/instance_presenter.rb2
-rw-r--r--app/serializers/manifest_serializer.rb40
-rw-r--r--app/services/report_service.rb3
-rw-r--r--app/views/about/_registration.html.haml2
-rw-r--r--app/views/about/more.html.haml4
-rw-r--r--app/views/admin/accounts/show.html.haml10
-rw-r--r--app/views/admin/instances/_instance.html.haml25
-rw-r--r--app/views/admin/instances/index.html.haml36
-rw-r--r--app/views/admin/instances/show.html.haml50
-rw-r--r--app/views/admin/reports/index.html.haml4
-rw-r--r--app/views/admin/reports/show.html.haml10
-rw-r--r--app/views/admin/settings/edit.html.haml6
-rw-r--r--app/views/auth/registrations/new.html.haml2
-rw-r--r--app/views/notification_mailer/_status.html.haml4
-rw-r--r--app/views/statuses/_detailed_status.html.haml2
-rw-r--r--app/views/statuses/_simple_status.html.haml2
-rw-r--r--app/workers/scheduler/instance_refresh_scheduler.rb11
-rw-r--r--boxfile.yml1
-rw-r--r--config/brakeman.ignore101
-rw-r--r--config/initializers/paperclip.rb11
-rw-r--r--config/locales/en.yml9
-rw-r--r--config/settings.yml1
-rw-r--r--config/sidekiq.yml17
-rw-r--r--db/migrate/20200309150742_add_forwarded_to_reports.rb5
-rw-r--r--db/migrate/20201206004238_create_instances.rb9
-rw-r--r--db/schema.rb28
-rw-r--r--db/views/instances_v01.sql17
-rw-r--r--lib/mastodon/domains_cli.rb4
-rw-r--r--lib/mastodon/maintenance_cli.rb2
-rw-r--r--lib/paperclip/attachment_extensions.rb17
-rw-r--r--public/shortcuts/direct.pngbin0 -> 3045 bytes
-rw-r--r--public/shortcuts/new-status.pngbin0 -> 3031 bytes
-rw-r--r--public/shortcuts/notifications.pngbin0 -> 3067 bytes
-rw-r--r--public/shortcuts/profile.pngbin0 -> 4190 bytes
-rw-r--r--spec/controllers/admin/instances_controller_spec.rb6
-rw-r--r--spec/helpers/statuses_helper_spec.rb18
-rw-r--r--spec/models/account_spec.rb21
-rw-r--r--yarn.lock6
69 files changed, 548 insertions, 292 deletions
diff --git a/Gemfile b/Gemfile
index 32ebf0dd1..f9eddd932 100644
--- a/Gemfile
+++ b/Gemfile
@@ -82,6 +82,7 @@ gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
 gem 'rqrcode', '~> 1.1'
 gem 'ruby-progressbar', '~> 1.10'
 gem 'sanitize', '~> 5.2'
+gem 'scenic', '~> 1.5'
 gem 'sidekiq', '~> 6.1'
 gem 'sidekiq-scheduler', '~> 3.0'
 gem 'sidekiq-unique-jobs', '~> 6.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index f5d8d20c1..a356029a6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -562,6 +562,9 @@ GEM
       crass (~> 1.0.2)
       nokogiri (>= 1.8.0)
       nokogumbo (~> 2.0)
+    scenic (1.5.4)
+      activerecord (>= 4.0.0)
+      railties (>= 4.0.0)
     securecompare (1.0.0)
     semantic_range (2.3.0)
     sidekiq (6.1.2)
@@ -784,6 +787,7 @@ DEPENDENCIES
   rubocop-rails (~> 2.8)
   ruby-progressbar (~> 1.10)
   sanitize (~> 5.2)
+  scenic (~> 1.5)
   sidekiq (~> 6.1)
   sidekiq-bulk (~> 0.2.0)
   sidekiq-scheduler (~> 3.0)
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index 74a36b79c..6a5b41a74 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -29,6 +29,7 @@ module Admin
           @domain_block = existing_domain_block
           @domain_block.update(resource_params)
         end
+
         if @domain_block.save
           DomainBlockWorker.perform_async(@domain_block.id)
           log_action :create, @domain_block
@@ -40,7 +41,7 @@ module Admin
     end
 
     def update
-      authorize :domain_block, :create?
+      authorize :domain_block, :update?
 
       @domain_block.update(update_params)
 
@@ -48,7 +49,7 @@ module Admin
 
       if @domain_block.save
         DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
-        log_action :create, @domain_block
+        log_action :update, @domain_block
         redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
       else
         render :edit
diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb
index 1790becbf..b5918d231 100644
--- a/app/controllers/admin/instances_controller.rb
+++ b/app/controllers/admin/instances_controller.rb
@@ -2,65 +2,31 @@
 
 module Admin
   class InstancesController < BaseController
-    before_action :set_domain_block, only: :show
-    before_action :set_domain_allow, only: :show
+    before_action :set_instances, only: :index
     before_action :set_instance, only: :show
 
     def index
       authorize :instance, :index?
-
-      @instances = ordered_instances
     end
 
     def show
       authorize :instance, :show?
-
-      @following_count = Follow.where(account: Account.where(domain: params[:id])).count
-      @followers_count = Follow.where(target_account: Account.where(domain: params[:id])).count
-      @reports_count   = Report.where(target_account: Account.where(domain: params[:id])).count
-      @blocks_count    = Block.where(target_account: Account.where(domain: params[:id])).count
-      @available       = DeliveryFailureTracker.available?(params[:id])
-      @media_storage   = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size)
-      @private_comment = @domain_block&.private_comment
-      @public_comment  = @domain_block&.public_comment
     end
 
     private
 
-    def set_domain_block
-      @domain_block = DomainBlock.rule_for(params[:id])
-    end
-
-    def set_domain_allow
-      @domain_allow = DomainAllow.rule_for(params[:id])
-    end
-
     def set_instance
-      resource   = Account.by_domain_accounts.find_by(domain: params[:id])
-      resource ||= @domain_block
-      resource ||= @domain_allow
+      @instance = Instance.find(params[:id])
+    end
 
-      if resource
-        @instance = Instance.new(resource)
-      else
-        not_found
-      end
+    def set_instances
+      @instances = filtered_instances.page(params[:page])
     end
 
     def filtered_instances
       InstanceFilter.new(whitelist_mode? ? { allowed: true } : filter_params).results
     end
 
-    def paginated_instances
-      filtered_instances.page(params[:page])
-    end
-
-    helper_method :paginated_instances
-
-    def ordered_instances
-      paginated_instances.map { |resource| Instance.new(resource) }
-    end
-
     def filter_params
       params.slice(*InstanceFilter::KEYS).permit(*InstanceFilter::KEYS)
     end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index fe199e689..85f4cc768 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -40,7 +40,7 @@ class Api::BaseController < ApplicationController
     render json: { error: 'This action is not allowed' }, status: 403
   end
 
-  rescue_from Mastodon::RaceConditionError do
+  rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight do
     render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
   end
 
diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb
index 9fa440935..2877fec52 100644
--- a/app/controllers/api/v1/instances/peers_controller.rb
+++ b/app/controllers/api/v1/instances/peers_controller.rb
@@ -8,7 +8,7 @@ class Api::V1::Instances::PeersController < Api::BaseController
 
   def index
     expires_in 1.day, public: true
-    render_with_cache(expires_in: 1.day) { Account.remote.domains }
+    render_with_cache(expires_in: 1.day) { Instance.where.not(domain: DomainBlock.select(:domain)).pluck(:domain) }
   end
 
   private
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e996c2217..41fe9d88a 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -29,7 +29,7 @@ class ApplicationController < ActionController::Base
   rescue_from ActiveRecord::RecordNotFound, with: :not_found
   rescue_from Mastodon::NotPermittedError, with: :forbidden
   rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
-  rescue_from Mastodon::RaceConditionError, with: :service_unavailable
+  rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight, with: :service_unavailable
   rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb
index daed9048f..1f654f34f 100644
--- a/app/helpers/statuses_helper.rb
+++ b/app/helpers/statuses_helper.rb
@@ -92,22 +92,6 @@ module StatusesHelper
     end
   end
 
-  def rtl_status?(status)
-    status.local? ? rtl?(status.text) : rtl?(strip_tags(status.text))
-  end
-
-  def rtl?(text)
-    text = simplified_text(text)
-    rtl_words = text.scan(/[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}]+/m)
-
-    if rtl_words.present?
-      total_size = text.size.to_f
-      rtl_size(rtl_words) / total_size > 0.3
-    else
-      false
-    end
-  end
-
   def fa_visibility_icon(status)
     case status.visibility
     when 'public'
@@ -143,10 +127,6 @@ module StatusesHelper
     end
   end
 
-  def rtl_size(words)
-    words.reduce(0) { |acc, elem| acc + elem.size }.to_f
-  end
-
   def embedded_view?
     params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION
   end
diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js
index bbc7cfac7..d2db89ca7 100644
--- a/app/javascript/core/admin.js
+++ b/app/javascript/core/admin.js
@@ -59,18 +59,46 @@ const onEnableBootstrapTimelineAccountsChange = (target) => {
     bootstrapTimelineAccountsField.disabled = !target.checked;
     if (target.checked) {
       bootstrapTimelineAccountsField.parentElement.classList.remove('disabled');
+      bootstrapTimelineAccountsField.parentElement.parentElement.classList.remove('disabled');
     } else {
       bootstrapTimelineAccountsField.parentElement.classList.add('disabled');
+      bootstrapTimelineAccountsField.parentElement.parentElement.classList.add('disabled');
     }
   }
 };
 
 delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target));
 
+const onChangeRegistrationMode = (target) => {
+  const enabled = target.value === 'approved';
+
+  [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => {
+    input.disabled = !enabled;
+    if (enabled) {
+      let element = input;
+      do {
+        element.classList.remove('disabled');
+        element = element.parentElement;
+      } while (element && !element.classList.contains('fields-group'));
+    } else {
+      let element = input;
+      do {
+        element.classList.add('disabled');
+        element = element.parentElement;
+      } while (element && !element.classList.contains('fields-group'));
+    }
+  });
+};
+
+delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target));
+
 ready(() => {
   const domainBlockSeverityInput = document.getElementById('domain_block_severity');
   if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput);
 
   const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts');
   if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
+
+  const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
+  if (registrationMode) onChangeRegistrationMode(registrationMode);
 });
diff --git a/app/javascript/mastodon/components/autosuggest_input.js b/app/javascript/mastodon/components/autosuggest_input.js
index 6d2035add..5187f95c8 100644
--- a/app/javascript/mastodon/components/autosuggest_input.js
+++ b/app/javascript/mastodon/components/autosuggest_input.js
@@ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji';
 import AutosuggestHashtag from './autosuggest_hashtag';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { isRtl } from '../rtl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import classNames from 'classnames';
 import { List as ImmutableList } from 'immutable';
@@ -189,11 +188,6 @@ export default class AutosuggestInput extends ImmutablePureComponent {
   render () {
     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
     const { suggestionsHidden } = this.state;
-    const style = { direction: 'ltr' };
-
-    if (isRtl(value)) {
-      style.direction = 'rtl';
-    }
 
     return (
       <div className='autosuggest-input'>
@@ -212,7 +206,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
             onKeyUp={onKeyUp}
             onFocus={this.onFocus}
             onBlur={this.onBlur}
-            style={style}
+            dir='auto'
             aria-autocomplete='list'
             id={id}
             className={className}
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
index 58ec4f6eb..08b9cd80b 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.js
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji';
 import AutosuggestHashtag from './autosuggest_hashtag';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { isRtl } from '../rtl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Textarea from 'react-textarea-autosize';
 import classNames from 'classnames';
@@ -195,11 +194,6 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
   render () {
     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
     const { suggestionsHidden } = this.state;
-    const style = { direction: 'ltr' };
-
-    if (isRtl(value)) {
-      style.direction = 'rtl';
-    }
 
     return [
       <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
@@ -220,7 +214,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
               onFocus={this.onFocus}
               onBlur={this.onBlur}
               onPaste={this.onPaste}
-              style={style}
+              dir='auto'
               aria-autocomplete='list'
             />
           </label>
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 3200f2d82..185a2a663 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -1,7 +1,6 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { isRtl } from '../rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
 import classnames from 'classnames';
@@ -186,17 +185,12 @@ export default class StatusContent extends React.PureComponent {
 
     const content = { __html: status.get('contentHtml') };
     const spoilerContent = { __html: status.get('spoilerHtml') };
-    const directionStyle = { direction: 'ltr' };
     const classNames = classnames('status__content', {
       'status__content--with-action': this.props.onClick && this.context.router,
       'status__content--with-spoiler': status.get('spoiler_text').length > 0,
       'status__content--collapsed': renderReadMore,
     });
 
-    if (isRtl(status.get('search_index'))) {
-      directionStyle.direction = 'rtl';
-    }
-
     const showThreadButton = (
       <button className='status__content__read-more-button' onClick={this.props.onClick}>
         <FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
@@ -225,7 +219,7 @@ export default class StatusContent extends React.PureComponent {
       }
 
       return (
-        <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+        <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
             <span dangerouslySetInnerHTML={spoilerContent} />
             {' '}
@@ -234,7 +228,7 @@ export default class StatusContent extends React.PureComponent {
 
           {mentionsPlaceholder}
 
-          <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
+          <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} dangerouslySetInnerHTML={content} />
 
           {!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
 
@@ -243,8 +237,8 @@ export default class StatusContent extends React.PureComponent {
       );
     } else if (this.props.onClick) {
       const output = [
-        <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
-          <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
+        <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
+          <div className='status__content__text status__content__text--visible' dangerouslySetInnerHTML={content} />
 
           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
 
@@ -259,8 +253,8 @@ export default class StatusContent extends React.PureComponent {
       return output;
     } else {
       return (
-        <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
-          <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
+        <div className={classNames} ref={this.setRef} tabIndex='0'>
+          <div className='status__content__text status__content__text--visible' dangerouslySetInnerHTML={content} />
 
           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
 
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
index 597ca8af6..015a6a6d7 100644
--- a/app/javascript/mastodon/features/account_gallery/index.js
+++ b/app/javascript/mastodon/features/account_gallery/index.js
@@ -152,6 +152,14 @@ class AccountGallery extends ImmutablePureComponent {
       loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
     }
 
+    let emptyMessage;
+
+    if (suspended) {
+      emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
+    } else if (blockedBy) {
+      emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
+    }
+
     return (
       <Column>
         <ColumnBackButton multiColumn={multiColumn} />
@@ -162,7 +170,7 @@ class AccountGallery extends ImmutablePureComponent {
 
             {(suspended || blockedBy) ? (
               <div className='empty-column-indicator'>
-                <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
+                {emptyMessage}
               </div>
             ) : (
               <div role='feed' className='account-gallery__container' ref={this.handleRef}>
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index cbc859805..fa4239d6f 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -136,7 +136,9 @@ class AccountTimeline extends ImmutablePureComponent {
 
     let emptyMessage;
 
-    if (suspended || blockedBy) {
+    if (suspended) {
+      emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
+    } else if (blockedBy) {
       emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
     } else if (remote && statusIds.isEmpty()) {
       emptyMessage = <RemoteHint url={remoteUrl} />;
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
index 66dc85742..856383893 100644
--- a/app/javascript/mastodon/features/compose/components/reply_indicator.js
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js
@@ -6,7 +6,6 @@ import IconButton from '../../../components/icon_button';
 import DisplayName from '../../../components/display_name';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { isRtl } from '../../../rtl';
 import AttachmentList from 'mastodon/components/attachment_list';
 
 const messages = defineMessages({
@@ -45,9 +44,6 @@ class ReplyIndicator extends ImmutablePureComponent {
     }
 
     const content = { __html: status.get('contentHtml') };
-    const style   = {
-      direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
-    };
 
     return (
       <div className='reply-indicator'>
@@ -60,7 +56,7 @@ class ReplyIndicator extends ImmutablePureComponent {
           </a>
         </div>
 
-        <div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} />
+        <div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
 
         {status.get('media_attachments').size > 0 && (
           <AttachmentList
diff --git a/app/javascript/mastodon/rtl.js b/app/javascript/mastodon/rtl.js
deleted file mode 100644
index 89bed6de8..000000000
--- a/app/javascript/mastodon/rtl.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// U+0590  to U+05FF  - Hebrew
-// U+0600  to U+06FF  - Arabic
-// U+0700  to U+074F  - Syriac
-// U+0750  to U+077F  - Arabic Supplement
-// U+0780  to U+07BF  - Thaana
-// U+07C0  to U+07FF  - N'Ko
-// U+0800  to U+083F  - Samaritan
-// U+08A0  to U+08FF  - Arabic Extended-A
-// U+FB1D  to U+FB4F  - Hebrew presentation forms
-// U+FB50  to U+FDFF  - Arabic presentation forms A
-// U+FE70  to U+FEFF  - Arabic presentation forms B
-
-const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
-
-export function isRtl(text) {
-  if (text.length === 0) {
-    return false;
-  }
-
-  text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, '');
-  text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, '');
-  text = text.replace(/\s+/g, '');
-  text = text.replace(/(\w\S+\.\w{2,}\S*)/g, '');
-
-  const matches = text.match(rtlChars);
-
-  if (!matches) {
-    return false;
-  }
-
-  return matches.length / text.length > 0.3;
-};
diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss
index e25a80c04..55ebd3091 100644
--- a/app/javascript/styles/mailer.scss
+++ b/app/javascript/styles/mailer.scss
@@ -58,6 +58,16 @@ td {
   vertical-align: top;
 }
 
+.auto-dir {
+  p {
+    unicode-bidi: plaintext;
+  }
+
+  a {
+    unicode-bidi: isolate;
+  }
+}
+
 .email-table,
 .content-section,
 .column,
@@ -96,7 +106,7 @@ body {
 .col-3,
 .col-4,
 .col-5,
-.col-6, {
+.col-6 {
   font-size: 0;
   display: inline-block;
   width: 100%;
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index e0c33fb85..7113d51c5 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -831,6 +831,7 @@
   p {
     margin-bottom: 20px;
     white-space: pre-wrap;
+    unicode-bidi: plaintext;
 
     &:last-child {
       margin-bottom: 0;
@@ -840,6 +841,7 @@
   a {
     color: $secondary-text-color;
     text-decoration: none;
+    unicode-bidi: isolate;
 
     &:hover {
       text-decoration: underline;
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 92d89e6f2..e0604303b 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -377,11 +377,6 @@ code {
       box-shadow: none;
     }
 
-    &:focus:invalid:not(:placeholder-shown),
-    &:required:invalid:not(:placeholder-shown) {
-      border-color: lighten($error-red, 12%);
-    }
-
     &:required:valid {
       border-color: $valid-value-color;
     }
@@ -397,6 +392,16 @@ code {
     }
   }
 
+  input[type=text],
+  input[type=number],
+  input[type=email],
+  input[type=password] {
+    &:focus:invalid:not(:placeholder-shown),
+    &:required:invalid:not(:placeholder-shown) {
+      border-color: lighten($error-red, 12%);
+    }
+  }
+
   .input.field_with_errors {
     label {
       color: lighten($error-red, 12%);
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index d56d47a2d..b9d43d74d 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -228,6 +228,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
     emoji.image_remote_url = image_url
     emoji.save
+  rescue Seahorse::Client::NetworkingError
+    nil
   end
 
   def process_attachments
@@ -250,6 +252,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
         media_attachment.save
       rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
         RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
+      rescue Seahorse::Client::NetworkingError
+        nil
       end
     end
 
diff --git a/app/models/account.rb b/app/models/account.rb
index b70978d2b..b0e254233 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -67,6 +67,7 @@ class Account < ApplicationRecord
   include Paginable
   include AccountCounters
   include DomainNormalizable
+  include DomainMaterializable
   include AccountMerging
 
   MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
@@ -107,7 +108,6 @@ class Account < ApplicationRecord
   scope :bots, -> { where(actor_type: %w(Application Service)) }
   scope :groups, -> { where(actor_type: 'Group') }
   scope :alphabetic, -> { order(domain: :asc, username: :asc) }
-  scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
   scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
   scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
@@ -440,10 +440,6 @@ class Account < ApplicationRecord
       super - %w(statuses_count following_count followers_count)
     end
 
-    def domains
-      reorder(nil).pluck(Arel.sql('distinct accounts.domain'))
-    end
-
     def inboxes
       urls = reorder(nil).where(protocol: :activitypub).group(:preferred_inbox_url).pluck(Arel.sql("coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url) AS preferred_inbox_url"))
       DeliveryFailureTracker.without_unavailable(urls)
diff --git a/app/models/concerns/domain_materializable.rb b/app/models/concerns/domain_materializable.rb
new file mode 100644
index 000000000..88337f8c0
--- /dev/null
+++ b/app/models/concerns/domain_materializable.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module DomainMaterializable
+  extend ActiveSupport::Concern
+
+  included do
+    after_create_commit :refresh_instances_view
+  end
+
+  def refresh_instances_view
+    Instance.refresh unless domain.nil? || Instance.where(domain: domain).exists?
+  end
+end
diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb
index 5fe0e3a29..4b0a89c18 100644
--- a/app/models/domain_allow.rb
+++ b/app/models/domain_allow.rb
@@ -12,6 +12,7 @@
 
 class DomainAllow < ApplicationRecord
   include DomainNormalizable
+  include DomainMaterializable
 
   validates :domain, presence: true, uniqueness: true, domain: true
 
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 2b18e01fa..829d7583b 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -16,6 +16,7 @@
 
 class DomainBlock < ApplicationRecord
   include DomainNormalizable
+  include DomainMaterializable
 
   enum severity: [:silence, :suspend, :noop]
 
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index fcec3e686..999d835e6 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -42,6 +42,7 @@ class Form::AdminSettings
     show_domain_blocks_rationale
     noindex
     outgoing_spoilers
+    require_invite_text
   ).freeze
 
   BOOLEAN_KEYS = %i(
@@ -62,6 +63,7 @@ class Form::AdminSettings
     trends
     trendable_by_default
     noindex
+    require_invite_text
   ).freeze
 
   UPLOAD_KEYS = %i(
diff --git a/app/models/instance.rb b/app/models/instance.rb
index 3c740f8a2..29be03662 100644
--- a/app/models/instance.rb
+++ b/app/models/instance.rb
@@ -1,26 +1,63 @@
 # frozen_string_literal: true
+# == Schema Information
+#
+# Table name: instances
+#
+#  domain         :string           primary key
+#  accounts_count :bigint(8)
+#
 
-class Instance
-  include ActiveModel::Model
+class Instance < ApplicationRecord
+  self.primary_key = :domain
 
-  attr_accessor :domain, :accounts_count, :domain_block
+  has_many :accounts, foreign_key: :domain, primary_key: :domain
 
-  def initialize(resource)
-    @domain         = resource.domain
-    @accounts_count = resource.respond_to?(:accounts_count) ? resource.accounts_count : nil
-    @domain_block   = resource.is_a?(DomainBlock) ? resource : DomainBlock.rule_for(domain)
-    @domain_allow   = resource.is_a?(DomainAllow) ? resource : DomainAllow.rule_for(domain)
+  belongs_to :domain_block, foreign_key: :domain, primary_key: :domain
+  belongs_to :domain_allow, foreign_key: :domain, primary_key: :domain
+
+  scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
+
+  def self.refresh
+    Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false)
   end
 
-  def countable?
-    @accounts_count.present?
+  def readonly?
+    true
   end
 
-  def to_param
-    domain
+  def delivery_failure_tracker
+    @delivery_failure_tracker ||= DeliveryFailureTracker.new(domain)
+  end
+
+  def following_count
+    @following_count ||= Follow.where(account: accounts).count
+  end
+
+  def followers_count
+    @followers_count ||= Follow.where(target_account: accounts).count
+  end
+
+  def reports_count
+    @reports_count ||= Report.where(target_account: accounts).count
   end
 
-  def cache_key
+  def blocks_count
+    @blocks_count ||= Block.where(target_account: accounts).count
+  end
+
+  def public_comment
+    domain_block&.public_comment
+  end
+
+  def private_comment
+    domain_block&.private_comment
+  end
+
+  def media_storage
+    @media_storage ||= MediaAttachment.where(account: accounts).sum(:file_file_size)
+  end
+
+  def to_param
     domain
   end
 end
diff --git a/app/models/instance_filter.rb b/app/models/instance_filter.rb
index 9c467bc27..0598d8fea 100644
--- a/app/models/instance_filter.rb
+++ b/app/models/instance_filter.rb
@@ -13,18 +13,27 @@ class InstanceFilter
   end
 
   def results
-    if params[:limited].present?
-      scope = DomainBlock
-      scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
-      scope.order(id: :desc)
-    elsif params[:allowed].present?
-      scope = DomainAllow
-      scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
-      scope.order(id: :desc)
+    scope = Instance.includes(:domain_block, :domain_allow).order(accounts_count: :desc)
+
+    params.each do |key, value|
+      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+    end
+
+    scope
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'limited'
+      Instance.joins(:domain_block).reorder(Arel.sql('domain_blocks.id desc'))
+    when 'allowed'
+      Instance.joins(:domain_allow).reorder(Arel.sql('domain_allows.id desc'))
+    when 'by_domain'
+      Instance.matches_domain(value)
     else
-      scope = Account.remote
-      scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
-      scope.by_domain_accounts
+      raise "Unknown filter: #{key}"
     end
   end
 end
diff --git a/app/models/report.rb b/app/models/report.rb
index f31bcfd2e..cd08120e4 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -14,6 +14,7 @@
 #  target_account_id          :bigint(8)        not null
 #  assigned_account_id        :bigint(8)
 #  uri                        :string
+#  forwarded                  :boolean
 #
 
 class Report < ApplicationRecord
diff --git a/app/models/unavailable_domain.rb b/app/models/unavailable_domain.rb
index e2918b586..5e8870bde 100644
--- a/app/models/unavailable_domain.rb
+++ b/app/models/unavailable_domain.rb
@@ -12,6 +12,8 @@
 class UnavailableDomain < ApplicationRecord
   include DomainNormalizable
 
+  validates :domain, presence: true, uniqueness: true
+
   after_commit :reset_cache!
 
   private
diff --git a/app/models/user.rb b/app/models/user.rb
index 984f04b4e..6495ae04c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -82,7 +82,8 @@ class User < ApplicationRecord
   has_many :webauthn_credentials, dependent: :destroy
 
   has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
-  accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
+  accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text }
+  validates :invite_request, presence: true, on: :create, if: -> { Setting.require_invite_text }
 
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
   validates_with BlacklistedEmailValidator, on: :create
diff --git a/app/policies/domain_block_policy.rb b/app/policies/domain_block_policy.rb
index 47c0a81af..543259cce 100644
--- a/app/policies/domain_block_policy.rb
+++ b/app/policies/domain_block_policy.rb
@@ -13,6 +13,10 @@ class DomainBlockPolicy < ApplicationPolicy
     admin?
   end
 
+  def update?
+    admin?
+  end
+
   def destroy?
     admin?
   end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index ee559037f..a37d904dc 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -29,7 +29,7 @@ class InstancePresenter
   end
 
   def domain_count
-    Rails.cache.fetch('distinct_domain_count') { Account.distinct.count(:domain) }
+    Rails.cache.fetch('distinct_domain_count') { Instance.count }
   end
 
   def sample_accounts
diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb
index 21ec0d4be..dafe8f55b 100644
--- a/app/serializers/manifest_serializer.rb
+++ b/app/serializers/manifest_serializer.rb
@@ -7,7 +7,7 @@ class ManifestSerializer < ActiveModel::Serializer
   attributes :name, :short_name, :description,
              :icons, :theme_color, :background_color,
              :display, :start_url, :scope,
-             :share_target
+             :share_target, :shortcuts
 
   def name
     object.site_title
@@ -64,4 +64,42 @@ class ManifestSerializer < ActiveModel::Serializer
       },
     }
   end
+
+  def shortcuts
+    [
+      {
+        name: 'New toot',
+        url: '/web/statuses/new',
+        icons: [
+          {
+            src: '/shortcuts/new-status.png',
+            type: 'image/png',
+            sizes: '192x192',
+          },
+        ],
+      },
+      {
+        name: 'Notifications',
+        url: '/web/notifications',
+        icons: [
+          {
+            src: '/shortcuts/notifications.png',
+            type: 'image/png',
+            sizes: '192x192',
+          },
+        ],
+      },
+      {
+        name: 'Direct messages',
+        url: '/web/timelines/direct',
+        icons: [
+          {
+            src: '/shortcuts/direct.png',
+            type: 'image/png',
+            sizes: '192x192',
+          },
+        ],
+      },
+    ]
+  end
 end
diff --git a/app/services/report_service.rb b/app/services/report_service.rb
index 1e955c1e7..9d9c7d6c9 100644
--- a/app/services/report_service.rb
+++ b/app/services/report_service.rb
@@ -24,7 +24,8 @@ class ReportService < BaseService
       target_account: @target_account,
       status_ids: @status_ids,
       comment: @comment,
-      uri: @options[:uri]
+      uri: @options[:uri],
+      forwarded: ActiveModel::Type::Boolean.new.cast(@options[:forward])
     )
   end
 
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index 6160ca4d4..e4d614d71 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -16,7 +16,7 @@
     - if approved_registrations?
       .fields-group
         = f.simple_fields_for :invite_request do |invite_request_fields|
-          = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false
+          = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: Setting.require_invite_text
 
     .fields-group
       = f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), required: true, disabled: closed_registrations?
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 0a12ab8d6..78f54ec5a 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -16,11 +16,11 @@
         .row__information-board
           .information-board__section
             %span= t 'about.user_count_before'
-            %strong= number_with_delimiter @instance_presenter.user_count
+            %strong= number_to_human @instance_presenter.user_count, strip_insignificant_zeros: true
             %span= t 'about.user_count_after', count: @instance_presenter.user_count
           .information-board__section
             %span= t 'about.status_count_before'
-            %strong= number_with_delimiter @instance_presenter.status_count
+            %strong= number_to_human @instance_presenter.status_count, strip_insignificant_zeros: true
             %span= t 'about.status_count_after', count: @instance_presenter.status_count
         .row__mascot
           .landing-page__mascot
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index d5978eddd..ae527cc23 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -242,3 +242,13 @@
 
     .actions
       = f.button :button, t('admin.account_moderation_notes.create'), type: :submit
+
+  %hr.spacer/
+
+  - if @account.user&.invite_request&.text&.present?
+    %div.speech-bubble
+      %div.speech-bubble__bubble
+        = @account.user&.invite_request&.text
+      %div.speech-bubble__owner
+        = admin_account_link_to @account
+        = t('admin.accounts.invite_request_text')
diff --git a/app/views/admin/instances/_instance.html.haml b/app/views/admin/instances/_instance.html.haml
new file mode 100644
index 000000000..188d0d984
--- /dev/null
+++ b/app/views/admin/instances/_instance.html.haml
@@ -0,0 +1,25 @@
+.directory__tag
+  = link_to admin_instance_path(instance) do
+    %h4
+      = instance.domain
+      %small
+        - if instance.domain_block
+          - first_item = true
+          - if !instance.domain_block.noop?
+            = t("admin.domain_blocks.severity.#{instance.domain_block.severity}")
+            - first_item = false
+          - unless instance.domain_block.suspend?
+            - if instance.domain_block.reject_media?
+              - unless first_item
+                &bull;
+              = t('admin.domain_blocks.rejecting_media')
+              - first_item = false
+            - if instance.domain_block.reject_reports?
+              - unless first_item
+                &bull;
+              = t('admin.domain_blocks.rejecting_reports')
+        - elsif whitelist_mode?
+          = t('admin.accounts.whitelisted')
+        - else
+          = t('admin.accounts.no_limits_imposed')
+    .trends__item__current{ title: t('admin.instances.known_accounts', count: instance.accounts_count) }= number_to_human instance.accounts_count, strip_insignificant_zeros: true
diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml
index 696ba3c7f..5f20e7ec0 100644
--- a/app/views/admin/instances/index.html.haml
+++ b/app/views/admin/instances/index.html.haml
@@ -32,32 +32,10 @@
 
 %hr.spacer/
 
-- @instances.each do |instance|
-  .directory__tag
-    = link_to admin_instance_path(instance) do
-      %h4
-        = instance.domain
-        %small
-          - if instance.domain_block
-            - first_item = true
-            - if !instance.domain_block.noop?
-              = t("admin.domain_blocks.severity.#{instance.domain_block.severity}")
-              - first_item = false
-            - unless instance.domain_block.suspend?
-              - if instance.domain_block.reject_media?
-                - unless first_item
-                  &bull;
-                = t('admin.domain_blocks.rejecting_media')
-                - first_item = false
-              - if instance.domain_block.reject_reports?
-                - unless first_item
-                  &bull;
-                = t('admin.domain_blocks.rejecting_reports')
-          - elsif whitelist_mode?
-            = t('admin.accounts.whitelisted')
-          - else
-            = t('admin.accounts.no_limits_imposed')
-      - if instance.countable?
-        .trends__item__current{ title: t('admin.instances.known_accounts', count: instance.accounts_count) }= number_to_human instance.accounts_count, strip_insignificant_zeros: true
-
-= paginate paginated_instances
+- if @instances.empty?
+  %div.muted-hint.center-text
+    = t 'admin.instances.empty'
+- else
+  = render @instances
+
+= paginate @instances
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index 92e14c0df..0b9382771 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -3,57 +3,59 @@
 
 .dashboard__counters
   %div
+    = link_to admin_accounts_path(remote: '1', by_domain: @instance.domain) do
+      .dashboard__counters__num= number_with_delimiter @instance.accounts_count
+      .dashboard__counters__label= t 'admin.accounts.title'
+  %div
+    = link_to admin_reports_path(by_target_domain: @instance.domain) do
+      .dashboard__counters__num= number_with_delimiter @instance.reports_count
+      .dashboard__counters__label= t 'admin.instances.total_reported'
+  %div
     %div
-      .dashboard__counters__num= number_with_delimiter @following_count
-      .dashboard__counters__label= t 'admin.instances.total_followed_by_them'
+      .dashboard__counters__num= number_to_human_size @instance.media_storage
+      .dashboard__counters__label= t 'admin.instances.total_storage'
   %div
     %div
-      .dashboard__counters__num= number_with_delimiter @followers_count
-      .dashboard__counters__label= t 'admin.instances.total_followed_by_us'
+      .dashboard__counters__num= number_with_delimiter @instance.following_count
+      .dashboard__counters__label= t 'admin.instances.total_followed_by_them'
   %div
     %div
-      .dashboard__counters__num= number_to_human_size @media_storage
-      .dashboard__counters__label= t 'admin.instances.total_storage'
+      .dashboard__counters__num= number_with_delimiter @instance.followers_count
+      .dashboard__counters__label= t 'admin.instances.total_followed_by_us'
   %div
     %div
-      .dashboard__counters__num= number_with_delimiter @blocks_count
+      .dashboard__counters__num= number_with_delimiter @instance.blocks_count
       .dashboard__counters__label= t 'admin.instances.total_blocked_by_us'
-  %div
-    = link_to admin_reports_path(by_target_domain: @instance.domain) do
-      .dashboard__counters__num= number_with_delimiter @reports_count
-      .dashboard__counters__label= t 'admin.instances.total_reported'
+
   %div
     %div
       .dashboard__counters__num
-        - if @available
+        - if @instance.delivery_failure_tracker.available?
           = fa_icon 'check'
         - else
           = fa_icon 'times'
       .dashboard__counters__label= t 'admin.instances.delivery_available'
 
-- if @private_comment.present?
+- if @instance.private_comment.present?
   .speech-bubble
     .speech-bubble__bubble
-      = simple_format(h(@private_comment))
+      = simple_format(h(@instance.private_comment))
     .speech-bubble__owner= t 'admin.instances.private_comment'
 
-- if @public_comment.present?
+- if @instance.public_comment.present?
   .speech-bubble
     .speech-bubble__bubble
-      = simple_format(h(@public_comment))
+      = simple_format(h(@instance.public_comment))
     .speech-bubble__owner= t 'admin.instances.public_comment'
 
 %hr.spacer/
 
 %div.action-buttons
   %div
-    = link_to t('admin.accounts.title'), admin_accounts_path(remote: '1', by_domain: @instance.domain), class: 'button'
-
-  %div
-    - if @domain_allow
-      = link_to t('admin.domain_allows.undo'), admin_domain_allow_path(@domain_allow), class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete }
-    - elsif @domain_block
-      = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@domain_block), class: 'button'
-      = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@domain_block), class: 'button'
+    - if @instance.domain_allow
+      = link_to t('admin.domain_allows.undo'), admin_domain_allow_path(@instance.domain_allow), class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete }
+    - elsif @instance.domain_block
+      = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button'
+      = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button'
     - else
       = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @instance.domain), class: 'button'
diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml
index bb441380e..721c55f71 100644
--- a/app/views/admin/reports/index.html.haml
+++ b/app/views/admin/reports/index.html.haml
@@ -59,6 +59,10 @@
                 = fa_icon('camera')
                 = report.media_attachments.count
 
+              - if report.forwarded?
+                ·
+                = t('admin.reports.forwarded_to', domain: target_account.domain)
+
           .report-card__summary__item__assigned
             - if report.assigned_account.present?
               = admin_account_link_to report.assigned_account
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 4ecc8dc93..167e96c03 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -43,6 +43,16 @@
         %td{ colspan: 2 }
           - if @report.action_taken?
             = table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put
+      - unless @report.target_account.local?
+        %tr
+          %th= t('admin.reports.forwarded')
+          %td{ colspan: 3 }
+            - if @report.forwarded.nil?
+              \-
+            - elsif @report.forwarded?
+              = t('simple_form.yes')
+            - else
+              = t('simple_form.no')
       - if !@report.action_taken_by_account.nil?
         %tr
           %th= t('admin.reports.action_taken_by')
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 108846ca9..9281d820e 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -41,6 +41,12 @@
   %hr.spacer/
 
   .fields-group
+    = f.input :require_invite_text, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.require_invite_text.title'), hint: t('admin.settings.registrations.require_invite_text.desc_html'), disabled: !approved_registrations?
+  .fields-group
+
+  %hr.spacer/
+
+  .fields-group
     = f.input :enable_bootstrap_timeline_accounts, as: :boolean, wrapper: :with_label, label: t('admin.settings.enable_bootstrap_timeline_accounts.title')
   .fields-group
     = f.input :bootstrap_timeline_accounts, wrapper: :with_block_label, label: t('admin.settings.bootstrap_timeline_accounts.title'), hint: t('admin.settings.bootstrap_timeline_accounts.desc_html'), disabled: !Setting.enable_bootstrap_timeline_accounts
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index de541847f..6981195ed 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -31,7 +31,7 @@
   - if approved_registrations? && !@invite.present?
     .fields-group
       = f.simple_fields_for :invite_request, resource.invite_request || resource.build_invite_request do |invite_request_fields|
-        = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false
+        = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: Setting.require_invite_text
 
   = f.input :invite_code, as: :hidden
 
diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml
index e992e5563..9b7e1b65c 100644
--- a/app/views/notification_mailer/_status.html.haml
+++ b/app/views/notification_mailer/_status.html.haml
@@ -26,11 +26,11 @@
                                       = "@#{status.account.acct}"
 
                               - if status.spoiler_text?
-                                %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
+                                %div.auto-dir
                                   %p
                                     = Formatter.instance.format_spoiler(status)
 
-                              %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
+                              %div.auto-dir
                                 = Formatter.instance.format(status)
 
                                 - if status.media_attachments.size > 0
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index a4dd8534f..4c879472d 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -20,7 +20,7 @@
       %p<
         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
         %button.status__content__spoiler-link= t('statuses.show_more')
-    .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
+    .e-content
       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
       - if status.preloadable_poll
         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index cbeefdffe..1b501a3ef 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -29,7 +29,7 @@
       %p<
         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
         %button.status__content__spoiler-link= t('statuses.show_more')
-    .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }<
+    .e-content<
       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
       - if status.preloadable_poll
         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
diff --git a/app/workers/scheduler/instance_refresh_scheduler.rb b/app/workers/scheduler/instance_refresh_scheduler.rb
new file mode 100644
index 000000000..917404bec
--- /dev/null
+++ b/app/workers/scheduler/instance_refresh_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Scheduler::InstanceRefreshScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options lock: :until_executed, retry: 0
+
+  def perform
+    Instance.refresh
+  end
+end
diff --git a/boxfile.yml b/boxfile.yml
index c4fd19ce6..c1d89bb15 100644
--- a/boxfile.yml
+++ b/boxfile.yml
@@ -110,6 +110,7 @@ worker.sidekiq:
     mailers: bundle exec sidekiq -c 5 -q mailers -L /app/log/sidekiq.log
     pull: bundle exec sidekiq -c 5 -q pull -L /app/log/sidekiq.log
     push: bundle exec sidekiq -c 5 -q push -L /app/log/sidekiq.log
+    scheduler: bundle exec sidekiq -c 5 -q scheduler -L /app/log/sidekiq.log
 
   writable_dirs:
     - tmp
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index baa993c78..dcbfd02b4 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -103,6 +103,37 @@
       "note": ""
     },
     {
+      "warning_type": "Dynamic Render Path",
+      "warning_code": 15,
+      "fingerprint": "4704e8093e3e0561bf705f892e8fc6780419f8255f4440b1c0afd09339bd6446",
+      "check_name": "Render",
+      "message": "Render path contains parameter value",
+      "file": "app/views/admin/instances/index.html.haml",
+      "line": 39,
+      "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
+      "code": "render(action => filtered_instances.page(params[:page]), {})",
+      "render_path": [
+        {
+          "type": "controller",
+          "class": "Admin::InstancesController",
+          "method": "index",
+          "line": 10,
+          "file": "app/controllers/admin/instances_controller.rb",
+          "rendered": {
+            "name": "admin/instances/index",
+            "file": "app/views/admin/instances/index.html.haml"
+          }
+        }
+      ],
+      "location": {
+        "type": "template",
+        "template": "admin/instances/index"
+      },
+      "user_input": "params[:page]",
+      "confidence": "Weak",
+      "note": ""
+    },
+    {
       "warning_type": "Redirect",
       "warning_code": 18,
       "fingerprint": "5fad11cd67f905fab9b1d5739d01384a1748ebe78c5af5ac31518201925265a7",
@@ -125,6 +156,26 @@
     {
       "warning_type": "SQL Injection",
       "warning_code": 0,
+      "fingerprint": "6e4051854bb62e2ddbc671f82d6c2328892e1134b8b28105ecba9b0122540714",
+      "check_name": "SQL",
+      "message": "Possible SQL injection",
+      "file": "app/models/account.rb",
+      "line": 491,
+      "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
+      "code": "find_by_sql([\"          WITH first_degree AS (\\n            SELECT target_account_id\\n            FROM follows\\n            WHERE account_id = ?\\n            UNION ALL\\n            SELECT ?\\n          )\\n          SELECT\\n            accounts.*,\\n            (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n          FROM accounts\\n          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)\\n          WHERE accounts.id IN (SELECT * FROM first_degree)\\n            AND #{query} @@ #{textsearch}\\n            AND accounts.suspended_at IS NULL\\n            AND accounts.moved_to_account_id IS NULL\\n          GROUP BY accounts.id\\n          ORDER BY rank DESC\\n          LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, account.id, limit, offset])",
+      "render_path": null,
+      "location": {
+        "type": "method",
+        "class": "Account",
+        "method": "advanced_search_for"
+      },
+      "user_input": "textsearch",
+      "confidence": "Medium",
+      "note": ""
+    },
+    {
+      "warning_type": "SQL Injection",
+      "warning_code": 0,
       "fingerprint": "6f075c1484908e3ec9bed21ab7cf3c7866be8da3881485d1c82e13093aefcbd7",
       "check_name": "SQL",
       "message": "Possible SQL injection",
@@ -163,23 +214,23 @@
       "note": ""
     },
     {
-      "warning_type": "Mass Assignment",
-      "warning_code": 105,
-      "fingerprint": "8f63dec68951d9bcf7eddb15af9392b2e1333003089c41fb76688dfd3579f394",
-      "check_name": "PermitAttributes",
-      "message": "Potentially dangerous key allowed for mass assignment",
-      "file": "app/controllers/api/v1/crypto/deliveries_controller.rb",
-      "line": 23,
-      "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
-      "code": "params.require(:device).permit(:account_id, :device_id, :type, :body, :hmac)",
+      "warning_type": "SQL Injection",
+      "warning_code": 0,
+      "fingerprint": "9251d682c4e2840e1b2fea91e7d758efe2097ecb7f6255c065e3750d25eb178c",
+      "check_name": "SQL",
+      "message": "Possible SQL injection",
+      "file": "app/models/account.rb",
+      "line": 460,
+      "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
+      "code": "find_by_sql([\"        SELECT\\n          accounts.*,\\n          ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n        FROM accounts\\n        WHERE #{query} @@ #{textsearch}\\n          AND accounts.suspended_at IS NULL\\n          AND accounts.moved_to_account_id IS NULL\\n        ORDER BY rank DESC\\n        LIMIT ? OFFSET ?\\n\".squish, limit, offset])",
       "render_path": null,
       "location": {
         "type": "method",
-        "class": "Api::V1::Crypto::DeliveriesController",
-        "method": "resource_params"
+        "class": "Account",
+        "method": "search_for"
       },
-      "user_input": ":account_id",
-      "confidence": "High",
+      "user_input": "textsearch",
+      "confidence": "Medium",
       "note": ""
     },
     {
@@ -274,6 +325,26 @@
       "note": ""
     },
     {
+      "warning_type": "SQL Injection",
+      "warning_code": 0,
+      "fingerprint": "e21d8fee7a5805761679877ca35ed1029c64c45ef3b4012a30262623e1ba8bb9",
+      "check_name": "SQL",
+      "message": "Possible SQL injection",
+      "file": "app/models/account.rb",
+      "line": 507,
+      "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
+      "code": "find_by_sql([\"          SELECT\\n            accounts.*,\\n            (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n          FROM accounts\\n          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)\\n          WHERE #{query} @@ #{textsearch}\\n            AND accounts.suspended_at IS NULL\\n            AND accounts.moved_to_account_id IS NULL\\n          GROUP BY accounts.id\\n          ORDER BY rank DESC\\n          LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, limit, offset])",
+      "render_path": null,
+      "location": {
+        "type": "method",
+        "class": "Account",
+        "method": "advanced_search_for"
+      },
+      "user_input": "textsearch",
+      "confidence": "Medium",
+      "note": ""
+    },
+    {
       "warning_type": "Mass Assignment",
       "warning_code": 105,
       "fingerprint": "e867661b2c9812bc8b75a5df12b28e2a53ab97015de0638b4e732fe442561b28",
@@ -294,6 +365,6 @@
       "note": ""
     }
   ],
-  "updated": "2020-06-01 18:18:02 +0200",
-  "brakeman_version": "4.8.0"
+  "updated": "2020-12-07 01:17:13 +0100",
+  "brakeman_version": "4.10.0"
 }
diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb
index 25adcd8d6..9ad7fd814 100644
--- a/config/initializers/paperclip.rb
+++ b/config/initializers/paperclip.rb
@@ -113,3 +113,14 @@ else
 end
 
 Paperclip.options[:content_type_mappings] = { csv: Import::FILE_TYPES }
+
+# In some places in the code, we rescue this exception, but we don't always
+# load the S3 library, so it may be an undefined constant:
+
+unless defined?(Seahorse)
+  module Seahorse
+    module Client
+      class NetworkingError < StandardError; end
+    end
+  end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 59f561aa3..1f5798fcc 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -131,6 +131,7 @@ en:
       follows: Follows
       header: Header
       inbox_url: Inbox URL
+      invite_request_text: Reasons for joining
       invited_by: Invited by
       ip: IP
       joined: Joined
@@ -255,6 +256,7 @@ en:
         unsuspend_account: Unsuspend Account
         update_announcement: Update Announcement
         update_custom_emoji: Update Custom Emoji
+        update_domain_block: Update Domain Block
         update_status: Update Status
       actions:
         assigned_to_self_report: "%{name} assigned report %{target} to themselves"
@@ -295,6 +297,7 @@ en:
         unsuspend_account: "%{name} unsuspended %{target}'s account"
         update_announcement: "%{name} updated announcement %{target}"
         update_custom_emoji: "%{name} updated emoji %{target}"
+        update_domain_block: "%{name} updated domain block for %{target}"
         update_status: "%{name} updated status by %{target}"
       deleted_status: "(deleted status)"
       empty: No logs found.
@@ -437,6 +440,7 @@ en:
     instances:
       by_domain: Domain
       delivery_available: Delivery is available
+      empty: No domains found.
       known_accounts:
         one: "%{count} known account"
         other: "%{count} known accounts"
@@ -514,6 +518,8 @@ en:
       comment:
         none: None
       created_at: Reported
+      forwarded: Forwarded
+      forwarded_to: Forwarded to %{domain}
       mark_as_resolved: Mark as resolved
       mark_as_unresolved: Mark as unresolved
       notes:
@@ -583,6 +589,9 @@ en:
         min_invite_role:
           disabled: No one
           title: Allow invitations by
+        require_invite_text:
+          desc_html: When registrations require manual approval, make the “Why do you want to join?” invite request text mandatory rather than optional
+          title: Require new users to fill an invite request text
       registrations_mode:
         modes:
           approved: Approval required for sign up
diff --git a/config/settings.yml b/config/settings.yml
index 4d6a1cffc..96645c222 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -79,6 +79,7 @@ defaults: &defaults
   show_domain_blocks: 'disabled'
   show_domain_blocks_rationale: 'disabled'
   outgoing_spoilers: ''
+  require_invite_text: false
 
 development:
   <<: *defaults
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 5de25de23..010923717 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -5,34 +5,51 @@
   - [push, 4]
   - [mailers, 2]
   - [pull]
+  - [scheduler]
+:scheduler:
+  :listened_queues_only: true
 :schedule:
   scheduled_statuses_scheduler:
     every: '5m'
     class: Scheduler::ScheduledStatusesScheduler
+    queue: scheduler
   trending_tags_scheduler:
     every: '5m'
     class: Scheduler::TrendingTagsScheduler
+    queue: scheduler
   media_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
     class: Scheduler::MediaCleanupScheduler
+    queue: scheduler
   feed_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(0..2) %> * * *'
     class: Scheduler::FeedCleanupScheduler
+    queue: scheduler
   doorkeeper_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(0..2) %> * * 0'
     class: Scheduler::DoorkeeperCleanupScheduler
+    queue: scheduler
   user_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(4..6) %> * * *'
     class: Scheduler::UserCleanupScheduler
+    queue: scheduler
   ip_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
     class: Scheduler::IpCleanupScheduler
+    queue: scheduler
   email_scheduler:
     cron: '0 10 * * 2'
     class: Scheduler::EmailScheduler
+    queue: scheduler
   backup_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
     class: Scheduler::BackupCleanupScheduler
+    queue: scheduler
   pghero_scheduler:
     cron: '0 0 * * *'
     class: Scheduler::PgheroScheduler
+    queue: scheduler
+  instance_refresh_scheduler:
+    cron: '0 * * * *'
+    class: Scheduler::InstanceRefreshScheduler
+    queue: scheduler
diff --git a/db/migrate/20200309150742_add_forwarded_to_reports.rb b/db/migrate/20200309150742_add_forwarded_to_reports.rb
new file mode 100644
index 000000000..df278240b
--- /dev/null
+++ b/db/migrate/20200309150742_add_forwarded_to_reports.rb
@@ -0,0 +1,5 @@
+class AddForwardedToReports < ActiveRecord::Migration[5.2]
+  def change
+    add_column :reports, :forwarded, :boolean
+  end
+end
diff --git a/db/migrate/20201206004238_create_instances.rb b/db/migrate/20201206004238_create_instances.rb
new file mode 100644
index 000000000..a4b866894
--- /dev/null
+++ b/db/migrate/20201206004238_create_instances.rb
@@ -0,0 +1,9 @@
+class CreateInstances < ActiveRecord::Migration[5.2]
+  def change
+    create_view :instances, materialized: true
+
+    # To be able to refresh the view concurrently,
+    # at least one unique index is required
+    safety_assured { add_index :instances, :domain, unique: true }
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7d39126f0..23541d95a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_10_17_234926) do
+ActiveRecord::Schema.define(version: 2020_12_06_004238) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -717,6 +717,7 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
     t.bigint "target_account_id", null: false
     t.bigint "assigned_account_id"
     t.string "uri"
+    t.boolean "forwarded"
     t.index ["account_id"], name: "index_reports_on_account_id"
     t.index ["target_account_id"], name: "index_reports_on_target_account_id"
   end
@@ -1047,4 +1048,29 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do
   add_foreign_key "web_push_subscriptions", "users", on_delete: :cascade
   add_foreign_key "web_settings", "users", name: "fk_11910667b2", on_delete: :cascade
   add_foreign_key "webauthn_credentials", "users"
+
+  create_view "instances", materialized: true, sql_definition: <<-SQL
+      WITH domain_counts(domain, accounts_count) AS (
+           SELECT accounts.domain,
+              count(*) AS accounts_count
+             FROM accounts
+            WHERE (accounts.domain IS NOT NULL)
+            GROUP BY accounts.domain
+          )
+   SELECT domain_counts.domain,
+      domain_counts.accounts_count
+     FROM domain_counts
+  UNION
+   SELECT domain_blocks.domain,
+      COALESCE(domain_counts.accounts_count, (0)::bigint) AS accounts_count
+     FROM (domain_blocks
+       LEFT JOIN domain_counts ON (((domain_counts.domain)::text = (domain_blocks.domain)::text)))
+  UNION
+   SELECT domain_allows.domain,
+      COALESCE(domain_counts.accounts_count, (0)::bigint) AS accounts_count
+     FROM (domain_allows
+       LEFT JOIN domain_counts ON (((domain_counts.domain)::text = (domain_allows.domain)::text)));
+  SQL
+  add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
+
 end
diff --git a/db/views/instances_v01.sql b/db/views/instances_v01.sql
new file mode 100644
index 000000000..94acd61a1
--- /dev/null
+++ b/db/views/instances_v01.sql
@@ -0,0 +1,17 @@
+WITH domain_counts(domain, accounts_count)
+AS (
+  SELECT domain, COUNT(*) as accounts_count
+  FROM accounts
+  WHERE domain IS NOT NULL
+  GROUP BY domain
+)
+SELECT domain, accounts_count
+FROM domain_counts
+UNION
+SELECT domain_blocks.domain, COALESCE(domain_counts.accounts_count, 0)
+FROM domain_blocks
+LEFT OUTER JOIN domain_counts ON domain_counts.domain = domain_blocks.domain
+UNION
+SELECT domain_allows.domain, COALESCE(domain_counts.accounts_count, 0)
+FROM domain_allows
+LEFT OUTER JOIN domain_counts ON domain_counts.domain = domain_allows.domain
diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb
index 5433ddd9d..3c2dfd4ec 100644
--- a/lib/mastodon/domains_cli.rb
+++ b/lib/mastodon/domains_cli.rb
@@ -53,6 +53,8 @@ module Mastodon
       custom_emojis_count = custom_emojis.count
       custom_emojis.destroy_all unless options[:dry_run]
 
+      Instance.refresh unless options[:dry_run]
+
       say("Removed #{custom_emojis_count} custom emojis", :green)
     end
 
@@ -83,7 +85,7 @@ module Mastodon
       processed       = Concurrent::AtomicFixnum.new(0)
       failed          = Concurrent::AtomicFixnum.new(0)
       start_at        = Time.now.to_f
-      seed            = start ? [start] : Account.remote.domains
+      seed            = start ? [start] : Instance.pluck(:domain)
       blocked_domains = Regexp.new('\\.?' + DomainBlock.where(severity: 1).pluck(:domain).join('|') + '$')
       progress        = create_progress_bar
 
diff --git a/lib/mastodon/maintenance_cli.rb b/lib/mastodon/maintenance_cli.rb
index 99d13f43d..ffad3f6a2 100644
--- a/lib/mastodon/maintenance_cli.rb
+++ b/lib/mastodon/maintenance_cli.rb
@@ -14,7 +14,7 @@ module Mastodon
     end
 
     MIN_SUPPORTED_VERSION = 2019_10_01_213028
-    MAX_SUPPORTED_VERSION = 2020_10_17_234926
+    MAX_SUPPORTED_VERSION = 2020_12_06_004238
 
     # Stubs to enjoy ActiveRecord queries while not depending on a particular
     # version of the code/database
diff --git a/lib/paperclip/attachment_extensions.rb b/lib/paperclip/attachment_extensions.rb
index 752e79e65..94f7769b6 100644
--- a/lib/paperclip/attachment_extensions.rb
+++ b/lib/paperclip/attachment_extensions.rb
@@ -39,6 +39,23 @@ module Paperclip
     def default_url(style_name = default_style)
       @url_generator.for_as_default(style_name)
     end
+
+    STOPLIGHT_THRESHOLD = 10
+    STOPLIGHT_COOLDOWN  = 30
+
+    # We overwrite this method to put a circuit breaker around
+    # calls to object storage, to stop hitting APIs that are slow
+    # to respond or don't respond at all and as such minimize the
+    # impact of object storage outages on application throughput
+    def save
+      Stoplight('object-storage') { super }.with_threshold(STOPLIGHT_THRESHOLD).with_cool_off_time(STOPLIGHT_COOLDOWN).with_error_handler do |error, handle|
+        if error.is_a?(Seahorse::Client::NetworkingError)
+          handle.call(error)
+        else
+          raise error
+        end
+      end.run
+    end
   end
 end
 
diff --git a/public/shortcuts/direct.png b/public/shortcuts/direct.png
new file mode 100644
index 000000000..e8772c00e
--- /dev/null
+++ b/public/shortcuts/direct.png
Binary files differdiff --git a/public/shortcuts/new-status.png b/public/shortcuts/new-status.png
new file mode 100644
index 000000000..b7095f3c4
--- /dev/null
+++ b/public/shortcuts/new-status.png
Binary files differdiff --git a/public/shortcuts/notifications.png b/public/shortcuts/notifications.png
new file mode 100644
index 000000000..6b9d45718
--- /dev/null
+++ b/public/shortcuts/notifications.png
Binary files differdiff --git a/public/shortcuts/profile.png b/public/shortcuts/profile.png
new file mode 100644
index 000000000..0b3bf517d
--- /dev/null
+++ b/public/shortcuts/profile.png
Binary files differdiff --git a/spec/controllers/admin/instances_controller_spec.rb b/spec/controllers/admin/instances_controller_spec.rb
index 412b81443..8c0b309f2 100644
--- a/spec/controllers/admin/instances_controller_spec.rb
+++ b/spec/controllers/admin/instances_controller_spec.rb
@@ -9,10 +9,10 @@ RSpec.describe Admin::InstancesController, type: :controller do
 
   describe 'GET #index' do
     around do |example|
-      default_per_page = Account.default_per_page
-      Account.paginates_per 1
+      default_per_page = Instance.default_per_page
+      Instance.paginates_per 1
       example.run
-      Account.paginates_per default_per_page
+      Instance.paginates_per default_per_page
     end
 
     it 'renders instances' do
diff --git a/spec/helpers/statuses_helper_spec.rb b/spec/helpers/statuses_helper_spec.rb
index 940ff072e..cba659bfb 100644
--- a/spec/helpers/statuses_helper_spec.rb
+++ b/spec/helpers/statuses_helper_spec.rb
@@ -149,22 +149,4 @@ RSpec.describe StatusesHelper, type: :helper do
       expect(css_class).to eq 'h-cite'
     end
   end
-
-  describe '#rtl?' do
-    it 'is false if text is empty' do
-      expect(helper).not_to be_rtl ''
-    end
-
-    it 'is false if there are no right to left characters' do
-      expect(helper).not_to be_rtl 'hello world'
-    end
-
-    it 'is false if right to left characters are fewer than 1/3 of total text' do
-      expect(helper).not_to be_rtl 'hello ݟ world'
-    end
-
-    it 'is true if right to left characters are greater than 1/3 of total text' do
-      expect(helper).to be_rtl 'aaݟaaݟ'
-    end
-  end
 end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 75f628076..1d000ed4d 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -440,13 +440,6 @@ RSpec.describe Account, type: :model do
     end
   end
 
-  describe '.domains' do
-    it 'returns domains' do
-      Fabricate(:account, domain: 'domain')
-      expect(Account.remote.domains).to match_array(['domain'])
-    end
-  end
-
   describe '#statuses_count' do
     subject { Fabricate(:account) }
 
@@ -737,20 +730,6 @@ RSpec.describe Account, type: :model do
       end
     end
 
-    describe 'by_domain_accounts' do
-      it 'returns accounts grouped by domain sorted by accounts' do
-        2.times { Fabricate(:account, domain: 'example.com') }
-        Fabricate(:account, domain: 'example2.com')
-
-        results = Account.where('id > 0').by_domain_accounts
-        expect(results.length).to eq 2
-        expect(results.first.domain).to eq 'example.com'
-        expect(results.first.accounts_count).to eq 2
-        expect(results.last.domain).to eq 'example2.com'
-        expect(results.last.accounts_count).to eq 1
-      end
-    end
-
     describe 'local' do
       it 'returns an array of accounts who do not have a domain' do
         account_1 = Fabricate(:account, domain: nil)
diff --git a/yarn.lock b/yarn.lock
index 69a0ccf92..f3d801763 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5533,9 +5533,9 @@ inherits@2.0.3:
   integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 
 ini@^1.3.4, ini@^1.3.5:
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
-  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84"
+  integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==
 
 inquirer@^0.12.0:
   version "0.12.0"