about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorClaire <claire.github-309c@sitedethib.com>2022-02-26 09:29:23 +0100
committerClaire <claire.github-309c@sitedethib.com>2022-02-26 09:29:23 +0100
commitbe493b6c0d60778257cbd6247f9287f939fc7e4e (patch)
tree07c127fc0e059ccd185c40a3c4497706f3bcacc7 /app
parente48eaf64cc7cb0cfab388331c4823ee5fb580d59 (diff)
parenta5c24d5c4d75f3f3144f69c8f60f542707a82584 (diff)
Merge branch 'main' into glitch-soc/merge-upstream
Conflicts:
- `app/models/account.rb`:
  Not a real conflict, just upstream getting rid of unused constants too close
  to glitch-soc-specific contents.
  Removed unused constants like upstream did.
- `app/models/trends.rb`:
  Conflict because glitch-soc disabled email notifications for trending links.
  Upstream has refactored this quite a bit and added trending posts.
  Took upstream code, but disabling the extra trending stuff will come in
  another commit.
- `app/views/admin/trends/links/index.html.haml`:
  Conflict due to glitch-soc's theming system.
  Ported upstream changes accordingly.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/email_domain_blocks_controller.rb72
-rw-r--r--app/controllers/admin/trends/links/preview_card_providers_controller.rb12
-rw-r--r--app/controllers/admin/trends/links_controller.rb20
-rw-r--r--app/controllers/admin/trends/statuses_controller.rb45
-rw-r--r--app/controllers/admin/trends/tags_controller.rb12
-rw-r--r--app/controllers/api/v1/admin/trends/links_controller.rb19
-rw-r--r--app/controllers/api/v1/admin/trends/statuses_controller.rb19
-rw-r--r--app/controllers/api/v1/admin/trends/tags_controller.rb2
-rw-r--r--app/controllers/api/v1/trends/links_controller.rb6
-rw-r--r--app/controllers/api/v1/trends/statuses_controller.rb27
-rw-r--r--app/controllers/api/v1/trends/tags_controller.rb2
-rw-r--r--app/controllers/concerns/localized.rb4
-rw-r--r--app/helpers/admin/filter_helper.rb7
-rw-r--r--app/helpers/languages_helper.rb2
-rw-r--r--app/javascript/mastodon/actions/modal.js3
-rw-r--r--app/javascript/mastodon/actions/trends.js91
-rw-r--r--app/javascript/mastodon/components/hashtag.js2
-rw-r--r--app/javascript/mastodon/components/icon_button.js18
-rw-r--r--app/javascript/mastodon/components/modal_root.js5
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js7
-rw-r--r--app/javascript/mastodon/components/status_list.js3
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js9
-rw-r--r--app/javascript/mastodon/features/explore/components/story.js51
-rw-r--r--app/javascript/mastodon/features/explore/index.js91
-rw-r--r--app/javascript/mastodon/features/explore/links.js48
-rw-r--r--app/javascript/mastodon/features/explore/results.js113
-rw-r--r--app/javascript/mastodon/features/explore/statuses.js48
-rw-r--r--app/javascript/mastodon/features/explore/suggestions.js40
-rw-r--r--app/javascript/mastodon/features/explore/tags.js40
-rw-r--r--app/javascript/mastodon/features/getting_started/containers/trends_container.js6
-rw-r--r--app/javascript/mastodon/features/picture_in_picture/components/footer.js4
-rw-r--r--app/javascript/mastodon/features/search/index.js17
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js9
-rw-r--r--app/javascript/mastodon/features/ui/components/navigation_panel.js1
-rw-r--r--app/javascript/mastodon/features/ui/components/tabs_bar.js6
-rw-r--r--app/javascript/mastodon/features/ui/containers/modal_container.js11
-rw-r--r--app/javascript/mastodon/features/ui/index.js4
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js8
-rw-r--r--app/javascript/mastodon/reducers/modal.js30
-rw-r--r--app/javascript/mastodon/reducers/search.js23
-rw-r--r--app/javascript/mastodon/reducers/status_lists.js23
-rw-r--r--app/javascript/mastodon/reducers/trends.js43
-rw-r--r--app/javascript/styles/mastodon/accounts.scss10
-rw-r--r--app/javascript/styles/mastodon/components.scss128
-rw-r--r--app/javascript/styles/mastodon/tables.scss7
-rw-r--r--app/lib/activitypub/activity/announce.rb3
-rw-r--r--app/lib/activitypub/activity/like.rb2
-rw-r--r--app/mailers/admin_mailer.rb27
-rw-r--r--app/models/account.rb30
-rw-r--r--app/models/email_domain_block.rb55
-rw-r--r--app/models/form/email_domain_block_batch.rb30
-rw-r--r--app/models/status.rb13
-rw-r--r--app/models/trends.rb26
-rw-r--r--app/models/trends/base.rb20
-rw-r--r--app/models/trends/links.rb52
-rw-r--r--app/models/trends/preview_card_batch.rb (renamed from app/models/form/preview_card_batch.rb)22
-rw-r--r--app/models/trends/preview_card_filter.rb (renamed from app/models/preview_card_filter.rb)23
-rw-r--r--app/models/trends/preview_card_provider_batch.rb (renamed from app/models/form/preview_card_provider_batch.rb)6
-rw-r--r--app/models/trends/preview_card_provider_filter.rb (renamed from app/models/preview_card_provider_filter.rb)2
-rw-r--r--app/models/trends/query.rb106
-rw-r--r--app/models/trends/status_batch.rb65
-rw-r--r--app/models/trends/status_filter.rb46
-rw-r--r--app/models/trends/statuses.rb142
-rw-r--r--app/models/trends/tag_batch.rb (renamed from app/models/form/tag_batch.rb)6
-rw-r--r--app/models/trends/tag_filter.rb (renamed from app/models/tag_filter.rb)10
-rw-r--r--app/models/trends/tags.rb36
-rw-r--r--app/models/user.rb2
-rw-r--r--app/policies/account_policy.rb4
-rw-r--r--app/policies/preview_card_policy.rb2
-rw-r--r--app/policies/preview_card_provider_policy.rb2
-rw-r--r--app/policies/status_policy.rb4
-rw-r--r--app/policies/tag_policy.rb4
-rw-r--r--app/presenters/status_relationships_presenter.rb2
-rw-r--r--app/services/delete_account_service.rb32
-rw-r--r--app/services/favourite_service.rb2
-rw-r--r--app/services/reblog_service.rb3
-rw-r--r--app/validators/blacklisted_email_validator.rb26
-rw-r--r--app/validators/email_mx_validator.rb20
-rw-r--r--app/views/admin/custom_emojis/_custom_emoji.html.haml2
-rw-r--r--app/views/admin/email_domain_blocks/_email_domain_block.html.haml27
-rw-r--r--app/views/admin/email_domain_blocks/index.html.haml28
-rw-r--r--app/views/admin/email_domain_blocks/new.html.haml32
-rw-r--r--app/views/admin/follow_recommendations/show.html.haml6
-rw-r--r--app/views/admin/trends/links/index.html.haml34
-rw-r--r--app/views/admin/trends/links/preview_card_providers/index.html.haml2
-rw-r--r--app/views/admin/trends/statuses/_status.html.haml30
-rw-r--r--app/views/admin/trends/statuses/index.html.haml43
-rw-r--r--app/views/admin/trends/tags/index.html.haml4
-rw-r--r--app/views/admin_mailer/_new_trending_links.text.erb14
-rw-r--r--app/views/admin_mailer/_new_trending_statuses.text.erb14
-rw-r--r--app/views/admin_mailer/_new_trending_tags.text.erb14
-rw-r--r--app/views/admin_mailer/new_trending_links.text.erb16
-rw-r--r--app/views/admin_mailer/new_trending_tags.text.erb16
-rw-r--r--app/views/admin_mailer/new_trends.text.erb13
-rw-r--r--app/views/application/_sidebar.html.haml2
-rw-r--r--app/workers/scheduler/email_domain_block_refresh_scheduler.rb30
-rw-r--r--app/workers/scheduler/follow_recommendations_scheduler.rb8
98 files changed, 1891 insertions, 419 deletions
diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb
index f7bdfb0c5..33ee079f3 100644
--- a/app/controllers/admin/email_domain_blocks_controller.rb
+++ b/app/controllers/admin/email_domain_blocks_controller.rb
@@ -6,7 +6,20 @@ module Admin
 
     def index
       authorize :email_domain_block, :index?
+
       @email_domain_blocks = EmailDomainBlock.where(parent_id: nil).includes(:children).order(id: :desc).page(params[:page])
+      @form                = Form::EmailDomainBlockBatch.new
+    end
+
+    def batch
+      @form = Form::EmailDomainBlockBatch.new(form_email_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
+      @form.save
+    rescue ActionController::ParameterMissing
+      flash[:alert] = I18n.t('admin.email_domain_blocks.no_email_domain_block_selected')
+    rescue Mastodon::NotPermittedError
+      flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
+    ensure
+      redirect_to admin_email_domain_blocks_path
     end
 
     def new
@@ -19,41 +32,25 @@ module Admin
 
       @email_domain_block = EmailDomainBlock.new(resource_params)
 
-      if @email_domain_block.save
-        log_action :create, @email_domain_block
-
-        if @email_domain_block.with_dns_records?
-          hostnames = []
-          ips       = []
-
-          Resolv::DNS.open do |dns|
-            dns.timeouts = 5
+      if action_from_button == 'save'
+        EmailDomainBlock.transaction do
+          @email_domain_block.save!
+          log_action :create, @email_domain_block
 
-            hostnames = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }
-
-            ([@email_domain_block.domain] + hostnames).uniq.each do |hostname|
-              ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s })
-              ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).to_a.map { |e| e.address.to_s })
-            end
-          end
-
-          (hostnames + ips).each do |hostname|
-            another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: @email_domain_block)
-            log_action :create, another_email_domain_block if another_email_domain_block.save
+          (@email_domain_block.other_domains || []).uniq.each do |domain|
+            other_email_domain_block = EmailDomainBlock.create!(domain: domain, parent: @email_domain_block)
+            log_action :create, other_email_domain_block
           end
         end
 
         redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg')
       else
+        set_resolved_records
         render :new
       end
-    end
-
-    def destroy
-      authorize @email_domain_block, :destroy?
-      @email_domain_block.destroy!
-      log_action :destroy, @email_domain_block
-      redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
+    rescue ActiveRecord::RecordInvalid
+      set_resolved_records
+      render :new
     end
 
     private
@@ -62,8 +59,27 @@ module Admin
       @email_domain_block = EmailDomainBlock.find(params[:id])
     end
 
+    def set_resolved_records
+      Resolv::DNS.open do |dns|
+        dns.timeouts = 5
+        @resolved_records = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a
+      end
+    end
+
     def resource_params
-      params.require(:email_domain_block).permit(:domain, :with_dns_records)
+      params.require(:email_domain_block).permit(:domain, other_domains: [])
+    end
+
+    def form_email_domain_block_batch_params
+      params.require(:form_email_domain_block_batch).permit(email_domain_block_ids: [])
+    end
+
+    def action_from_button
+      if params[:delete]
+        'delete'
+      elsif params[:save]
+        'save'
+      end
     end
   end
 end
diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb
index 2c26e03f3..40a466cd6 100644
--- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb
+++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb
@@ -5,11 +5,11 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll
     authorize :preview_card_provider, :index?
 
     @preview_card_providers = filtered_preview_card_providers.page(params[:page])
-    @form = Form::PreviewCardProviderBatch.new
+    @form = Trends::PreviewCardProviderBatch.new
   end
 
   def batch
-    @form = Form::PreviewCardProviderBatch.new(form_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
+    @form = Trends::PreviewCardProviderBatch.new(trends_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
     flash[:alert] = I18n.t('admin.accounts.no_account_selected')
@@ -20,15 +20,15 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll
   private
 
   def filtered_preview_card_providers
-    PreviewCardProviderFilter.new(filter_params).results
+    Trends::PreviewCardProviderFilter.new(filter_params).results
   end
 
   def filter_params
-    params.slice(:page, *PreviewCardProviderFilter::KEYS).permit(:page, *PreviewCardProviderFilter::KEYS)
+    params.slice(:page, *Trends::PreviewCardProviderFilter::KEYS).permit(:page, *Trends::PreviewCardProviderFilter::KEYS)
   end
 
-  def form_preview_card_provider_batch_params
-    params.require(:form_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
+  def trends_preview_card_provider_batch_params
+    params.require(:trends_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
   end
 
   def action_from_button
diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb
index 619b37deb..434eec5fe 100644
--- a/app/controllers/admin/trends/links_controller.rb
+++ b/app/controllers/admin/trends/links_controller.rb
@@ -5,11 +5,11 @@ class Admin::Trends::LinksController < Admin::BaseController
     authorize :preview_card, :index?
 
     @preview_cards = filtered_preview_cards.page(params[:page])
-    @form          = Form::PreviewCardBatch.new
+    @form          = Trends::PreviewCardBatch.new
   end
 
   def batch
-    @form = Form::PreviewCardBatch.new(form_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
+    @form = Trends::PreviewCardBatch.new(trends_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
     flash[:alert] = I18n.t('admin.accounts.no_account_selected')
@@ -20,26 +20,26 @@ class Admin::Trends::LinksController < Admin::BaseController
   private
 
   def filtered_preview_cards
-    PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results
+    Trends::PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results
   end
 
   def filter_params
-    params.slice(:page, *PreviewCardFilter::KEYS).permit(:page, *PreviewCardFilter::KEYS)
+    params.slice(:page, *Trends::PreviewCardFilter::KEYS).permit(:page, *Trends::PreviewCardFilter::KEYS)
   end
 
-  def form_preview_card_batch_params
-    params.require(:form_preview_card_batch).permit(:action, preview_card_ids: [])
+  def trends_preview_card_batch_params
+    params.require(:trends_preview_card_batch).permit(:action, preview_card_ids: [])
   end
 
   def action_from_button
     if params[:approve]
       'approve'
-    elsif params[:approve_all]
-      'approve_all'
+    elsif params[:approve_providers]
+      'approve_providers'
     elsif params[:reject]
       'reject'
-    elsif params[:reject_all]
-      'reject_all'
+    elsif params[:reject_providers]
+      'reject_providers'
     end
   end
 end
diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb
new file mode 100644
index 000000000..766242738
--- /dev/null
+++ b/app/controllers/admin/trends/statuses_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class Admin::Trends::StatusesController < Admin::BaseController
+  def index
+    authorize :status, :index?
+
+    @statuses = filtered_statuses.page(params[:page])
+    @form     = Trends::StatusBatch.new
+  end
+
+  def batch
+    @form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button))
+    @form.save
+  rescue ActionController::ParameterMissing
+    flash[:alert] = I18n.t('admin.accounts.no_account_selected')
+  ensure
+    redirect_to admin_trends_statuses_path(filter_params)
+  end
+
+  private
+
+  def filtered_statuses
+    Trends::StatusFilter.new(filter_params.with_defaults(trending: 'all')).results.includes(:account, :media_attachments, :active_mentions)
+  end
+
+  def filter_params
+    params.slice(:page, *Trends::StatusFilter::KEYS).permit(:page, *Trends::StatusFilter::KEYS)
+  end
+
+  def trends_status_batch_params
+    params.require(:trends_status_batch).permit(:action, status_ids: [])
+  end
+
+  def action_from_button
+    if params[:approve]
+      'approve'
+    elsif params[:approve_accounts]
+      'approve_accounts'
+    elsif params[:reject]
+      'reject'
+    elsif params[:reject_accounts]
+      'reject_accounts'
+    end
+  end
+end
diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb
index 91ff33d40..f4d1ec0d1 100644
--- a/app/controllers/admin/trends/tags_controller.rb
+++ b/app/controllers/admin/trends/tags_controller.rb
@@ -5,11 +5,11 @@ class Admin::Trends::TagsController < Admin::BaseController
     authorize :tag, :index?
 
     @tags = filtered_tags.page(params[:page])
-    @form = Form::TagBatch.new
+    @form = Trends::TagBatch.new
   end
 
   def batch
-    @form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button))
+    @form = Trends::TagBatch.new(trends_tag_batch_params.merge(current_account: current_account, action: action_from_button))
     @form.save
   rescue ActionController::ParameterMissing
     flash[:alert] = I18n.t('admin.accounts.no_account_selected')
@@ -20,15 +20,15 @@ class Admin::Trends::TagsController < Admin::BaseController
   private
 
   def filtered_tags
-    TagFilter.new(filter_params).results
+    Trends::TagFilter.new(filter_params).results
   end
 
   def filter_params
-    params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
+    params.slice(:page, *Trends::TagFilter::KEYS).permit(:page, *Trends::TagFilter::KEYS)
   end
 
-  def form_tag_batch_params
-    params.require(:form_tag_batch).permit(:action, tag_ids: [])
+  def trends_tag_batch_params
+    params.require(:trends_tag_batch).permit(:action, tag_ids: [])
   end
 
   def action_from_button
diff --git a/app/controllers/api/v1/admin/trends/links_controller.rb b/app/controllers/api/v1/admin/trends/links_controller.rb
new file mode 100644
index 000000000..63b3d9358
--- /dev/null
+++ b/app/controllers/api/v1/admin/trends/links_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::Trends::LinksController < Api::BaseController
+  protect_from_forgery with: :exception
+
+  before_action -> { authorize_if_got_token! :'admin:read' }
+  before_action :require_staff!
+  before_action :set_links
+
+  def index
+    render json: @links, each_serializer: REST::Trends::LinkSerializer
+  end
+
+  private
+
+  def set_links
+    @links = Trends.links.query.limit(limit_param(10))
+  end
+end
diff --git a/app/controllers/api/v1/admin/trends/statuses_controller.rb b/app/controllers/api/v1/admin/trends/statuses_controller.rb
new file mode 100644
index 000000000..86633cc74
--- /dev/null
+++ b/app/controllers/api/v1/admin/trends/statuses_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::Trends::StatusesController < Api::BaseController
+  protect_from_forgery with: :exception
+
+  before_action -> { authorize_if_got_token! :'admin:read' }
+  before_action :require_staff!
+  before_action :set_statuses
+
+  def index
+    render json: @statuses, each_serializer: REST::StatusSerializer
+  end
+
+  private
+
+  def set_statuses
+    @statuses = cache_collection(Trends.statuses.query.limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status)
+  end
+end
diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb
index 4815af31e..5cc4c269d 100644
--- a/app/controllers/api/v1/admin/trends/tags_controller.rb
+++ b/app/controllers/api/v1/admin/trends/tags_controller.rb
@@ -14,6 +14,6 @@ class Api::V1::Admin::Trends::TagsController < Api::BaseController
   private
 
   def set_tags
-    @tags = Trends.tags.get(false, limit_param(10))
+    @tags = Trends.tags.query.limit(limit_param(10))
   end
 end
diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb
index 1c3ab1e1c..ad20e7f8b 100644
--- a/app/controllers/api/v1/trends/links_controller.rb
+++ b/app/controllers/api/v1/trends/links_controller.rb
@@ -12,10 +12,14 @@ class Api::V1::Trends::LinksController < Api::BaseController
   def set_links
     @links = begin
       if Setting.trends
-        Trends.links.get(true, limit_param(10))
+        links_from_trends
       else
         []
       end
     end
   end
+
+  def links_from_trends
+    Trends.links.query.allowed.in_locale(content_locale).limit(limit_param(10))
+  end
 end
diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb
new file mode 100644
index 000000000..d4ec97ae5
--- /dev/null
+++ b/app/controllers/api/v1/trends/statuses_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class Api::V1::Trends::StatusesController < Api::BaseController
+  before_action :set_statuses
+
+  def index
+    render json: @statuses, each_serializer: REST::StatusSerializer
+  end
+
+  private
+
+  def set_statuses
+    @statuses = begin
+      if Setting.trends
+        cache_collection(statuses_from_trends, Status)
+      else
+        []
+      end
+    end
+  end
+
+  def statuses_from_trends
+    scope = Trends.statuses.query.allowed.in_locale(content_locale)
+    scope = scope.filtered_for(current_account) if user_signed_in?
+    scope.limit(limit_param(DEFAULT_STATUSES_LIMIT))
+  end
+end
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
index 947b53de2..1334b72d2 100644
--- a/app/controllers/api/v1/trends/tags_controller.rb
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -12,7 +12,7 @@ class Api::V1::Trends::TagsController < Api::BaseController
   def set_tags
     @tags = begin
       if Setting.trends
-        Trends.tags.get(true, limit_param(10))
+        Trends.tags.query.allowed.limit(limit_param(10))
       else
         []
       end
diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb
index 173316800..ede299d5a 100644
--- a/app/controllers/concerns/localized.rb
+++ b/app/controllers/concerns/localized.rb
@@ -27,4 +27,8 @@ module Localized
   def available_locale_or_nil(locale_name)
     locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym)
   end
+
+  def content_locale
+    @content_locale ||= I18n.locale.to_s.split(/[_-]/).first
+  end
 end
diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb
index 907529b37..140fc73ed 100644
--- a/app/helpers/admin/filter_helper.rb
+++ b/app/helpers/admin/filter_helper.rb
@@ -5,9 +5,10 @@ module Admin::FilterHelper
     AccountFilter::KEYS,
     CustomEmojiFilter::KEYS,
     ReportFilter::KEYS,
-    TagFilter::KEYS,
-    PreviewCardProviderFilter::KEYS,
-    PreviewCardFilter::KEYS,
+    Trends::TagFilter::KEYS,
+    Trends::PreviewCardProviderFilter::KEYS,
+    Trends::PreviewCardFilter::KEYS,
+    Trends::StatusFilter::KEYS,
     InstanceFilter::KEYS,
     InviteFilter::KEYS,
     RelationshipFilter::KEYS,
diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb
index 3a65af686..f22cc6d28 100644
--- a/app/helpers/languages_helper.rb
+++ b/app/helpers/languages_helper.rb
@@ -242,6 +242,6 @@ module LanguagesHelper
   end
 
   def valid_locale?(locale)
-    SUPPORTED_LOCALES.key?(locale.to_sym)
+    locale.present? && SUPPORTED_LOCALES.key?(locale.to_sym)
   end
 end
diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js
index 3d0299db5..3e576fab8 100644
--- a/app/javascript/mastodon/actions/modal.js
+++ b/app/javascript/mastodon/actions/modal.js
@@ -9,9 +9,10 @@ export function openModal(type, props) {
   };
 };
 
-export function closeModal(type) {
+export function closeModal(type, options = { ignoreFocus: false }) {
   return {
     type: MODAL_CLOSE,
     modalType: type,
+    ignoreFocus: options.ignoreFocus,
   };
 };
diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js
index 853e4f60a..304bbebef 100644
--- a/app/javascript/mastodon/actions/trends.js
+++ b/app/javascript/mastodon/actions/trends.js
@@ -1,31 +1,94 @@
 import api from '../api';
+import { importFetchedStatuses } from './importer';
 
-export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
-export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
-export const TRENDS_FETCH_FAIL    = 'TRENDS_FETCH_FAIL';
+export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';
+export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS';
+export const TRENDS_TAGS_FETCH_FAIL    = 'TRENDS_TAGS_FETCH_FAIL';
 
-export const fetchTrends = () => (dispatch, getState) => {
-  dispatch(fetchTrendsRequest());
+export const TRENDS_LINKS_FETCH_REQUEST = 'TRENDS_LINKS_FETCH_REQUEST';
+export const TRENDS_LINKS_FETCH_SUCCESS = 'TRENDS_LINKS_FETCH_SUCCESS';
+export const TRENDS_LINKS_FETCH_FAIL    = 'TRENDS_LINKS_FETCH_FAIL';
+
+export const TRENDS_STATUSES_FETCH_REQUEST = 'TRENDS_STATUSES_FETCH_REQUEST';
+export const TRENDS_STATUSES_FETCH_SUCCESS = 'TRENDS_STATUSES_FETCH_SUCCESS';
+export const TRENDS_STATUSES_FETCH_FAIL    = 'TRENDS_STATUSES_FETCH_FAIL';
+
+export const fetchTrendingHashtags = () => (dispatch, getState) => {
+  dispatch(fetchTrendingHashtagsRequest());
+
+  api(getState)
+    .get('/api/v1/trends/tags')
+    .then(({ data }) => dispatch(fetchTrendingHashtagsSuccess(data)))
+    .catch(err => dispatch(fetchTrendingHashtagsFail(err)));
+};
+
+export const fetchTrendingHashtagsRequest = () => ({
+  type: TRENDS_TAGS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendingHashtagsSuccess = trends => ({
+  type: TRENDS_TAGS_FETCH_SUCCESS,
+  trends,
+  skipLoading: true,
+});
+
+export const fetchTrendingHashtagsFail = error => ({
+  type: TRENDS_TAGS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+export const fetchTrendingLinks = () => (dispatch, getState) => {
+  dispatch(fetchTrendingLinksRequest());
 
   api(getState)
-    .get('/api/v1/trends')
-    .then(({ data }) => dispatch(fetchTrendsSuccess(data)))
-    .catch(err => dispatch(fetchTrendsFail(err)));
+    .get('/api/v1/trends/links')
+    .then(({ data }) => dispatch(fetchTrendingLinksSuccess(data)))
+    .catch(err => dispatch(fetchTrendingLinksFail(err)));
 };
 
-export const fetchTrendsRequest = () => ({
-  type: TRENDS_FETCH_REQUEST,
+export const fetchTrendingLinksRequest = () => ({
+  type: TRENDS_LINKS_FETCH_REQUEST,
   skipLoading: true,
 });
 
-export const fetchTrendsSuccess = trends => ({
-  type: TRENDS_FETCH_SUCCESS,
+export const fetchTrendingLinksSuccess = trends => ({
+  type: TRENDS_LINKS_FETCH_SUCCESS,
   trends,
   skipLoading: true,
 });
 
-export const fetchTrendsFail = error => ({
-  type: TRENDS_FETCH_FAIL,
+export const fetchTrendingLinksFail = error => ({
+  type: TRENDS_LINKS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+export const fetchTrendingStatuses = () => (dispatch, getState) => {
+  dispatch(fetchTrendingStatusesRequest());
+
+  api(getState).get('/api/v1/trends/statuses').then(({ data }) => {
+    dispatch(importFetchedStatuses(data));
+    dispatch(fetchTrendingStatusesSuccess(data));
+  }).catch(err => dispatch(fetchTrendingStatusesFail(err)));
+};
+
+export const fetchTrendingStatusesRequest = () => ({
+  type: TRENDS_STATUSES_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendingStatusesSuccess = statuses => ({
+  type: TRENDS_STATUSES_FETCH_SUCCESS,
+  statuses,
+  skipLoading: true,
+});
+
+export const fetchTrendingStatusesFail = error => ({
+  type: TRENDS_STATUSES_FETCH_FAIL,
   error,
   skipLoading: true,
   skipAlert: true,
diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js
index a793a32f5..7f442d189 100644
--- a/app/javascript/mastodon/components/hashtag.js
+++ b/app/javascript/mastodon/components/hashtag.js
@@ -38,7 +38,7 @@ class SilentErrorBoundary extends React.Component {
  *
  * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
  */
-const accountsCountRenderer = (displayNumber, pluralReady) => (
+export const accountsCountRenderer = (displayNumber, pluralReady) => (
   <FormattedMessage
     id='trends.counter_by_accounts'
     defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index 7ec39198a..6a653675b 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -27,6 +27,7 @@ export default class IconButton extends React.PureComponent {
     tabIndex: PropTypes.string,
     counter: PropTypes.number,
     obfuscateCount: PropTypes.bool,
+    href: PropTypes.string,
   };
 
   static defaultProps = {
@@ -102,6 +103,7 @@ export default class IconButton extends React.PureComponent {
       title,
       counter,
       obfuscateCount,
+      href,
     } = this.props;
 
     const {
@@ -123,6 +125,20 @@ export default class IconButton extends React.PureComponent {
       style.width = 'auto';
     }
 
+    let contents = (
+      <React.Fragment>
+        <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
+      </React.Fragment>
+    );
+
+    if (href) {
+      contents = (
+        <a href={href} target='_blank' rel='noopener noreferrer'>
+          {contents}
+        </a>
+      );
+    }
+
     return (
       <button
         aria-label={title}
@@ -138,7 +154,7 @@ export default class IconButton extends React.PureComponent {
         tabIndex={tabIndex}
         disabled={disabled}
       >
-        <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
+        {contents}
       </button>
     );
   }
diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js
index 755c46fd6..b894aeaf9 100644
--- a/app/javascript/mastodon/components/modal_root.js
+++ b/app/javascript/mastodon/components/modal_root.js
@@ -18,6 +18,7 @@ export default class ModalRoot extends React.PureComponent {
       g: PropTypes.number,
       b: PropTypes.number,
     }),
+    ignoreFocus: PropTypes.bool,
   };
 
   activeElement = this.props.children ? document.activeElement : null;
@@ -72,7 +73,9 @@ export default class ModalRoot extends React.PureComponent {
       // immediately selectable, we have to wait for observers to run, as
       // described in https://github.com/WICG/inert#performance-and-gotchas
       Promise.resolve().then(() => {
-        this.activeElement.focus({ preventScroll: true });
+        if (!this.props.ignoreFocus) {
+          this.activeElement.focus({ preventScroll: true });
+        }
         this.activeElement = null;
       }).catch(console.error);
 
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index e3a7d763f..1d8fe23da 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -77,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent {
     onPin: PropTypes.func,
     onBookmark: PropTypes.func,
     withDismiss: PropTypes.bool,
+    withCounters: PropTypes.bool,
     scrollKey: PropTypes.string,
     intl: PropTypes.object.isRequired,
   };
@@ -226,7 +227,7 @@ class StatusActionBar extends ImmutablePureComponent {
   }
 
   render () {
-    const { status, relationship, intl, withDismiss, scrollKey } = this.props;
+    const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
 
     const anonymousAccess    = !me;
     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
@@ -331,8 +332,8 @@ class StatusActionBar extends ImmutablePureComponent {
     return (
       <div className='status__action-bar'>
         <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
-        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
-        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
+        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
 
         {shareButton}
 
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index eaaffcc3a..35e5749a3 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -24,6 +24,7 @@ export default class StatusList extends ImmutablePureComponent {
     prepend: PropTypes.node,
     emptyMessage: PropTypes.node,
     alwaysPrepend: PropTypes.bool,
+    withCounters: PropTypes.bool,
     timelineId: PropTypes.string,
   };
 
@@ -100,6 +101,7 @@ export default class StatusList extends ImmutablePureComponent {
           contextType={timelineId}
           scrollKey={this.props.scrollKey}
           showThread
+          withCounters={this.props.withCounters}
         />
       ))
     ) : null;
@@ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent {
           onMoveDown={this.handleMoveDown}
           contextType={timelineId}
           showThread
+          withCounters={this.props.withCounters}
         />
       )).concat(scrollableContent);
     }
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 26232247d..826d9f504 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -164,8 +164,13 @@ class ComposeForm extends ImmutablePureComponent {
         selectionStart = selectionEnd;
       }
 
-      this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
-      this.autosuggestTextarea.textarea.focus();
+      // Because of the wicg-inert polyfill, the activeElement may not be
+      // immediately selectable, we have to wait for observers to run, as
+      // described in https://github.com/WICG/inert#performance-and-gotchas
+      Promise.resolve().then(() => {
+        this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
+        this.autosuggestTextarea.textarea.focus();
+      }).catch(console.error);
     } else if(prevProps.isSubmitting && !this.props.isSubmitting) {
       this.autosuggestTextarea.textarea.focus();
     } else if (this.props.spoiler !== prevProps.spoiler) {
diff --git a/app/javascript/mastodon/features/explore/components/story.js b/app/javascript/mastodon/features/explore/components/story.js
new file mode 100644
index 000000000..563128029
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/components/story.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Blurhash from 'mastodon/components/blurhash';
+import { accountsCountRenderer } from 'mastodon/components/hashtag';
+import ShortNumber from 'mastodon/components/short_number';
+import Skeleton from 'mastodon/components/skeleton';
+import classNames from 'classnames';
+
+export default class Story extends React.PureComponent {
+
+  static propTypes = {
+    url: PropTypes.string,
+    title: PropTypes.string,
+    publisher: PropTypes.string,
+    sharedTimes: PropTypes.number,
+    thumbnail: PropTypes.string,
+    blurhash: PropTypes.string,
+  };
+
+  state = {
+    thumbnailLoaded: false,
+  };
+
+  handleImageLoad = () => this.setState({ thumbnailLoaded: true });
+
+  render () {
+    const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props;
+
+    const { thumbnailLoaded } = this.state;
+
+    return (
+      <a className='story' href={url} target='blank' rel='noopener'>
+        <div className='story__details'>
+          <div className='story__details__publisher'>{publisher ? publisher : <Skeleton width={50} />}</div>
+          <div className='story__details__title'>{title ? title : <Skeleton />}</div>
+          <div className='story__details__shared'>{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div>
+        </div>
+
+        <div className='story__thumbnail'>
+          {thumbnail ? (
+            <React.Fragment>
+              <div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
+              <img src={thumbnail} onLoad={this.handleImageLoad} alt='' role='presentation' />
+            </React.Fragment>
+          ) : <Skeleton />}
+        </div>
+      </a>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/index.js b/app/javascript/mastodon/features/explore/index.js
new file mode 100644
index 000000000..ddacf5812
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/index.js
@@ -0,0 +1,91 @@
+import React from 'react';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import { NavLink, Switch, Route } from 'react-router-dom';
+import Links from './links';
+import Tags from './tags';
+import Statuses from './statuses';
+import Suggestions from './suggestions';
+import Search from 'mastodon/features/compose/containers/search_container';
+import SearchResults from './results';
+
+const messages = defineMessages({
+  title: { id: 'explore.title', defaultMessage: 'Explore' },
+  searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
+});
+
+const mapStateToProps = state => ({
+  layout: state.getIn(['meta', 'layout']),
+  isSearching: state.getIn(['search', 'submitted']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Explore extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    isSearching: PropTypes.bool,
+    layout: PropTypes.string,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  render () {
+    const { intl, multiColumn, isSearching, layout } = this.props;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
+        {layout === 'mobile' ? (
+          <div className='explore__search-header'>
+            <Search />
+          </div>
+        ) : (
+          <ColumnHeader
+            icon={isSearching ? 'search' : 'globe'}
+            title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
+            onClick={this.handleHeaderClick}
+            multiColumn={multiColumn}
+          />
+        )}
+
+        <div className='scrollable scrollable--flex'>
+          {isSearching ? (
+            <SearchResults />
+          ) : (
+            <React.Fragment>
+              <div className='account__section-headline'>
+                <NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
+                <NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
+                <NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
+                <NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>
+              </div>
+
+              <Switch>
+                <Route path='/explore/tags' component={Tags} />
+                <Route path='/explore/links' component={Links} />
+                <Route path='/explore/suggestions' component={Suggestions} />
+                <Route exact path={['/explore', '/explore/posts', '/search']} component={Statuses} componentParams={{ multiColumn }} />
+              </Switch>
+            </React.Fragment>
+          )}
+        </div>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/links.js b/app/javascript/mastodon/features/explore/links.js
new file mode 100644
index 000000000..6649fb6e4
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/links.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Story from './components/story';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchTrendingLinks } from 'mastodon/actions/trends';
+
+const mapStateToProps = state => ({
+  links: state.getIn(['trends', 'links', 'items']),
+  isLoading: state.getIn(['trends', 'links', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Links extends React.PureComponent {
+
+  static propTypes = {
+    links: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchTrendingLinks());
+  }
+
+  render () {
+    const { isLoading, links } = this.props;
+
+    return (
+      <div className='explore__links'>
+        {isLoading ? (<LoadingIndicator />) : links.map(link => (
+          <Story
+            key={link.get('id')}
+            url={link.get('url')}
+            title={link.get('title')}
+            publisher={link.get('provider_name')}
+            sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
+            thumbnail={link.get('image')}
+            blurhash={link.get('blurhash')}
+          />
+        ))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/results.js b/app/javascript/mastodon/features/explore/results.js
new file mode 100644
index 000000000..27e8aaa4f
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/results.js
@@ -0,0 +1,113 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { expandSearch } from 'mastodon/actions/search';
+import Account from 'mastodon/containers/account_container';
+import Status from 'mastodon/containers/status_container';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
+import { List as ImmutableList } from 'immutable';
+import LoadMore from 'mastodon/components/load_more';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+
+const mapStateToProps = state => ({
+  isLoading: state.getIn(['search', 'isLoading']),
+  results: state.getIn(['search', 'results']),
+});
+
+const appendLoadMore = (id, list, onLoadMore) => {
+  if (list.size >= 5) {
+    return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />);
+  } else {
+    return list;
+  }
+};
+
+const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts').map(item => (
+  <Account key={`account-${item}`} id={item} />
+)), onLoadMore);
+
+const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags').map(item => (
+  <Hashtag key={`tag-${item.get('name')}`} hashtag={item} />
+)), onLoadMore);
+
+const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses').map(item => (
+  <Status key={`status-${item}`} id={item} />
+)), onLoadMore);
+
+export default @connect(mapStateToProps)
+class Results extends React.PureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map,
+    isLoading: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  state = {
+    type: 'all',
+  };
+
+  handleSelectAll = () => this.setState({ type: 'all' });
+  handleSelectAccounts = () => this.setState({ type: 'accounts' });
+  handleSelectHashtags = () => this.setState({ type: 'hashtags' });
+  handleSelectStatuses = () => this.setState({ type: 'statuses' });
+  handleLoadMoreAccounts = () => this.loadMore('accounts');
+  handleLoadMoreStatuses = () => this.loadMore('statuses');
+  handleLoadMoreHashtags = () => this.loadMore('hashtags');
+
+  loadMore (type) {
+    const { dispatch } = this.props;
+    dispatch(expandSearch(type));
+  }
+
+  render () {
+    const { isLoading, results } = this.props;
+    const { type } = this.state;
+
+    let filteredResults = ImmutableList();
+
+    if (!isLoading) {
+      switch(type) {
+      case 'all':
+        filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses));
+        break;
+      case 'accounts':
+        filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts));
+        break;
+      case 'hashtags':
+        filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags));
+        break;
+      case 'statuses':
+        filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses));
+        break;
+      }
+
+      if (filteredResults.size === 0) {
+        filteredResults = (
+          <div className='empty-column-indicator'>
+            <FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
+          </div>
+        );
+      }
+    }
+
+    return (
+      <React.Fragment>
+        <div className='account__section-headline'>
+          <button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
+          <button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='People' /></button>
+          <button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
+          <button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></button>
+        </div>
+
+        <div className='explore__search-results'>
+          {isLoading ? (<LoadingIndicator />) : filteredResults}
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/statuses.js b/app/javascript/mastodon/features/explore/statuses.js
new file mode 100644
index 000000000..4e5530d84
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/statuses.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusList from 'mastodon/components/status_list';
+import { FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { fetchTrendingStatuses } from 'mastodon/actions/trends';
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'trending', 'items']),
+  isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
+});
+
+export default @connect(mapStateToProps)
+class Statuses extends React.PureComponent {
+
+  static propTypes = {
+    statusIds: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchTrendingStatuses());
+  }
+
+  render () {
+    const { isLoading, statusIds, multiColumn } = this.props;
+
+    const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />;
+
+    return (
+      <StatusList
+        trackScroll
+        statusIds={statusIds}
+        scrollKey='explore-statuses'
+        hasMore={false}
+        isLoading={isLoading}
+        emptyMessage={emptyMessage}
+        bindToDocument={!multiColumn}
+        withCounters
+      />
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/suggestions.js b/app/javascript/mastodon/features/explore/suggestions.js
new file mode 100644
index 000000000..c094a8d93
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/suggestions.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Account from 'mastodon/containers/account_container';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchSuggestions } from 'mastodon/actions/suggestions';
+
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['suggestions', 'items']),
+  isLoading: state.getIn(['suggestions', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Suggestions extends React.PureComponent {
+
+  static propTypes = {
+    isLoading: PropTypes.bool,
+    suggestions: ImmutablePropTypes.list,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchSuggestions(true));
+  }
+
+  render () {
+    const { isLoading, suggestions } = this.props;
+
+    return (
+      <div className='explore__links'>
+        {isLoading ? (<LoadingIndicator />) : suggestions.map(suggestion => (
+          <Account key={suggestion.get('account')} id={suggestion.get('account')} />
+        ))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/tags.js b/app/javascript/mastodon/features/explore/tags.js
new file mode 100644
index 000000000..c0ad9fc6e
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/tags.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchTrendingHashtags } from 'mastodon/actions/trends';
+
+const mapStateToProps = state => ({
+  hashtags: state.getIn(['trends', 'tags', 'items']),
+  isLoadingHashtags: state.getIn(['trends', 'tags', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Tags extends React.PureComponent {
+
+  static propTypes = {
+    hashtags: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchTrendingHashtags());
+  }
+
+  render () {
+    const { isLoading, hashtags } = this.props;
+
+    return (
+      <div className='explore__links'>
+        {isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => (
+          <Hashtag key={hashtag.get('name')} hashtag={hashtag} />
+        ))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/containers/trends_container.js b/app/javascript/mastodon/features/getting_started/containers/trends_container.js
index 7a5268780..a73832db7 100644
--- a/app/javascript/mastodon/features/getting_started/containers/trends_container.js
+++ b/app/javascript/mastodon/features/getting_started/containers/trends_container.js
@@ -1,13 +1,13 @@
 import { connect } from 'react-redux';
-import { fetchTrends } from 'mastodon/actions/trends';
+import { fetchTrendingHashtags } from 'mastodon/actions/trends';
 import Trends from '../components/trends';
 
 const mapStateToProps = state => ({
-  trends: state.getIn(['trends', 'items']),
+  trends: state.getIn(['trends', 'tags', 'items']),
 });
 
 const mapDispatchToProps = dispatch => ({
-  fetchTrends: () => dispatch(fetchTrends()),
+  fetchTrends: () => dispatch(fetchTrendingHashtags()),
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(Trends);
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
index 0de562ee1..0cb42b25a 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
@@ -60,7 +60,7 @@ class Footer extends ImmutablePureComponent {
     const { router } = this.context;
 
     if (onClose) {
-      onClose();
+      onClose(true);
     }
 
     dispatch(replyCompose(status, router.history));
@@ -156,7 +156,7 @@ class Footer extends ImmutablePureComponent {
         <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
         <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
         <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
-        {withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} />}
+        {withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={status.get('url')} />}
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/search/index.js b/app/javascript/mastodon/features/search/index.js
deleted file mode 100644
index 76bf70d4b..000000000
--- a/app/javascript/mastodon/features/search/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-import SearchContainer from 'mastodon/features/compose/containers/search_container';
-import SearchResultsContainer from 'mastodon/features/compose/containers/search_results_container';
-
-const Search = () => (
-  <div className='column search-page'>
-    <SearchContainer />
-
-    <div className='drawer__pager'>
-      <div className='drawer__inner darker'>
-        <SearchResultsContainer />
-      </div>
-    </div>
-  </div>
-);
-
-export default Search;
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 193637113..db047f5f0 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -53,7 +53,7 @@ const messages = defineMessages({
   publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
 });
 
-const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/search|^\/getting-started|^\/start/);
+const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/explore|^\/getting-started|^\/start/);
 
 export default @(component => injectIntl(component, { withRef: true }))
 class ColumnsArea extends ImmutablePureComponent {
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 7b14fe5ca..3fc235849 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -45,6 +45,7 @@ export default class ModalRoot extends React.PureComponent {
     type: PropTypes.string,
     props: PropTypes.object,
     onClose: PropTypes.func.isRequired,
+    ignoreFocus: PropTypes.bool,
   };
 
   state = {
@@ -79,7 +80,7 @@ export default class ModalRoot extends React.PureComponent {
     return <BundleModalError {...props} onClose={onClose} />;
   }
 
-  handleClose = () => {
+  handleClose = (ignoreFocus = false) => {
     const { onClose } = this.props;
     let message = null;
     try {
@@ -89,7 +90,7 @@ export default class ModalRoot extends React.PureComponent {
       // isn't set.
       // This would be much smoother with react-intl 3+ and `forwardRef`.
     }
-    onClose(message);
+    onClose(message, ignoreFocus);
   }
 
   setModalRef = (c) => {
@@ -97,12 +98,12 @@ export default class ModalRoot extends React.PureComponent {
   }
 
   render () {
-    const { type, props } = this.props;
+    const { type, props, ignoreFocus } = this.props;
     const { backgroundColor } = this.state;
     const visible = !!type;
 
     return (
-      <Base backgroundColor={backgroundColor} onClose={this.handleClose}>
+      <Base backgroundColor={backgroundColor} onClose={this.handleClose} ignoreFocus={ignoreFocus}>
         {visible && (
           <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
             {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js
index 901dbdfcb..a70e5ab61 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.js
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js
@@ -13,6 +13,7 @@ const NavigationPanel = () => (
     <NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
     <FollowRequestsNavLink />
+    <NavLink className='column-link column-link--transparent' to='/explore' data-preview-title-id='explore.title' data-preview-icon='globe'><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='explore.title' defaultMessage='Explore' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
     <NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
index a023bcf34..195403fd3 100644
--- a/app/javascript/mastodon/features/ui/components/tabs_bar.js
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -10,9 +10,9 @@ import NotificationsCounterIcon from './notifications_counter_icon';
 export const links = [
   <NavLink className='tabs-bar__link' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
   <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
-  <NavLink className='tabs-bar__link' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
-  <NavLink className='tabs-bar__link' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
-  <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
+  <NavLink className='tabs-bar__link optional' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link optional' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/explore' data-preview-title-id='tabs_bar.search' data-preview-icon='search' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
   <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
 ];
 
diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js
index 34fec8206..35be26222 100644
--- a/app/javascript/mastodon/features/ui/containers/modal_container.js
+++ b/app/javascript/mastodon/features/ui/containers/modal_container.js
@@ -3,22 +3,23 @@ import { openModal, closeModal } from '../../../actions/modal';
 import ModalRoot from '../components/modal_root';
 
 const mapStateToProps = state => ({
-  type: state.getIn(['modal', 0, 'modalType'], null),
-  props: state.getIn(['modal', 0, 'modalProps'], {}),
+  ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
+  type: state.getIn(['modal', 'stack', 0, 'modalType'], null),
+  props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}),
 });
 
 const mapDispatchToProps = dispatch => ({
-  onClose (confirmationMessage) {
+  onClose (confirmationMessage, ignoreFocus = false) {
     if (confirmationMessage) {
       dispatch(
         openModal('CONFIRM', {
           message: confirmationMessage.message,
           confirm: confirmationMessage.confirm,
-          onConfirm: () => dispatch(closeModal()),
+          onConfirm: () => dispatch(closeModal(undefined, { ignoreFocus })),
         }),
       );
     } else {
-      dispatch(closeModal());
+      dispatch(closeModal(undefined, { ignoreFocus }));
     }
   },
 });
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 3feffa656..2d0136992 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -49,8 +49,8 @@ import {
   Mutes,
   PinnedStatuses,
   Lists,
-  Search,
   Directory,
+  Explore,
   FollowRecommendations,
 } from './util/async-components';
 import { me } from '../../initial_state';
@@ -167,8 +167,8 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
 
           <WrappedRoute path='/start' component={FollowRecommendations} content={children} />
-          <WrappedRoute path='/search' component={Search} content={children} />
           <WrappedRoute path='/directory' component={Directory} content={children} />
+          <WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
           <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
 
           <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 5349bd656..92c683e2f 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -138,10 +138,6 @@ export function ListAdder () {
   return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
 }
 
-export function Search () {
-  return import(/*webpackChunkName: "features/search" */'../../search');
-}
-
 export function Tesseract () {
   return import(/*webpackChunkName: "tesseract" */'tesseract.js');
 }
@@ -161,3 +157,7 @@ export function FollowRecommendations () {
 export function CompareHistoryModal () {
   return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal');
 }
+
+export function Explore () {
+  return import(/* webpackChunkName: "features/explore" */'../../explore');
+}
diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js
index 41161a206..3eab07d9d 100644
--- a/app/javascript/mastodon/reducers/modal.js
+++ b/app/javascript/mastodon/reducers/modal.js
@@ -3,16 +3,36 @@ import { TIMELINE_DELETE } from '../actions/timelines';
 import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
 import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
 
-export default function modal(state = ImmutableStack(), action) {
+const initialState = ImmutableMap({
+  ignoreFocus: false,
+  stack: ImmutableStack(),
+});
+
+const popModal = (state, { modalType, ignoreFocus }) => {
+  if (modalType === undefined || modalType === state.getIn(['stack', 0, 'modalType'])) {
+    return state.set('ignoreFocus', !!ignoreFocus).update('stack', stack => stack.shift());
+  } else {
+    return state;
+  }
+};
+
+const pushModal = (state, modalType, modalProps) => {
+  return state.withMutations(map => {
+    map.set('ignoreFocus', false);
+    map.update('stack', stack => stack.unshift(ImmutableMap({ modalType, modalProps })));
+  });
+};
+
+export default function modal(state = initialState, action) {
   switch(action.type) {
   case MODAL_OPEN:
-    return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps }));
+    return pushModal(state, action.modalType, action.modalProps);
   case MODAL_CLOSE:
-    return (action.modalType === undefined || action.modalType === state.getIn([0, 'modalType'])) ? state.shift() : state;
+    return popModal(state, action);
   case COMPOSE_UPLOAD_CHANGE_SUCCESS:
-    return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state;
+    return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
   case TIMELINE_DELETE:
-    return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
+    return state.update('stack', stack => stack.filterNot((modal) => modal.get('modalProps').statusId === action.id));
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 875b2d92b..23bbe4d99 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -1,6 +1,8 @@
 import {
   SEARCH_CHANGE,
   SEARCH_CLEAR,
+  SEARCH_FETCH_REQUEST,
+  SEARCH_FETCH_FAIL,
   SEARCH_FETCH_SUCCESS,
   SEARCH_SHOW,
   SEARCH_EXPAND_SUCCESS,
@@ -17,6 +19,7 @@ const initialState = ImmutableMap({
   submitted: false,
   hidden: false,
   results: ImmutableMap(),
+  isLoading: false,
   searchTerm: '',
 });
 
@@ -37,12 +40,22 @@ export default function search(state = initialState, action) {
   case COMPOSE_MENTION:
   case COMPOSE_DIRECT:
     return state.set('hidden', true);
+  case SEARCH_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case SEARCH_FETCH_FAIL:
+    return state.set('isLoading', false);
   case SEARCH_FETCH_SUCCESS:
-    return state.set('results', ImmutableMap({
-      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('searchTerm', action.searchTerm);
+    return state.withMutations(map => {
+      map.set('results', ImmutableMap({
+        accounts: ImmutableList(action.results.accounts.map(item => item.id)),
+        statuses: ImmutableList(action.results.statuses.map(item => item.id)),
+        hashtags: fromJS(action.results.hashtags),
+      }));
+
+      map.set('submitted', true);
+      map.set('searchTerm', action.searchTerm);
+      map.set('isLoading', false);
+    });
   case SEARCH_EXPAND_SUCCESS:
     const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
     return state.updateIn(['results', action.searchType], list => list.concat(results));
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
index 9f8f28dee..49bc94a40 100644
--- a/app/javascript/mastodon/reducers/status_lists.js
+++ b/app/javascript/mastodon/reducers/status_lists.js
@@ -17,6 +17,11 @@ import {
 import {
   PINNED_STATUSES_FETCH_SUCCESS,
 } from '../actions/pin_statuses';
+import {
+  TRENDS_STATUSES_FETCH_REQUEST,
+  TRENDS_STATUSES_FETCH_SUCCESS,
+  TRENDS_STATUSES_FETCH_FAIL,
+} from '../actions/trends';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 import {
   FAVOURITE_SUCCESS,
@@ -26,6 +31,10 @@ import {
   PIN_SUCCESS,
   UNPIN_SUCCESS,
 } from '../actions/interactions';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from '../actions/accounts';
 
 const initialState = ImmutableMap({
   favourites: ImmutableMap({
@@ -43,6 +52,11 @@ const initialState = ImmutableMap({
     loaded: false,
     items: ImmutableList(),
   }),
+  trending: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableList(),
+  }),
 });
 
 const normalizeList = (state, listType, statuses, next) => {
@@ -96,6 +110,12 @@ export default function statusLists(state = initialState, action) {
     return normalizeList(state, 'bookmarks', action.statuses, action.next);
   case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
     return appendToList(state, 'bookmarks', action.statuses, action.next);
+  case TRENDS_STATUSES_FETCH_REQUEST:
+    return state.setIn(['trending', 'isLoading'], true);
+  case TRENDS_STATUSES_FETCH_FAIL:
+    return state.setIn(['trending', 'isLoading'], false);
+  case TRENDS_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'trending', action.statuses, action.next);
   case FAVOURITE_SUCCESS:
     return prependOneToList(state, 'favourites', action.status);
   case UNFAVOURITE_SUCCESS:
@@ -110,6 +130,9 @@ export default function statusLists(state = initialState, action) {
     return prependOneToList(state, 'pins', action.status);
   case UNPIN_SUCCESS:
     return removeOneFromList(state, 'pins', action.status);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return state.updateIn(['trending', 'items'], ImmutableList(), list => list.filterNot(statusId => action.statuses.getIn([statusId, 'account']) === action.relationship.id));
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/trends.js b/app/javascript/mastodon/reducers/trends.js
index 5cecc8fca..3e01bd07d 100644
--- a/app/javascript/mastodon/reducers/trends.js
+++ b/app/javascript/mastodon/reducers/trends.js
@@ -1,22 +1,45 @@
-import { TRENDS_FETCH_REQUEST, TRENDS_FETCH_SUCCESS, TRENDS_FETCH_FAIL } from '../actions/trends';
+import {
+  TRENDS_TAGS_FETCH_REQUEST,
+  TRENDS_TAGS_FETCH_SUCCESS,
+  TRENDS_TAGS_FETCH_FAIL,
+  TRENDS_LINKS_FETCH_REQUEST,
+  TRENDS_LINKS_FETCH_SUCCESS,
+  TRENDS_LINKS_FETCH_FAIL,
+} from 'mastodon/actions/trends';
 import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
 const initialState = ImmutableMap({
-  items: ImmutableList(),
-  isLoading: false,
+  tags: ImmutableMap({
+    items: ImmutableList(),
+    isLoading: false,
+  }),
+
+  links: ImmutableMap({
+    items: ImmutableList(),
+    isLoading: false,
+  }),
 });
 
 export default function trendsReducer(state = initialState, action) {
   switch(action.type) {
-  case TRENDS_FETCH_REQUEST:
-    return state.set('isLoading', true);
-  case TRENDS_FETCH_SUCCESS:
+  case TRENDS_TAGS_FETCH_REQUEST:
+    return state.setIn(['tags', 'isLoading'], true);
+  case TRENDS_TAGS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.setIn(['tags', 'items'], fromJS(action.trends));
+      map.setIn(['tags', 'isLoading'], false);
+    });
+  case TRENDS_TAGS_FETCH_FAIL:
+    return state.setIn(['tags', 'isLoading'], false);
+  case TRENDS_LINKS_FETCH_REQUEST:
+    return state.setIn(['links', 'isLoading'], true);
+  case TRENDS_LINKS_FETCH_SUCCESS:
     return state.withMutations(map => {
-      map.set('items', fromJS(action.trends));
-      map.set('isLoading', false);
+      map.setIn(['links', 'items'], fromJS(action.trends));
+      map.setIn(['links', 'isLoading'], false);
     });
-  case TRENDS_FETCH_FAIL:
-    return state.set('isLoading', false);
+  case TRENDS_LINKS_FETCH_FAIL:
+    return state.setIn(['links', 'isLoading'], false);
   default:
     return state;
   }
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index 485fe4a9d..215774a19 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -331,7 +331,8 @@
 }
 
 .batch-table__row--muted .pending-account__header,
-.batch-table__row--muted .accounts-table {
+.batch-table__row--muted .accounts-table,
+.batch-table__row--muted .name-tag {
   &,
   a,
   strong {
@@ -339,6 +340,10 @@
   }
 }
 
+.batch-table__row--muted .name-tag .avatar {
+  opacity: 0.5;
+}
+
 .batch-table__row--muted .accounts-table {
   tbody td.accounts-table__extra,
   &__count,
@@ -352,7 +357,8 @@
 }
 
 .batch-table__row--attention .pending-account__header,
-.batch-table__row--attention .accounts-table {
+.batch-table__row--attention .accounts-table,
+.batch-table__row--attention .name-tag {
   &,
   a,
   strong {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 108bf68a5..8f6d4b69a 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -166,6 +166,11 @@
   transition-property: background-color, color;
   text-decoration: none;
 
+  a {
+    color: inherit;
+    text-decoration: none;
+  }
+
   &:hover,
   &:active,
   &:focus {
@@ -2797,6 +2802,10 @@ a.account__display-name {
     position: relative;
     min-height: 120px;
   }
+
+  .scrollable {
+    flex: 1 1 auto;
+  }
 }
 
 .scrollable.fullscreen {
@@ -7724,3 +7733,122 @@ noscript {
     text-align: center;
   }
 }
+
+.explore__search-header {
+  background: $ui-base-color;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding: 15px;
+
+  .search {
+    width: 100%;
+    margin-bottom: 0;
+  }
+
+  .search__input {
+    border-radius: 4px;
+    color: $inverted-text-color;
+    background: $simple-background-color;
+    padding: 10px;
+
+    &::placeholder {
+      color: $dark-text-color;
+    }
+  }
+
+  .search .fa {
+    top: 10px;
+    right: 10px;
+    color: $dark-text-color;
+  }
+
+  .search .fa-times-circle {
+    top: 12px;
+  }
+}
+
+.explore__search-results {
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+}
+
+.story {
+  display: flex;
+  align-items: center;
+  color: $primary-text-color;
+  text-decoration: none;
+  padding: 15px 0;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+  &:last-child {
+    border-bottom: 0;
+  }
+
+  &:hover,
+  &:active,
+  &:focus {
+    background-color: lighten($ui-base-color, 4%);
+  }
+
+  &__details {
+    padding: 0 15px;
+    flex: 1 1 auto;
+
+    &__publisher {
+      color: $darker-text-color;
+      margin-bottom: 4px;
+    }
+
+    &__title {
+      font-size: 19px;
+      line-height: 24px;
+      font-weight: 500;
+      margin-bottom: 4px;
+    }
+
+    &__shared {
+      color: $darker-text-color;
+    }
+  }
+
+  &__thumbnail {
+    flex: 0 0 auto;
+    margin: 0 15px;
+    position: relative;
+    width: 120px;
+    height: 120px;
+
+    .skeleton {
+      width: 100%;
+      height: 100%;
+    }
+
+    img {
+      border-radius: 4px;
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    &__preview {
+      border-radius: 4px;
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: fill;
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 0;
+
+      &--hidden {
+        display: none;
+      }
+    }
+  }
+}
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
index 36bc07a72..1f7e71776 100644
--- a/app/javascript/styles/mastodon/tables.scss
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -210,6 +210,7 @@ a.table-action-link {
     &__content {
       padding-top: 12px;
       padding-bottom: 16px;
+      overflow: hidden;
 
       &--unpadded {
         padding: 0;
@@ -296,3 +297,9 @@ a.table-action-link {
     }
   }
 }
+
+.one-liner {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 12fad8da4..7cd5a41e8 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -23,8 +23,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
         visibility: visibility_from_audience
       )
 
-      Trends.tags.register(@status)
-      Trends.links.register(@status)
+      Trends.register!(@status)
 
       distribute
     end
diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb
index c065f01f8..ebbda15b9 100644
--- a/app/lib/activitypub/activity/like.rb
+++ b/app/lib/activitypub/activity/like.rb
@@ -7,6 +7,8 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
     return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
 
     favourite = original_status.favourites.create!(account: @account)
+
     NotifyService.new.call(original_status.account, :favourite, favourite)
+    Trends.statuses.register(original_status)
   end
 end
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index a9d00c000..f416977d8 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -35,25 +35,18 @@ class AdminMailer < ApplicationMailer
     end
   end
 
-  def new_trending_tags(recipient, tags)
-    @tags                = tags
-    @me                  = recipient
-    @instance            = Rails.configuration.x.local_domain
-    @lowest_trending_tag = Trends.tags.get(true, Trends.tags.options[:review_threshold]).last
+  def new_trends(recipient, links, tags, statuses)
+    @links                  = links
+    @lowest_trending_link   = Trends.links.query.allowed.limit(Trends.links.options[:review_threshold]).last
+    @tags                   = tags
+    @lowest_trending_tag    = Trends.tags.query.allowed.limit(Trends.tags.options[:review_threshold]).last
+    @statuses               = statuses
+    @lowest_trending_status = Trends.statuses.query.allowed.limit(Trends.statuses.options[:review_threshold]).last
+    @me                     = recipient
+    @instance               = Rails.configuration.x.local_domain
 
     locale_for_account(@me) do
-      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tags.subject', instance: @instance)
-    end
-  end
-
-  def new_trending_links(recipient, links)
-    @links                = links
-    @me                   = recipient
-    @instance             = Rails.configuration.x.local_domain
-    @lowest_trending_link = Trends.links.get(true, Trends.links.options[:review_threshold]).last
-
-    locale_for_account(@me) do
-      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_links.subject', instance: @instance)
+      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.subject', instance: @instance)
     end
   end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index 8f6663e7c..8617b389c 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -40,13 +40,15 @@
 #  also_known_as                 :string           is an Array
 #  silenced_at                   :datetime
 #  suspended_at                  :datetime
-#  trust_level                   :integer
 #  hide_collections              :boolean
 #  avatar_storage_schema_version :integer
 #  header_storage_schema_version :integer
 #  devices_url                   :string
 #  suspension_origin             :integer
 #  sensitized_at                 :datetime
+#  trendable                     :boolean
+#  reviewed_at                   :datetime
+#  requested_review_at           :datetime
 #
 
 class Account < ApplicationRecord
@@ -56,6 +58,7 @@ class Account < ApplicationRecord
     remote_url
     salmon_url
     hub_url
+    trust_level
   )
 
   USERNAME_RE   = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
@@ -78,11 +81,6 @@ class Account < ApplicationRecord
   MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i
   MAX_FIELDS = (ENV['MAX_PROFILE_FIELDS'] || 4).to_i
 
-  TRUST_LEVELS = {
-    untrusted: 0,
-    trusted: 1,
-  }.freeze
-
   enum protocol: [:ostatus, :activitypub]
   enum suspension_origin: [:local, :remote], _prefix: true
 
@@ -206,10 +204,6 @@ class Account < ApplicationRecord
     last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
   end
 
-  def trust_level
-    self[:trust_level] || 0
-  end
-
   def refresh!
     ResolveAccountService.new.call(acct) unless local?
   end
@@ -390,6 +384,22 @@ class Account < ApplicationRecord
     @synchronization_uri_prefix ||= "#{uri[URL_PREFIX_RE]}/"
   end
 
+  def requires_review?
+    reviewed_at.nil?
+  end
+
+  def reviewed?
+    reviewed_at.present?
+  end
+
+  def requested_review?
+    requested_review_at.present?
+  end
+
+  def requires_review_notification?
+    requires_review? && !requested_review?
+  end
+
   class Field < ActiveModelSerializers::Model
     attributes :name, :value, :verified_at, :account
 
diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb
index f50fa46ba..36e7e62ab 100644
--- a/app/models/email_domain_block.rb
+++ b/app/models/email_domain_block.rb
@@ -3,11 +3,13 @@
 #
 # Table name: email_domain_blocks
 #
-#  id         :bigint(8)        not null, primary key
-#  domain     :string           default(""), not null
-#  created_at :datetime         not null
-#  updated_at :datetime         not null
-#  parent_id  :bigint(8)
+#  id              :bigint(8)        not null, primary key
+#  domain          :string           default(""), not null
+#  created_at      :datetime         not null
+#  updated_at      :datetime         not null
+#  parent_id       :bigint(8)
+#  ips             :inet             is an Array
+#  last_refresh_at :datetime
 #
 
 class EmailDomainBlock < ApplicationRecord
@@ -18,27 +20,42 @@ class EmailDomainBlock < ApplicationRecord
 
   validates :domain, presence: true, uniqueness: true, domain: true
 
-  def with_dns_records=(val)
-    @with_dns_records = ActiveModel::Type::Boolean.new.cast(val)
-  end
+  # Used for adding multiple blocks at once
+  attr_accessor :other_domains
 
-  def with_dns_records?
-    @with_dns_records
+  def history
+    @history ||= Trends::History.new('email_domain_blocks', id)
   end
 
-  alias with_dns_records with_dns_records?
+  def self.block?(domain_or_domains, ips: [], attempt_ip: nil)
+    domains = Array(domain_or_domains).map do |str|
+      domain = begin
+        if str.include?('@')
+          str.split('@', 2).last
+        else
+          str
+        end
+      end
+
+      TagManager.instance.normalize_domain(domain) if domain.present?
+    rescue Addressable::URI::InvalidURIError
+      nil
+    end
 
-  def self.block?(email)
-    _, domain = email.split('@', 2)
+    # If some of the inputs passed in are invalid, we definitely want to
+    # block the attempt, but we also want to register hits against any
+    # other valid matches
 
-    return true if domain.nil?
+    blocked = domains.any?(&:nil?)
 
-    begin
-      domain = TagManager.instance.normalize_domain(domain)
-    rescue Addressable::URI::InvalidURIError
-      return true
+    scope = where(domain: domains)
+    scope = scope.or(where('ips && ARRAY[?]::inet[]', ips)) if ips.any?
+
+    scope.find_each do |block|
+      blocked = true
+      block.history.add(attempt_ip) if attempt_ip.present?
     end
 
-    where(domain: domain).exists?
+    blocked
   end
 end
diff --git a/app/models/form/email_domain_block_batch.rb b/app/models/form/email_domain_block_batch.rb
new file mode 100644
index 000000000..df120182b
--- /dev/null
+++ b/app/models/form/email_domain_block_batch.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Form::EmailDomainBlockBatch
+  include ActiveModel::Model
+  include Authorization
+  include AccountableConcern
+
+  attr_accessor :email_domain_block_ids, :action, :current_account
+
+  def save
+    case action
+    when 'delete'
+      delete!
+    end
+  end
+
+  private
+
+  def email_domain_blocks
+    @email_domain_blocks ||= EmailDomainBlock.where(id: email_domain_block_ids)
+  end
+
+  def delete!
+    email_domain_blocks.each do |email_domain_block|
+      authorize(email_domain_block, :destroy?)
+      email_domain_block.destroy!
+      log_action :destroy, email_domain_block
+    end
+  end
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index 607b70712..2fe4eedb5 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -27,6 +27,7 @@
 #  content_type           :string
 #  deleted_at             :datetime
 #  edited_at              :datetime
+#  trendable              :boolean
 #
 
 class Status < ApplicationRecord
@@ -274,6 +275,18 @@ class Status < ApplicationRecord
     update_status_stat!(key => [public_send(key) - 1, 0].max)
   end
 
+  def trendable?
+    if attributes['trendable'].nil?
+      account.trendable?
+    else
+      attributes['trendable']
+    end
+  end
+
+  def requires_review_notification?
+    attributes['trendable'].nil? && account.requires_review_notification?
+  end
+
   after_create_commit  :increment_counter_caches
   after_destroy_commit :decrement_counter_caches
 
diff --git a/app/models/trends.rb b/app/models/trends.rb
index 8f8cb0261..f8864e55f 100644
--- a/app/models/trends.rb
+++ b/app/models/trends.rb
@@ -13,15 +13,37 @@ module Trends
     @tags ||= Trends::Tags.new
   end
 
+  def self.statuses
+    @statuses ||= Trends::Statuses.new
+  end
+
+  def self.register!(status)
+    [links, tags, statuses].each { |trend_type| trend_type.register(status) }
+  end
+
   def self.refresh!
-    [links, tags].each(&:refresh)
+    [links, tags, statuses].each(&:refresh)
   end
 
   def self.request_review!
-    [tags].each(&:request_review) if enabled?
+    return unless enabled?
+
+    links_requiring_review    = links.request_review
+    tags_requiring_review     = tags.request_review
+    statuses_requiring_review = statuses.request_review
+
+    return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
+
+    User.staff.includes(:account).find_each do |user|
+      AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
+    end
   end
 
   def self.enabled?
     Setting.trends
   end
+
+  def self.available_locales
+    @available_locales ||= I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq
+  end
 end
diff --git a/app/models/trends/base.rb b/app/models/trends/base.rb
index b767dcb1a..7ed13228d 100644
--- a/app/models/trends/base.rb
+++ b/app/models/trends/base.rb
@@ -2,6 +2,7 @@
 
 class Trends::Base
   include Redisable
+  include LanguagesHelper
 
   class_attribute :default_options
 
@@ -32,8 +33,8 @@ class Trends::Base
     raise NotImplementedError
   end
 
-  def get(*)
-    raise NotImplementedError
+  def query
+    Trends::Query.new(key_prefix, klass)
   end
 
   def score(id)
@@ -72,6 +73,21 @@ class Trends::Base
     redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0
   end
 
+  # @param [Integer] id
+  # @param [Float] score
+  # @param [Hash<String, Boolean>] subsets
+  def add_to_and_remove_from_subsets(id, score, subsets = {})
+    subsets.each_key do |subset|
+      key = [key_prefix, subset].compact.join(':')
+
+      if score.positive? && subsets[subset]
+        redis.zadd(key, score, id)
+      else
+        redis.zrem(key, id)
+      end
+    end
+  end
+
   private
 
   def used_key(at_time)
diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb
index a0d65138b..62308e706 100644
--- a/app/models/trends/links.rb
+++ b/app/models/trends/links.rb
@@ -4,8 +4,8 @@ class Trends::Links < Trends::Base
   PREFIX = 'trending_links'
 
   self.default_options = {
-    threshold: 15,
-    review_threshold: 10,
+    threshold: 5,
+    review_threshold: 3,
     max_score_cooldown: 2.days.freeze,
     max_score_halflife: 8.hours.freeze,
   }
@@ -27,12 +27,6 @@ class Trends::Links < Trends::Base
     record_used_id(preview_card.id, at_time)
   end
 
-  def get(allowed, limit)
-    preview_card_ids = currently_trending_ids(allowed, limit)
-    preview_cards = PreviewCard.where(id: preview_card_ids).index_by(&:id)
-    preview_card_ids.map { |id| preview_cards[id] }.compact
-  end
-
   def refresh(at_time = Time.now.utc)
     preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq)
     calculate_scores(preview_cards, at_time)
@@ -42,7 +36,7 @@ class Trends::Links < Trends::Base
   def request_review
     preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1))
 
-    preview_cards_requiring_review = preview_cards.filter_map do |preview_card|
+    preview_cards.filter_map do |preview_card|
       next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification?
 
       if preview_card.provider.nil?
@@ -53,12 +47,6 @@ class Trends::Links < Trends::Base
 
       preview_card
     end
-
-    return if preview_cards_requiring_review.empty?
-
-    User.staff.includes(:account).find_each do |user|
-      AdminMailer.new_trending_links(user.account, preview_cards_requiring_review).deliver_later! if user.allows_trending_tag_emails?
-    end
   end
 
   protected
@@ -67,6 +55,10 @@ class Trends::Links < Trends::Base
     PREFIX
   end
 
+  def klass
+    PreviewCard
+  end
+
   private
 
   def calculate_scores(preview_cards, at_time)
@@ -96,17 +88,27 @@ class Trends::Links < Trends::Base
 
       decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
 
-      if decaying_score.zero?
-        redis.zrem("#{PREFIX}:all", preview_card.id)
-        redis.zrem("#{PREFIX}:allowed", preview_card.id)
-      else
-        redis.zadd("#{PREFIX}:all", decaying_score, preview_card.id)
+      add_to_and_remove_from_subsets(preview_card.id, decaying_score, {
+        all: true,
+        allowed: preview_card.trendable?,
+      })
 
-        if preview_card.trendable?
-          redis.zadd("#{PREFIX}:allowed", decaying_score, preview_card.id)
-        else
-          redis.zrem("#{PREFIX}:allowed", preview_card.id)
-        end
+      next unless valid_locale?(preview_card.language)
+
+      add_to_and_remove_from_subsets(preview_card.id, decaying_score, {
+        "all:#{preview_card.language}" => true,
+        "allowed:#{preview_card.language}" => preview_card.trendable?,
+      })
+    end
+
+    # Clean up localized sets by calculating the intersection with the main
+    # set. We do this instead of just deleting the localized sets to avoid
+    # having moments where the API returns empty results
+
+    redis.pipelined do
+      Trends.available_locales.each do |locale|
+        redis.zinterstore("#{key_prefix}:all:#{locale}", ["#{key_prefix}:all:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
+        redis.zinterstore("#{key_prefix}:allowed:#{locale}", ["#{key_prefix}:allowed:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
       end
     end
   end
diff --git a/app/models/form/preview_card_batch.rb b/app/models/trends/preview_card_batch.rb
index 5f6e6522a..b1d682910 100644
--- a/app/models/form/preview_card_batch.rb
+++ b/app/models/trends/preview_card_batch.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class Form::PreviewCardBatch
+class Trends::PreviewCardBatch
   include ActiveModel::Model
   include Authorization
 
@@ -10,12 +10,12 @@ class Form::PreviewCardBatch
     case action
     when 'approve'
       approve!
-    when 'approve_all'
-      approve_all!
+    when 'approve_providers'
+      approve_providers!
     when 'reject'
       reject!
-    when 'reject_all'
-      reject_all!
+    when 'reject_providers'
+      reject_providers!
     end
   end
 
@@ -30,13 +30,13 @@ class Form::PreviewCardBatch
   end
 
   def approve!
-    preview_cards.each { |preview_card| authorize(preview_card, :update?) }
+    preview_cards.each { |preview_card| authorize(preview_card, :review?) }
     preview_cards.update_all(trendable: true)
   end
 
-  def approve_all!
+  def approve_providers!
     preview_card_providers.each do |provider|
-      authorize(provider, :update?)
+      authorize(provider, :review?)
       provider.update(trendable: true, reviewed_at: action_time)
     end
 
@@ -45,13 +45,13 @@ class Form::PreviewCardBatch
   end
 
   def reject!
-    preview_cards.each { |preview_card| authorize(preview_card, :update?) }
+    preview_cards.each { |preview_card| authorize(preview_card, :review?) }
     preview_cards.update_all(trendable: false)
   end
 
-  def reject_all!
+  def reject_providers!
     preview_card_providers.each do |provider|
-      authorize(provider, :update?)
+      authorize(provider, :review?)
       provider.update(trendable: false, reviewed_at: action_time)
     end
 
diff --git a/app/models/preview_card_filter.rb b/app/models/trends/preview_card_filter.rb
index 8dda9989c..25add58c8 100644
--- a/app/models/preview_card_filter.rb
+++ b/app/models/trends/preview_card_filter.rb
@@ -1,8 +1,9 @@
 # frozen_string_literal: true
 
-class PreviewCardFilter
+class Trends::PreviewCardFilter
   KEYS = %i(
     trending
+    locale
   ).freeze
 
   attr_reader :params
@@ -15,7 +16,7 @@ class PreviewCardFilter
     scope = PreviewCard.unscoped
 
     params.each do |key, value|
-      next if key.to_s == 'page'
+      next if %w(page locale).include?(key.to_s)
 
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
     end
@@ -35,19 +36,11 @@ class PreviewCardFilter
   end
 
   def trending_scope(value)
-    ids = begin
-      case value.to_s
-      when 'allowed'
-        Trends.links.currently_trending_ids(true, -1)
-      else
-        Trends.links.currently_trending_ids(false, -1)
-      end
-    end
+    scope = Trends.links.query
 
-    if ids.empty?
-      PreviewCard.none
-    else
-      PreviewCard.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on preview_cards.id = x.id").order('x.ordering')
-    end
+    scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
+    scope = scope.allowed if value == 'allowed'
+
+    scope.to_arel
   end
 end
diff --git a/app/models/form/preview_card_provider_batch.rb b/app/models/trends/preview_card_provider_batch.rb
index e6ab3d8fa..062720c81 100644
--- a/app/models/form/preview_card_provider_batch.rb
+++ b/app/models/trends/preview_card_provider_batch.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class Form::PreviewCardProviderBatch
+class Trends::PreviewCardProviderBatch
   include ActiveModel::Model
   include Authorization
 
@@ -22,12 +22,12 @@ class Form::PreviewCardProviderBatch
   end
 
   def approve!
-    preview_card_providers.each { |provider| authorize(provider, :update?) }
+    preview_card_providers.each { |provider| authorize(provider, :review?) }
     preview_card_providers.update_all(trendable: true, reviewed_at: Time.now.utc)
   end
 
   def reject!
-    preview_card_providers.each { |provider| authorize(provider, :update?) }
+    preview_card_providers.each { |provider| authorize(provider, :review?) }
     preview_card_providers.update_all(trendable: false, reviewed_at: Time.now.utc)
   end
 end
diff --git a/app/models/preview_card_provider_filter.rb b/app/models/trends/preview_card_provider_filter.rb
index 1e90d3c9d..abfdd07e8 100644
--- a/app/models/preview_card_provider_filter.rb
+++ b/app/models/trends/preview_card_provider_filter.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class PreviewCardProviderFilter
+class Trends::PreviewCardProviderFilter
   KEYS = %i(
     status
   ).freeze
diff --git a/app/models/trends/query.rb b/app/models/trends/query.rb
new file mode 100644
index 000000000..64a4c0c1f
--- /dev/null
+++ b/app/models/trends/query.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class Trends::Query
+  include Redisable
+  include Enumerable
+
+  attr_reader :prefix, :klass, :loaded
+
+  alias loaded? loaded
+
+  def initialize(prefix, klass)
+    @prefix  = prefix
+    @klass   = klass
+    @records = []
+    @loaded  = false
+    @allowed = false
+    @limit   = -1
+    @offset  = 0
+  end
+
+  def allowed!
+    @allowed = true
+    self
+  end
+
+  def allowed
+    clone.allowed!
+  end
+
+  def in_locale!(value)
+    @locale = value
+    self
+  end
+
+  def in_locale(value)
+    clone.in_locale!(value)
+  end
+
+  def offset!(value)
+    @offset = value
+    self
+  end
+
+  def offset(value)
+    clone.offset!(value)
+  end
+
+  def limit!(value)
+    @limit = value
+    self
+  end
+
+  def limit(value)
+    clone.limit!(value)
+  end
+
+  def records
+    load
+    @records
+  end
+
+  delegate :each, :empty?, :first, :last, to: :records
+
+  def to_ary
+    records.dup
+  end
+
+  alias to_a to_ary
+
+  def to_arel
+    tmp_ids = ids
+
+    if tmp_ids.empty?
+      klass.none
+    else
+      klass.joins("join unnest(array[#{tmp_ids.join(',')}]) with ordinality as x (id, ordering) on #{klass.table_name}.id = x.id").reorder('x.ordering')
+    end
+  end
+
+  private
+
+  def key
+    [@prefix, @allowed ? 'allowed' : 'all', @locale].compact.join(':')
+  end
+
+  def load
+    unless loaded?
+      @records = perform_queries
+      @loaded  = true
+    end
+
+    self
+  end
+
+  def ids
+    redis.zrevrange(key, @offset, @limit.positive? ? @limit - 1 : @limit).map(&:to_i)
+  end
+
+  def perform_queries
+    apply_scopes(to_arel).to_a
+  end
+
+  def apply_scopes(scope)
+    scope
+  end
+end
diff --git a/app/models/trends/status_batch.rb b/app/models/trends/status_batch.rb
new file mode 100644
index 000000000..78d93bed4
--- /dev/null
+++ b/app/models/trends/status_batch.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class Trends::StatusBatch
+  include ActiveModel::Model
+  include Authorization
+
+  attr_accessor :status_ids, :action, :current_account
+
+  def save
+    case action
+    when 'approve'
+      approve!
+    when 'approve_accounts'
+      approve_accounts!
+    when 'reject'
+      reject!
+    when 'reject_accounts'
+      reject_accounts!
+    end
+  end
+
+  private
+
+  def statuses
+    @statuses ||= Status.where(id: status_ids)
+  end
+
+  def status_accounts
+    @status_accounts ||= Account.where(id: statuses.map(&:account_id).uniq)
+  end
+
+  def approve!
+    statuses.each { |status| authorize(status, :review?) }
+    statuses.update_all(trendable: true)
+  end
+
+  def approve_accounts!
+    status_accounts.each do |account|
+      authorize(account, :review?)
+      account.update(trendable: true, reviewed_at: action_time)
+    end
+
+    # Reset any individual overrides
+    statuses.update_all(trendable: nil)
+  end
+
+  def reject!
+    statuses.each { |status| authorize(status, :review?) }
+    statuses.update_all(trendable: false)
+  end
+
+  def reject_accounts!
+    status_accounts.each do |account|
+      authorize(account, :review?)
+      account.update(trendable: false, reviewed_at: action_time)
+    end
+
+    # Reset any individual overrides
+    statuses.update_all(trendable: nil)
+  end
+
+  def action_time
+    @action_time ||= Time.now.utc
+  end
+end
diff --git a/app/models/trends/status_filter.rb b/app/models/trends/status_filter.rb
new file mode 100644
index 000000000..7c453e339
--- /dev/null
+++ b/app/models/trends/status_filter.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+class Trends::StatusFilter
+  KEYS = %i(
+    trending
+    locale
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = Status.unscoped.kept
+
+    params.each do |key, value|
+      next if %w(page locale).include?(key.to_s)
+
+      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 'trending'
+      trending_scope(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+
+  def trending_scope(value)
+    scope = Trends.statuses.query
+
+    scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
+    scope = scope.allowed if value == 'allowed'
+
+    scope.to_arel
+  end
+end
diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb
new file mode 100644
index 000000000..e785413ec
--- /dev/null
+++ b/app/models/trends/statuses.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+class Trends::Statuses < Trends::Base
+  PREFIX = 'trending_statuses'
+
+  self.default_options = {
+    threshold: 5,
+    review_threshold: 3,
+    score_halflife: 2.hours.freeze,
+  }
+
+  class Query < Trends::Query
+    def filtered_for!(account)
+      @account = account
+      self
+    end
+
+    def filtered_for(account)
+      clone.filtered_for!(account)
+    end
+
+    private
+
+    def apply_scopes(scope)
+      scope.includes(:account)
+    end
+
+    def perform_queries
+      return super if @account.nil?
+
+      statuses        = super
+      account_ids     = statuses.map(&:account_id)
+      account_domains = statuses.map(&:account_domain)
+
+      preloaded_relations = {
+        blocking: Account.blocking_map(account_ids, @account.id),
+        blocked_by: Account.blocked_by_map(account_ids, @account.id),
+        muting: Account.muting_map(account_ids, @account.id),
+        following: Account.following_map(account_ids, @account.id),
+        domain_blocking_by_domain: Account.domain_blocking_map_by_domain(account_domains, @account.id),
+      }
+
+      statuses.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
+    end
+  end
+
+  def register(status, at_time = Time.now.utc)
+    add(status.proper, status.account_id, at_time) if eligible?(status)
+  end
+
+  def add(status, _account_id, at_time = Time.now.utc)
+    # We rely on the total reblogs and favourites count, so we
+    # don't record which account did the what and when here
+
+    record_used_id(status.id, at_time)
+  end
+
+  def query
+    Query.new(key_prefix, klass)
+  end
+
+  def refresh(at_time = Time.now.utc)
+    statuses = Status.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq).includes(:account, :media_attachments)
+    calculate_scores(statuses, at_time)
+    trim_older_items
+  end
+
+  def request_review
+    statuses = Status.where(id: currently_trending_ids(false, -1)).includes(:account)
+
+    statuses.filter_map do |status|
+      next unless would_be_trending?(status.id) && !status.trendable? && status.requires_review_notification?
+
+      status.account.touch(:requested_review_at)
+      status
+    end
+  end
+
+  protected
+
+  def key_prefix
+    PREFIX
+  end
+
+  def klass
+    Status
+  end
+
+  private
+
+  def eligible?(status)
+    original_status = status.proper
+
+    original_status.public_visibility? &&
+      original_status.account.discoverable? && !original_status.account.silenced? &&
+      original_status.spoiler_text.blank? && !original_status.sensitive? && !original_status.reply?
+  end
+
+  def calculate_scores(statuses, at_time)
+    redis.pipelined do
+      statuses.each do |status|
+        expected  = 1.0
+        observed  = (status.reblogs_count + status.favourites_count).to_f
+
+        score = begin
+          if expected > observed || observed < options[:threshold]
+            0
+          else
+            ((observed - expected)**2) / expected
+          end
+        end
+
+        decaying_score = score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
+
+        add_to_and_remove_from_subsets(status.id, decaying_score, {
+          all: true,
+          allowed: status.trendable? && status.account.discoverable?,
+        })
+
+        next unless valid_locale?(status.language)
+
+        add_to_and_remove_from_subsets(status.id, decaying_score, {
+          "all:#{status.language}" => true,
+          "allowed:#{status.language}" => status.trendable? && status.account.discoverable?,
+        })
+      end
+
+      # Clean up localized sets by calculating the intersection with the main
+      # set. We do this instead of just deleting the localized sets to avoid
+      # having moments where the API returns empty results
+
+      Trends.available_locales.each do |locale|
+        redis.zinterstore("#{key_prefix}:all:#{locale}", ["#{key_prefix}:all:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
+        redis.zinterstore("#{key_prefix}:allowed:#{locale}", ["#{key_prefix}:allowed:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
+      end
+    end
+  end
+
+  def would_be_trending?(id)
+    score(id) > score_at_rank(options[:review_threshold] - 1)
+  end
+end
diff --git a/app/models/form/tag_batch.rb b/app/models/trends/tag_batch.rb
index b9330745f..16ee08c06 100644
--- a/app/models/form/tag_batch.rb
+++ b/app/models/trends/tag_batch.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class Form::TagBatch
+class Trends::TagBatch
   include ActiveModel::Model
   include Authorization
 
@@ -22,12 +22,12 @@ class Form::TagBatch
   end
 
   def approve!
-    tags.each { |tag| authorize(tag, :update?) }
+    tags.each { |tag| authorize(tag, :review?) }
     tags.update_all(trendable: true, reviewed_at: action_time)
   end
 
   def reject!
-    tags.each { |tag| authorize(tag, :update?) }
+    tags.each { |tag| authorize(tag, :review?) }
     tags.update_all(trendable: false, reviewed_at: action_time)
   end
 
diff --git a/app/models/tag_filter.rb b/app/models/trends/tag_filter.rb
index ecdb52503..3b142efc4 100644
--- a/app/models/tag_filter.rb
+++ b/app/models/trends/tag_filter.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class TagFilter
+class Trends::TagFilter
   KEYS = %i(
     trending
     status
@@ -42,13 +42,7 @@ class TagFilter
   end
 
   def trending_scope
-    ids = Trends.tags.currently_trending_ids(false, -1)
-
-    if ids.empty?
-      Tag.none
-    else
-      Tag.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on tags.id = x.id").order('x.ordering')
-    end
+    Trends.tags.query.to_arel
   end
 
   def status_scope(value)
diff --git a/app/models/trends/tags.rb b/app/models/trends/tags.rb
index 2ea4550df..3caa58815 100644
--- a/app/models/trends/tags.rb
+++ b/app/models/trends/tags.rb
@@ -5,7 +5,7 @@ class Trends::Tags < Trends::Base
 
   self.default_options = {
     threshold: 5,
-    review_threshold: 10,
+    review_threshold: 3,
     max_score_cooldown: 2.days.freeze,
     max_score_halflife: 4.hours.freeze,
   }
@@ -29,27 +29,15 @@ class Trends::Tags < Trends::Base
     trim_older_items
   end
 
-  def get(allowed, limit)
-    tag_ids = currently_trending_ids(allowed, limit)
-    tags = Tag.where(id: tag_ids).index_by(&:id)
-    tag_ids.map { |id| tags[id] }.compact
-  end
-
   def request_review
     tags = Tag.where(id: currently_trending_ids(false, -1))
 
-    tags_requiring_review = tags.filter_map do |tag|
+    tags.filter_map do |tag|
       next unless would_be_trending?(tag.id) && !tag.trendable? && tag.requires_review_notification?
 
       tag.touch(:requested_review_at)
       tag
     end
-
-    return if tags_requiring_review.empty?
-
-    User.staff.includes(:account).find_each do |user|
-      AdminMailer.new_trending_tags(user.account, tags_requiring_review).deliver_later! if user.allows_trending_tag_emails?
-    end
   end
 
   protected
@@ -58,6 +46,10 @@ class Trends::Tags < Trends::Base
     PREFIX
   end
 
+  def klass
+    Tag
+  end
+
   private
 
   def calculate_scores(tags, at_time)
@@ -87,18 +79,10 @@ class Trends::Tags < Trends::Base
 
       decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
 
-      if decaying_score.zero?
-        redis.zrem("#{PREFIX}:all", tag.id)
-        redis.zrem("#{PREFIX}:allowed", tag.id)
-      else
-        redis.zadd("#{PREFIX}:all", decaying_score, tag.id)
-
-        if tag.trendable?
-          redis.zadd("#{PREFIX}:allowed", decaying_score, tag.id)
-        else
-          redis.zrem("#{PREFIX}:allowed", tag.id)
-        end
-      end
+      add_to_and_remove_from_subsets(tag.id, decaying_score, {
+        all: true,
+        allowed: tag.trendable?,
+      })
     end
   end
 
diff --git a/app/models/user.rb b/app/models/user.rb
index a21e96ae5..cb03e99a0 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -269,7 +269,7 @@ class User < ApplicationRecord
     settings.notification_emails['appeal']
   end
 
-  def allows_trending_tag_emails?
+  def allows_trends_review_emails?
     settings.notification_emails['trending_tag']
   end
 
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 46237e45c..cc23771e7 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -68,4 +68,8 @@ class AccountPolicy < ApplicationPolicy
   def unblock_email?
     staff?
   end
+
+  def review?
+    staff?
+  end
 end
diff --git a/app/policies/preview_card_policy.rb b/app/policies/preview_card_policy.rb
index 4f485d7fc..0410987e4 100644
--- a/app/policies/preview_card_policy.rb
+++ b/app/policies/preview_card_policy.rb
@@ -5,7 +5,7 @@ class PreviewCardPolicy < ApplicationPolicy
     staff?
   end
 
-  def update?
+  def review?
     staff?
   end
 end
diff --git a/app/policies/preview_card_provider_policy.rb b/app/policies/preview_card_provider_policy.rb
index 598d54a5e..44d2ad5cf 100644
--- a/app/policies/preview_card_provider_policy.rb
+++ b/app/policies/preview_card_provider_policy.rb
@@ -5,7 +5,7 @@ class PreviewCardProviderPolicy < ApplicationPolicy
     staff?
   end
 
-  def update?
+  def review?
     staff?
   end
 end
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 8746fb2c6..d3a3b36c0 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -42,6 +42,10 @@ class StatusPolicy < ApplicationPolicy
     staff? || owned?
   end
 
+  def review?
+    staff?
+  end
+
   private
 
   def requires_mention?
diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb
index aaf70fcab..bdfcec0c9 100644
--- a/app/policies/tag_policy.rb
+++ b/app/policies/tag_policy.rb
@@ -12,4 +12,8 @@ class TagPolicy < ApplicationPolicy
   def update?
     staff?
   end
+
+  def review?
+    staff?
+  end
 end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 70fb2ba90..4163bb098 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -15,7 +15,7 @@ class StatusRelationshipsPresenter
       statuses            = statuses.compact
       status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
       conversation_ids    = statuses.filter_map(&:conversation_id).uniq
-      pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted).include?(s.visibility) }
+      pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) }
 
       @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
       @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb
index a572a7c59..a2d535d26 100644
--- a/app/services/delete_account_service.rb
+++ b/app/services/delete_account_service.rb
@@ -220,21 +220,23 @@ class DeleteAccountService < BaseService
 
     return unless keep_account_record?
 
-    @account.silenced_at       = nil
-    @account.suspended_at      = @options[:suspended_at] || Time.now.utc
-    @account.suspension_origin = :local
-    @account.locked            = false
-    @account.memorial          = false
-    @account.discoverable      = false
-    @account.display_name      = ''
-    @account.note              = ''
-    @account.fields            = []
-    @account.statuses_count    = 0
-    @account.followers_count   = 0
-    @account.following_count   = 0
-    @account.moved_to_account  = nil
-    @account.also_known_as     = []
-    @account.trust_level       = :untrusted
+    @account.silenced_at         = nil
+    @account.suspended_at        = @options[:suspended_at] || Time.now.utc
+    @account.suspension_origin   = :local
+    @account.locked              = false
+    @account.memorial            = false
+    @account.discoverable        = false
+    @account.trendable           = false
+    @account.display_name        = ''
+    @account.note                = ''
+    @account.fields              = []
+    @account.statuses_count      = 0
+    @account.followers_count     = 0
+    @account.following_count     = 0
+    @account.moved_to_account    = nil
+    @account.reviewed_at         = nil
+    @account.requested_review_at = nil
+    @account.also_known_as       = []
     @account.avatar.destroy
     @account.header.destroy
     @account.save!
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index a0ab3b4b7..0ca0081b4 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -17,6 +17,8 @@ class FavouriteService < BaseService
 
     favourite = Favourite.create!(account: account, status: status)
 
+    Trends.statuses.register(status)
+
     create_notification(favourite)
     bump_potential_friendship(account, status)
 
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 6556fbff7..97f9f8e92 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -30,8 +30,7 @@ class ReblogService < BaseService
 
     reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
 
-    Trends.tags.register(reblog)
-    Trends.links.register(reblog)
+    Trends.register!(reblog)
     DistributionWorker.perform_async(reblog.id)
     ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
 
diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb
index eb66ad93d..9b3f2e33e 100644
--- a/app/validators/blacklisted_email_validator.rb
+++ b/app/validators/blacklisted_email_validator.rb
@@ -4,41 +4,39 @@ class BlacklistedEmailValidator < ActiveModel::Validator
   def validate(user)
     return if user.valid_invitation? || user.email.blank?
 
-    @email = user.email
-
-    user.errors.add(:email, :blocked) if blocked_email_provider?
-    user.errors.add(:email, :taken) if blocked_canonical_email?
+    user.errors.add(:email, :blocked) if blocked_email_provider?(user.email, user.sign_up_ip)
+    user.errors.add(:email, :taken) if blocked_canonical_email?(user.email)
   end
 
   private
 
-  def blocked_email_provider?
-    disallowed_through_email_domain_block? || disallowed_through_configuration? || not_allowed_through_configuration?
+  def blocked_email_provider?(email, ip)
+    disallowed_through_email_domain_block?(email, ip) || disallowed_through_configuration?(email) || not_allowed_through_configuration?(email)
   end
 
-  def blocked_canonical_email?
-    CanonicalEmailBlock.block?(@email)
+  def blocked_canonical_email?(email)
+    CanonicalEmailBlock.block?(email)
   end
 
-  def disallowed_through_email_domain_block?
-    EmailDomainBlock.block?(@email)
+  def disallowed_through_email_domain_block?(email, ip)
+    EmailDomainBlock.block?(email, attempt_ip: ip)
   end
 
-  def not_allowed_through_configuration?
+  def not_allowed_through_configuration?(email)
     return false if Rails.configuration.x.email_domains_whitelist.blank?
 
     domains = Rails.configuration.x.email_domains_whitelist.gsub('.', '\.')
     regexp  = Regexp.new("@(.+\\.)?(#{domains})$", true)
 
-    @email !~ regexp
+    email !~ regexp
   end
 
-  def disallowed_through_configuration?
+  def disallowed_through_configuration?(email)
     return false if Rails.configuration.x.email_domains_blacklist.blank?
 
     domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
     regexp  = Regexp.new("@(.+\\.)?(#{domains})", true)
 
-    regexp.match?(@email)
+    regexp.match?(email)
   end
 end
diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb
index dceef5029..237ca4c7b 100644
--- a/app/validators/email_mx_validator.rb
+++ b/app/validators/email_mx_validator.rb
@@ -11,11 +11,11 @@ class EmailMxValidator < ActiveModel::Validator
     if domain.blank?
       user.errors.add(:email, :invalid)
     elsif !on_allowlist?(domain)
-      ips, hostnames = resolve_mx(domain)
+      resolved_ips, resolved_domains = resolve_mx(domain)
 
-      if ips.empty?
+      if resolved_ips.empty?
         user.errors.add(:email, :unreachable)
-      elsif on_blacklist?(hostnames + ips)
+      elsif on_blacklist?(resolved_domains, resolved_ips, user.sign_up_ip)
         user.errors.add(:email, :blocked)
       end
     end
@@ -40,24 +40,24 @@ class EmailMxValidator < ActiveModel::Validator
   end
 
   def resolve_mx(domain)
-    hostnames = []
-    ips       = []
+    records = []
+    ips     = []
 
     Resolv::DNS.open do |dns|
       dns.timeouts = 5
 
-      hostnames = dns.getresources(domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }
+      records = dns.getresources(domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }
 
-      ([domain] + hostnames).uniq.each do |hostname|
+      ([domain] + records).uniq.each do |hostname|
         ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s })
         ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).to_a.map { |e| e.address.to_s })
       end
     end
 
-    [ips, hostnames]
+    [ips, records]
   end
 
-  def on_blacklist?(values)
-    EmailDomainBlock.where(domain: values.uniq).any?
+  def on_blacklist?(domains, resolved_ips, attempt_ip)
+    EmailDomainBlock.block?(domains, ips: resolved_ips, attempt_ip: attempt_ip)
   end
 end
diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml
index 526c844e9..41f3975cf 100644
--- a/app/views/admin/custom_emojis/_custom_emoji.html.haml
+++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml
@@ -3,7 +3,7 @@
     = f.check_box :custom_emoji_ids, { multiple: true, include_hidden: false }, custom_emoji.id
   .batch-table__row__content.batch-table__row__content--with-image
     .batch-table__row__content__image
-      = custom_emoji_tag(custom_emoji, animate = current_account&.user&.setting_auto_play_gif)
+      = custom_emoji_tag(custom_emoji, current_account&.user&.setting_auto_play_gif)
 
     .batch-table__row__content__text
       %samp= ":#{custom_emoji.shortcode}:"
diff --git a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
index 41ab8c171..c5a55bc27 100644
--- a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
+++ b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
@@ -1,15 +1,14 @@
-%tr
-  %td
-    %samp= email_domain_block.domain
-  %td
-    = table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(email_domain_block), method: :delete
+.batch-table__row
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :email_domain_block_ids, { multiple: true, include_hidden: false }, email_domain_block.id
+  .batch-table__row__content.pending-account
+    .pending-account__header
+      %samp= link_to email_domain_block.domain, admin_accounts_path(email: "%@#{email_domain_block.domain}")
 
-- email_domain_block.children.each do |child_email_domain_block|
-  %tr
-    %td
-      %samp= child_email_domain_block.domain
-      %span.muted-hint
-        = surround '(', ')' do
-          = t('admin.email_domain_blocks.from_html', domain: content_tag(:samp, email_domain_block.domain))
-    %td
-      = table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(child_email_domain_block), method: :delete
+      %br/
+
+      - if email_domain_block.parent.present?
+        = t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain))
+        •
+
+      = t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts })
diff --git a/app/views/admin/email_domain_blocks/index.html.haml b/app/views/admin/email_domain_blocks/index.html.haml
index fa5d86b67..b073e8716 100644
--- a/app/views/admin/email_domain_blocks/index.html.haml
+++ b/app/views/admin/email_domain_blocks/index.html.haml
@@ -4,16 +4,22 @@
 - content_for :heading_actions do
   = link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button'
 
-- if @email_domain_blocks.empty?
-  %div.muted-hint.center-text=t 'admin.email_domain_blocks.empty'
-- else
-  .table-wrapper
-    %table.table
-      %thead
-        %tr
-          %th= t('admin.email_domain_blocks.domain')
-          %th
-      %tbody
-        = render partial: 'email_domain_block', collection: @email_domain_blocks
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+= form_for(@form, url: batch_admin_email_domain_blocks_path) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        = f.button safe_join([fa_icon('times'), t('admin.email_domain_blocks.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+    .batch-table__body
+      - if @email_domain_blocks.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'email_domain_block', collection: @email_domain_blocks.flat_map { |x| [x, x.children.to_a].flatten }, locals: { f: f }
 
 = paginate @email_domain_blocks
diff --git a/app/views/admin/email_domain_blocks/new.html.haml b/app/views/admin/email_domain_blocks/new.html.haml
index 4a346f240..524b69968 100644
--- a/app/views/admin/email_domain_blocks/new.html.haml
+++ b/app/views/admin/email_domain_blocks/new.html.haml
@@ -1,14 +1,38 @@
 - content_for :page_title do
   = t('.title')
 
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
 = simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f|
   = render 'shared/error_messages', object: @email_domain_block
 
   .fields-group
-    = f.input :domain, wrapper: :with_block_label, label: t('admin.email_domain_blocks.domain')
+    = f.input :domain, wrapper: :with_block_label, label: t('admin.email_domain_blocks.domain'), input_html: { readonly: defined?(@resolved_records) }
 
-  .fields-group
-    = f.input :with_dns_records, as: :boolean, wrapper: :with_label
+  - if defined?(@resolved_records)
+    %p.hint= t('admin.email_domain_blocks.resolved_dns_records_hint_html')
+
+    .batch-table
+      .batch-table__toolbar
+        %label.batch-table__toolbar__select.batch-checkbox-all
+          = check_box_tag :batch_checkbox_all, nil, false
+        .batch-table__toolbar__actions
+      .batch-table__body
+        - @resolved_records.each do |record|
+          .batch-table__row
+            %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+              = f.input_field :other_domains, as: :boolean, checked_value: record.exchange.to_s, include_hidden: false, multiple: true
+            .batch-table__row__content.pending-account
+              .pending-account__header
+                %samp= record.exchange.to_s
+                %br
+                = t('admin.email_domain_blocks.dns.types.mx')
+
+    %hr.spacer/
 
   .actions
-    = f.button :button, t('.create'), type: :submit
+    - if defined?(@resolved_records)
+      = f.button :button, t('.create'), type: :submit, name: :save
+    - else
+      = f.button :button, t('.resolve'), type: :submit, name: :resolve
diff --git a/app/views/admin/follow_recommendations/show.html.haml b/app/views/admin/follow_recommendations/show.html.haml
index 85dee210a..dc65a7213 100644
--- a/app/views/admin/follow_recommendations/show.html.haml
+++ b/app/views/admin/follow_recommendations/show.html.haml
@@ -6,12 +6,14 @@
 %hr.spacer/
 
 = form_tag admin_follow_recommendations_path, method: 'GET', class: 'simple_form' do
+  - RelationshipFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
+
   .filters
     .filter-subset.filter-subset--with-select
       %strong= t('admin.follow_recommendations.language')
       .input.select.optional
-        = select_tag :language, options_for_select(I18n.available_locales.map { |key| key.to_s.split(/[_-]/).first.to_sym }.uniq.map { |key| [standard_locale_name(key), key]}, @language)
-
+        = select_tag :language, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key] }, @language)
     .filter-subset
       %strong= t('admin.follow_recommendations.status')
       %ul
diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml
index acd2b0466..e05f877b0 100644
--- a/app/views/admin/trends/links/index.html.haml
+++ b/app/views/admin/trends/links/index.html.haml
@@ -1,23 +1,29 @@
 - content_for :page_title do
   = t('admin.trends.links.title')
 
-.filters
-  .filter-subset
-    %strong= t('admin.trends.trending')
-    %ul
-      %li= filter_link_to t('generic.all'), trending: nil
-      %li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed'
-  .back-link
-    = link_to admin_trends_links_preview_card_providers_path do
-      = t('admin.trends.preview_card_providers.title')
-      = fa_icon 'chevron-right fw'
+= form_tag admin_trends_links_path, method: 'GET', class: 'simple_form' do
+  - Trends::PreviewCardFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
 
-%hr.spacer/
+  .filters
+    .filter-subset.filter-subset--with-select
+      %strong= t('admin.follow_recommendations.language')
+      .input.select.optional
+        = select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key] }, params[:locale]), include_blank: true
+    .filter-subset
+      %strong= t('admin.trends.trending')
+      %ul
+        %li= filter_link_to t('generic.all'), trending: nil
+        %li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed'
+    .back-link
+      = link_to admin_trends_links_preview_card_providers_path do
+        = t('admin.trends.preview_card_providers.title')
+        = fa_icon 'chevron-right fw'
 
 = form_for(@form, url: batch_admin_trends_links_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
 
-  - PreviewCardFilter::KEYS.each do |key|
+  - Trends::PreviewCardFilter::KEYS.each do |key|
     = hidden_field_tag key, params[key] if params[key].present?
 
   .batch-table
@@ -26,9 +32,9 @@
         = check_box_tag :batch_checkbox_all, nil, false
       .batch-table__toolbar__actions
         = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow_provider')]), name: :approve_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow_provider')]), name: :approve_providers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
         = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow_provider')]), name: :reject_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow_provider')]), name: :reject_providers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
     .batch-table__body
       - if @preview_cards.empty?
         = nothing_here 'nothing-here--under-tabs'
diff --git a/app/views/admin/trends/links/preview_card_providers/index.html.haml b/app/views/admin/trends/links/preview_card_providers/index.html.haml
index df54f58ba..13c279b53 100644
--- a/app/views/admin/trends/links/preview_card_providers/index.html.haml
+++ b/app/views/admin/trends/links/preview_card_providers/index.html.haml
@@ -20,7 +20,7 @@
 = form_for(@form, url: batch_admin_trends_links_preview_card_providers_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
 
-  - PreviewCardProviderFilter::KEYS.each do |key|
+  - Trends::PreviewCardProviderFilter::KEYS.each do |key|
     = hidden_field_tag key, params[key] if params[key].present?
 
   .batch-table.optional
diff --git a/app/views/admin/trends/statuses/_status.html.haml b/app/views/admin/trends/statuses/_status.html.haml
new file mode 100644
index 000000000..c99ee5d60
--- /dev/null
+++ b/app/views/admin/trends/statuses/_status.html.haml
@@ -0,0 +1,30 @@
+.batch-table__row{ class: [status.account.requires_review? && 'batch-table__row--attention', !status.account.requires_review? && !status.trendable? && 'batch-table__row--muted'] }
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
+
+  .batch-table__row__content.pending-account__header
+    .one-liner
+      = admin_account_link_to status.account
+
+      = link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank', class: 'emojify', rel: 'noopener noreferrer' do
+        = one_line_preview(status)
+
+        - status.media_attachments.each do |media_attachment|
+          %abbr{ title: media_attachment.description }
+            = fa_icon 'link'
+            = media_attachment.file_file_name
+
+    = t('admin.trends.statuses.shared_by', count: status.reblogs_count + status.favourites_count, friendly_count: friendly_number_to_human(status.reblogs_count + status.favourites_count))
+
+    - if status.account.domain.present?
+      •
+      = status.account.domain
+    - if status.language.present?
+      •
+      = standard_locale_name(status.language)
+    - if status.trendable? && (rank = Trends.statuses.rank(status.id))
+      •
+      %abbr{ title: t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
+    - elsif status.account.requires_review?
+      •
+      = t('admin.trends.pending_review')
diff --git a/app/views/admin/trends/statuses/index.html.haml b/app/views/admin/trends/statuses/index.html.haml
new file mode 100644
index 000000000..347688262
--- /dev/null
+++ b/app/views/admin/trends/statuses/index.html.haml
@@ -0,0 +1,43 @@
+- content_for :page_title do
+  = t('admin.trends.statuses.title')
+
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+= form_tag admin_trends_statuses_path, method: 'GET', class: 'simple_form' do
+  - Trends::StatusFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .filters
+    .filter-subset.filter-subset--with-select
+      %strong= t('admin.follow_recommendations.language')
+      .input.select.optional
+        = select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key]}, params[:locale]), include_blank: true
+    .filter-subset
+      %strong= t('admin.trends.trending')
+      %ul
+        %li= filter_link_to t('generic.all'), trending: nil
+        %li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed'
+
+= form_for(@form, url: batch_admin_trends_statuses_path) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  - Trends::StatusFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        = f.button safe_join([fa_icon('check'), t('admin.trends.statuses.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('check'), t('admin.trends.statuses.allow_account')]), name: :approve_accounts, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.trends.statuses.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.trends.statuses.disallow_account')]), name: :reject_accounts, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+    .batch-table__body
+      - if @statuses.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'status', collection: @statuses, locals: { f: f }
+
+= paginate @statuses
diff --git a/app/views/admin/trends/tags/index.html.haml b/app/views/admin/trends/tags/index.html.haml
index 99ad5490f..3433b8dd4 100644
--- a/app/views/admin/trends/tags/index.html.haml
+++ b/app/views/admin/trends/tags/index.html.haml
@@ -10,12 +10,10 @@
       %li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
       %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), status: 'pending_review'
 
-%hr.spacer/
-
 = form_for(@form, url: batch_admin_trends_tags_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
 
-  - TagFilter::KEYS.each do |key|
+  - Trends::TagFilter::KEYS.each do |key|
     = hidden_field_tag key, params[key] if params[key].present?
 
   .batch-table.optional
diff --git a/app/views/admin_mailer/_new_trending_links.text.erb b/app/views/admin_mailer/_new_trending_links.text.erb
new file mode 100644
index 000000000..405926fdd
--- /dev/null
+++ b/app/views/admin_mailer/_new_trending_links.text.erb
@@ -0,0 +1,14 @@
+<%= raw t('admin_mailer.new_trends.new_trending_links.title') %>
+
+<% @links.each do |link| %>
+- <%= link.title %> • <%= link.url %>
+  <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.links.score(link.id).round(2)) %>
+<% end %>
+
+<% if @lowest_trending_link %>
+<%= raw t('admin_mailer.new_trends.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2), rank: Trends.links.options[:review_threshold]) %>
+<% else %>
+<%= raw t('admin_mailer.new_trends.new_trending_links.no_approved_links') %>
+<% end %>
+
+<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>
diff --git a/app/views/admin_mailer/_new_trending_statuses.text.erb b/app/views/admin_mailer/_new_trending_statuses.text.erb
new file mode 100644
index 000000000..8d11a80c2
--- /dev/null
+++ b/app/views/admin_mailer/_new_trending_statuses.text.erb
@@ -0,0 +1,14 @@
+<%= raw t('admin_mailer.new_trends.new_trending_statuses.title') %>
+
+<% @statuses.each do |status| %>
+- <%= ActivityPub::TagManager.instance.url_for(status) %>
+  <%= raw t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id).round(2)) %>
+<% end %>
+
+<% if @lowest_trending_status %>
+<%= raw t('admin_mailer.new_trends.new_trending_statuses.requirements', lowest_status_url: ActivityPub::TagManager.instance.url_for(@lowest_trending_status), lowest_status_score: Trends.statuses.score(@lowest_trending_status.id).round(2), rank: Trends.statuses.options[:review_threshold]) %>
+<% else %>
+<%= raw t('admin_mailer.new_trends.new_trending_statuses.no_approved_statuses') %>
+<% end %>
+
+<%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %>
diff --git a/app/views/admin_mailer/_new_trending_tags.text.erb b/app/views/admin_mailer/_new_trending_tags.text.erb
new file mode 100644
index 000000000..49fe84309
--- /dev/null
+++ b/app/views/admin_mailer/_new_trending_tags.text.erb
@@ -0,0 +1,14 @@
+<%= raw t('admin_mailer.new_trends.new_trending_tags.title') %>
+
+<% @tags.each do |tag| %>
+- #<%= tag.name %>
+  <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
+<% end %>
+
+<% if @lowest_trending_tag %>
+<%= raw t('admin_mailer.new_trends.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2), rank: Trends.tags.options[:review_threshold]) %>
+<% else %>
+<%= raw t('admin_mailer.new_trends.new_trending_tags.no_approved_tags') %>
+<% end %>
+
+<%= raw t('application_mailer.view')%> <%= admin_trends_tags_url(pending_review: '1') %>
diff --git a/app/views/admin_mailer/new_trending_links.text.erb b/app/views/admin_mailer/new_trending_links.text.erb
deleted file mode 100644
index 51789aca5..000000000
--- a/app/views/admin_mailer/new_trending_links.text.erb
+++ /dev/null
@@ -1,16 +0,0 @@
-<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
-
-<%= raw t('admin_mailer.new_trending_links.body') %>
-
-<% @links.each do |link| %>
-- <%= link.title %> • <%= link.url %>
-  <%= t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.links.score(link.id).round(2)) %>
-<% end %>
-
-<% if @lowest_trending_link %>
-<%= t('admin_mailer.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2)) %>
-<% else %>
-<%= t('admin_mailer.new_trending_links.no_approved_links') %>
-<% end %>
-
-<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>
diff --git a/app/views/admin_mailer/new_trending_tags.text.erb b/app/views/admin_mailer/new_trending_tags.text.erb
deleted file mode 100644
index 9ea31fa7c..000000000
--- a/app/views/admin_mailer/new_trending_tags.text.erb
+++ /dev/null
@@ -1,16 +0,0 @@
-<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
-
-<%= raw t('admin_mailer.new_trending_tags.body') %>
-
-<% @tags.each do |tag| %>
-- #<%= tag.name %>
-  <%= t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
-<% end %>
-
-<% if @lowest_trending_tag %>
-<%= t('admin_mailer.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2)) %>
-<% else %>
-<%= t('admin_mailer.new_trending_tags.no_approved_tags') %>
-<% end %>
-
-<%= raw t('application_mailer.view')%> <%= admin_trends_tags_url(status: 'pending_review') %>
diff --git a/app/views/admin_mailer/new_trends.text.erb b/app/views/admin_mailer/new_trends.text.erb
new file mode 100644
index 000000000..13b296846
--- /dev/null
+++ b/app/views/admin_mailer/new_trends.text.erb
@@ -0,0 +1,13 @@
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
+
+<%= raw t('admin_mailer.new_trends.body') %>
+
+<% unless @links.empty? %>
+<%= render 'new_trending_links' %>
+<% end %>
+<% unless @tags.empty? %>
+<%= render 'new_trending_tags' unless @tags.empty? %>
+<% end %>
+<% unless @statuses.empty? %>
+<%= render 'new_trending_statuses' unless @statuses.empty? %>
+<% end %>
diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml
index 6826c3b58..e97c493fe 100644
--- a/app/views/application/_sidebar.html.haml
+++ b/app/views/application/_sidebar.html.haml
@@ -6,7 +6,7 @@
     %p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html')
 
 - if Setting.trends && !(user_signed_in? && !current_user.setting_trends)
-  - trends = Trends.tags.get(true, 3)
+  - trends = Trends.tags.query.allowed.limit(3)
 
   - unless trends.empty?
     .endorsements-widget.trends-widget
diff --git a/app/workers/scheduler/email_domain_block_refresh_scheduler.rb b/app/workers/scheduler/email_domain_block_refresh_scheduler.rb
new file mode 100644
index 000000000..c67be6843
--- /dev/null
+++ b/app/workers/scheduler/email_domain_block_refresh_scheduler.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Scheduler::EmailDomainBlockRefreshScheduler
+  include Sidekiq::Worker
+  include Redisable
+
+  sidekiq_options retry: 0
+
+  def perform
+    Resolv::DNS.open do |dns|
+      dns.timeouts = 5
+
+      EmailDomainBlock.find_each do |email_domain_block|
+        ips = begin
+          if ip?(email_domain_block.domain)
+            [email_domain_block.domain]
+          else
+            dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::A).to_a + dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::AAAA).to_a.map { |resource| resource.address.to_s }
+          end
+        end
+
+        email_domain_block.update(ips: ips, last_refresh_at: Time.now.utc)
+      end
+    end
+  end
+
+  def ip?(str)
+    str =~ Regexp.union([Resolv::IPv4::Regex, Resolv::IPv6::Regex])
+  end
+end
diff --git a/app/workers/scheduler/follow_recommendations_scheduler.rb b/app/workers/scheduler/follow_recommendations_scheduler.rb
index 084619cbd..57f78170e 100644
--- a/app/workers/scheduler/follow_recommendations_scheduler.rb
+++ b/app/workers/scheduler/follow_recommendations_scheduler.rb
@@ -18,7 +18,7 @@ class Scheduler::FollowRecommendationsScheduler
 
     fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE)
 
-    I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq.each do |locale|
+    Trends.available_locales.each do |locale|
       recommendations = begin
         if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist
           FollowRecommendation.localized(locale).order(rank: :desc).limit(SET_SIZE).map { |recommendation| [recommendation.account_id, recommendation.rank] }
@@ -49,11 +49,11 @@ class Scheduler::FollowRecommendationsScheduler
         end
       end
 
-      redis.pipelined do
-        redis.del(key(locale))
+      redis.multi do |multi|
+        multi.del(key(locale))
 
         recommendations.each do |(account_id, rank)|
-          redis.zadd(key(locale), rank, account_id)
+          multi.zadd(key(locale), rank, account_id)
         end
       end
     end