about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2021-11-25 13:07:38 +0100
committerGitHub <noreply@github.com>2021-11-25 13:07:38 +0100
commit6e50134a42cb303e6e42f89f9ddb5aacf83e7a6d (patch)
treef60727e2c871857422082d814bb0cb28ce88f6c3 /app
parent46e62fc4b33f3566eb9bf588b15bac28cae967a3 (diff)
Add trending links (#16917)
* Add trending links

* Add overriding specific links trendability

* Add link type to preview cards and only trend articles

Change trends review notifications from being sent every 5 minutes to being sent every 2 hours

Change threshold from 5 unique accounts to 15 unique accounts

* Fix tests
Diffstat (limited to 'app')
-rw-r--r--app/chewy/tags_index.rb2
-rw-r--r--app/controllers/admin/dashboard_controller.rb2
-rw-r--r--app/controllers/admin/tags_controller.rb76
-rw-r--r--app/controllers/admin/trends/links/preview_card_providers_controller.rb41
-rw-r--r--app/controllers/admin/trends/links_controller.rb45
-rw-r--r--app/controllers/admin/trends/tags_controller.rb41
-rw-r--r--app/controllers/api/v1/admin/dimensions_controller.rb3
-rw-r--r--app/controllers/api/v1/admin/measures_controller.rb3
-rw-r--r--app/controllers/api/v1/admin/trends/tags_controller.rb16
-rw-r--r--app/controllers/api/v1/admin/trends_controller.rb16
-rw-r--r--app/controllers/api/v1/trends/links_controller.rb21
-rw-r--r--app/controllers/api/v1/trends/tags_controller.rb21
-rw-r--r--app/controllers/api/v1/trends_controller.rb15
-rw-r--r--app/helpers/admin/filter_helper.rb2
-rw-r--r--app/helpers/languages_helper.rb94
-rw-r--r--app/helpers/settings_helper.rb89
-rw-r--r--app/javascript/mastodon/components/admin/Counter.js5
-rw-r--r--app/javascript/mastodon/components/admin/Dimension.js5
-rw-r--r--app/javascript/mastodon/components/admin/Trends.js2
-rw-r--r--app/javascript/styles/mastodon/accounts.scss16
-rw-r--r--app/javascript/styles/mastodon/dashboard.scss10
-rw-r--r--app/lib/activitypub/activity.rb2
-rw-r--r--app/lib/activitypub/activity/announce.rb5
-rw-r--r--app/lib/activitypub/activity/create.rb7
-rw-r--r--app/lib/admin/metrics/dimension.rb9
-rw-r--r--app/lib/admin/metrics/dimension/base_dimension.rb13
-rw-r--r--app/lib/admin/metrics/dimension/languages_dimension.rb4
-rw-r--r--app/lib/admin/metrics/dimension/tag_languages_dimension.rb36
-rw-r--r--app/lib/admin/metrics/dimension/tag_servers_dimension.rb35
-rw-r--r--app/lib/admin/metrics/measure.rb10
-rw-r--r--app/lib/admin/metrics/measure/active_users_measure.rb4
-rw-r--r--app/lib/admin/metrics/measure/base_measure.rb15
-rw-r--r--app/lib/admin/metrics/measure/interactions_measure.rb4
-rw-r--r--app/lib/admin/metrics/measure/tag_accounts_measure.rb41
-rw-r--r--app/lib/admin/metrics/measure/tag_servers_measure.rb47
-rw-r--r--app/lib/admin/metrics/measure/tag_uses_measure.rb41
-rw-r--r--app/lib/link_details_extractor.rb49
-rw-r--r--app/mailers/admin_mailer.rb22
-rw-r--r--app/models/account_statuses_cleanup_policy.rb4
-rw-r--r--app/models/form/preview_card_batch.rb65
-rw-r--r--app/models/form/preview_card_provider_batch.rb33
-rw-r--r--app/models/form/tag_batch.rb8
-rw-r--r--app/models/preview_card.rb42
-rw-r--r--app/models/preview_card_filter.rb53
-rw-r--r--app/models/preview_card_provider.rb57
-rw-r--r--app/models/preview_card_provider_filter.rb49
-rw-r--r--app/models/tag.rb23
-rw-r--r--app/models/tag_filter.rb56
-rw-r--r--app/models/trending_tags.rb128
-rw-r--r--app/models/trends.rb27
-rw-r--r--app/models/trends/base.rb80
-rw-r--r--app/models/trends/history.rb98
-rw-r--r--app/models/trends/links.rb117
-rw-r--r--app/models/trends/tags.rb111
-rw-r--r--app/policies/preview_card_policy.rb11
-rw-r--r--app/policies/preview_card_provider_policy.rb11
-rw-r--r--app/serializers/rest/trends/link_serializer.rb5
-rw-r--r--app/services/fetch_link_card_service.rb3
-rw-r--r--app/services/post_status_service.rb3
-rw-r--r--app/services/process_hashtags_service.rb2
-rw-r--r--app/services/reblog_service.rb13
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/tags/_tag.html.haml19
-rw-r--r--app/views/admin/tags/index.html.haml74
-rw-r--r--app/views/admin/tags/show.html.haml68
-rw-r--r--app/views/admin/trends/links/_preview_card.html.haml30
-rw-r--r--app/views/admin/trends/links/index.html.haml41
-rw-r--r--app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml16
-rw-r--r--app/views/admin/trends/links/preview_card_providers/index.html.haml43
-rw-r--r--app/views/admin/trends/tags/_tag.html.haml24
-rw-r--r--app/views/admin/trends/tags/index.html.haml38
-rw-r--r--app/views/admin_mailer/new_trending_links.text.erb16
-rw-r--r--app/views/admin_mailer/new_trending_tag.text.erb5
-rw-r--r--app/views/admin_mailer/new_trending_tags.text.erb16
-rw-r--r--app/views/application/_sidebar.html.haml2
-rw-r--r--app/workers/scheduler/trends/refresh_scheduler.rb (renamed from app/workers/scheduler/trending_tags_scheduler.rb)4
-rw-r--r--app/workers/scheduler/trends/review_notifications_scheduler.rb11
77 files changed, 1734 insertions, 543 deletions
diff --git a/app/chewy/tags_index.rb b/app/chewy/tags_index.rb
index f811a8d67..f9db2b03a 100644
--- a/app/chewy/tags_index.rb
+++ b/app/chewy/tags_index.rb
@@ -31,7 +31,7 @@ class TagsIndex < Chewy::Index
     end
 
     field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
-    field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day[:accounts].to_i } }
+    field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day.accounts } }
     field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
   end
 end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index cbfff2707..f0a935411 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -4,7 +4,7 @@ module Admin
   class DashboardController < BaseController
     def index
       @system_checks         = Admin::SystemCheck.perform
-      @time_period           = (1.month.ago.to_date...Time.now.utc.to_date)
+      @time_period           = (29.days.ago.to_date...Time.now.utc.to_date)
       @pending_users_count   = User.pending.count
       @pending_reports_count = Report.unresolved.count
       @pending_tags_count    = Tag.pending_review.count
diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb
index eed4feea2..749e2f144 100644
--- a/app/controllers/admin/tags_controller.rb
+++ b/app/controllers/admin/tags_controller.rb
@@ -2,38 +2,12 @@
 
 module Admin
   class TagsController < BaseController
-    before_action :set_tag, except: [:index, :batch, :approve_all, :reject_all]
-    before_action :set_usage_by_domain, except: [:index, :batch, :approve_all, :reject_all]
-    before_action :set_counters, except: [:index, :batch, :approve_all, :reject_all]
-
-    def index
-      authorize :tag, :index?
-
-      @tags = filtered_tags.page(params[:page])
-      @form = Form::TagBatch.new
-    end
-
-    def batch
-      @form = Form::TagBatch.new(form_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')
-    ensure
-      redirect_to admin_tags_path(filter_params)
-    end
-
-    def approve_all
-      Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'approve').save
-      redirect_to admin_tags_path(filter_params)
-    end
-
-    def reject_all
-      Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'reject').save
-      redirect_to admin_tags_path(filter_params)
-    end
+    before_action :set_tag
 
     def show
       authorize @tag, :show?
+
+      @time_period = (6.days.ago.to_date...Time.now.utc.to_date)
     end
 
     def update
@@ -52,52 +26,8 @@ module Admin
       @tag = Tag.find(params[:id])
     end
 
-    def set_usage_by_domain
-      @usage_by_domain = @tag.statuses
-                             .with_public_visibility
-                             .excluding_silenced_accounts
-                             .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
-                             .joins(:account)
-                             .group('accounts.domain')
-                             .reorder(statuses_count: :desc)
-                             .pluck(Arel.sql('accounts.domain, count(*) AS statuses_count'))
-    end
-
-    def set_counters
-      @accounts_today = @tag.history.first[:accounts]
-      @accounts_week  = Redis.current.pfcount(*current_week_days.map { |day| "activity:tags:#{@tag.id}:#{day}:accounts" })
-    end
-
-    def filtered_tags
-      TagFilter.new(filter_params).results
-    end
-
-    def filter_params
-      params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
-    end
-
     def tag_params
       params.require(:tag).permit(:name, :trendable, :usable, :listable)
     end
-
-    def current_week_days
-      now = Time.now.utc.beginning_of_day.to_date
-
-      (Date.commercial(now.cwyear, now.cweek)..now).map do |date|
-        date.to_time(:utc).beginning_of_day.to_i
-      end
-    end
-
-    def form_tag_batch_params
-      params.require(:form_tag_batch).permit(:action, tag_ids: [])
-    end
-
-    def action_from_button
-      if params[:approve]
-        'approve'
-      elsif params[:reject]
-        'reject'
-      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
new file mode 100644
index 000000000..2c26e03f3
--- /dev/null
+++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseController
+  def index
+    authorize :preview_card_provider, :index?
+
+    @preview_card_providers = filtered_preview_card_providers.page(params[:page])
+    @form = Form::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.save
+  rescue ActionController::ParameterMissing
+    flash[:alert] = I18n.t('admin.accounts.no_account_selected')
+  ensure
+    redirect_to admin_trends_links_preview_card_providers_path(filter_params)
+  end
+
+  private
+
+  def filtered_preview_card_providers
+    PreviewCardProviderFilter.new(filter_params).results
+  end
+
+  def filter_params
+    params.slice(:page, *PreviewCardProviderFilter::KEYS).permit(:page, *PreviewCardProviderFilter::KEYS)
+  end
+
+  def form_preview_card_provider_batch_params
+    params.require(:form_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
+  end
+
+  def action_from_button
+    if params[:approve]
+      'approve'
+    elsif params[:reject]
+      'reject'
+    end
+  end
+end
diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb
new file mode 100644
index 000000000..619b37deb
--- /dev/null
+++ b/app/controllers/admin/trends/links_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class Admin::Trends::LinksController < Admin::BaseController
+  def index
+    authorize :preview_card, :index?
+
+    @preview_cards = filtered_preview_cards.page(params[:page])
+    @form          = Form::PreviewCardBatch.new
+  end
+
+  def batch
+    @form = Form::PreviewCardBatch.new(form_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')
+  ensure
+    redirect_to admin_trends_links_path(filter_params)
+  end
+
+  private
+
+  def filtered_preview_cards
+    PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results
+  end
+
+  def filter_params
+    params.slice(:page, *PreviewCardFilter::KEYS).permit(:page, *PreviewCardFilter::KEYS)
+  end
+
+  def form_preview_card_batch_params
+    params.require(:form_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[:reject]
+      'reject'
+    elsif params[:reject_all]
+      'reject_all'
+    end
+  end
+end
diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb
new file mode 100644
index 000000000..91ff33d40
--- /dev/null
+++ b/app/controllers/admin/trends/tags_controller.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::Trends::TagsController < Admin::BaseController
+  def index
+    authorize :tag, :index?
+
+    @tags = filtered_tags.page(params[:page])
+    @form = Form::TagBatch.new
+  end
+
+  def batch
+    @form = Form::TagBatch.new(form_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')
+  ensure
+    redirect_to admin_trends_tags_path(filter_params)
+  end
+
+  private
+
+  def filtered_tags
+    TagFilter.new(filter_params).results
+  end
+
+  def filter_params
+    params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
+  end
+
+  def form_tag_batch_params
+    params.require(:form_tag_batch).permit(:action, tag_ids: [])
+  end
+
+  def action_from_button
+    if params[:approve]
+      'approve'
+    elsif params[:reject]
+      'reject'
+    end
+  end
+end
diff --git a/app/controllers/api/v1/admin/dimensions_controller.rb b/app/controllers/api/v1/admin/dimensions_controller.rb
index 170596d27..5e8f0f89f 100644
--- a/app/controllers/api/v1/admin/dimensions_controller.rb
+++ b/app/controllers/api/v1/admin/dimensions_controller.rb
@@ -17,7 +17,8 @@ class Api::V1::Admin::DimensionsController < Api::BaseController
       params[:keys],
       params[:start_at],
       params[:end_at],
-      params[:limit]
+      params[:limit],
+      params
     )
   end
 end
diff --git a/app/controllers/api/v1/admin/measures_controller.rb b/app/controllers/api/v1/admin/measures_controller.rb
index a3ac6fe85..f28191753 100644
--- a/app/controllers/api/v1/admin/measures_controller.rb
+++ b/app/controllers/api/v1/admin/measures_controller.rb
@@ -16,7 +16,8 @@ class Api::V1::Admin::MeasuresController < Api::BaseController
     @measures = Admin::Metrics::Measure.retrieve(
       params[:keys],
       params[:start_at],
-      params[:end_at]
+      params[:end_at],
+      params
     )
   end
 end
diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb
new file mode 100644
index 000000000..3653d1dd1
--- /dev/null
+++ b/app/controllers/api/v1/admin/trends/tags_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::Trends::TagsController < Api::BaseController
+  before_action :require_staff!
+  before_action :set_tags
+
+  def index
+    render json: @tags, each_serializer: REST::Admin::TagSerializer
+  end
+
+  private
+
+  def set_tags
+    @tags = Trends.tags.get(false, limit_param(10))
+  end
+end
diff --git a/app/controllers/api/v1/admin/trends_controller.rb b/app/controllers/api/v1/admin/trends_controller.rb
deleted file mode 100644
index e32ab5d2c..000000000
--- a/app/controllers/api/v1/admin/trends_controller.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-class Api::V1::Admin::TrendsController < Api::BaseController
-  before_action :require_staff!
-  before_action :set_trends
-
-  def index
-    render json: @trends, each_serializer: REST::Admin::TagSerializer
-  end
-
-  private
-
-  def set_trends
-    @trends = TrendingTags.get(10, filtered: false)
-  end
-end
diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb
new file mode 100644
index 000000000..1c3ab1e1c
--- /dev/null
+++ b/app/controllers/api/v1/trends/links_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::Trends::LinksController < Api::BaseController
+  before_action :set_links
+
+  def index
+    render json: @links, each_serializer: REST::Trends::LinkSerializer
+  end
+
+  private
+
+  def set_links
+    @links = begin
+      if Setting.trends
+        Trends.links.get(true, limit_param(10))
+      else
+        []
+      end
+    end
+  end
+end
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
new file mode 100644
index 000000000..947b53de2
--- /dev/null
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::Trends::TagsController < Api::BaseController
+  before_action :set_tags
+
+  def index
+    render json: @tags, each_serializer: REST::TagSerializer
+  end
+
+  private
+
+  def set_tags
+    @tags = begin
+      if Setting.trends
+        Trends.tags.get(true, limit_param(10))
+      else
+        []
+      end
+    end
+  end
+end
diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb
deleted file mode 100644
index c875e9041..000000000
--- a/app/controllers/api/v1/trends_controller.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-class Api::V1::TrendsController < Api::BaseController
-  before_action :set_tags
-
-  def index
-    render json: @tags, each_serializer: REST::TagSerializer
-  end
-
-  private
-
-  def set_tags
-    @tags = TrendingTags.get(limit_param(10))
-  end
-end
diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb
index ba0ca9638..5f69f176a 100644
--- a/app/helpers/admin/filter_helper.rb
+++ b/app/helpers/admin/filter_helper.rb
@@ -6,6 +6,8 @@ module Admin::FilterHelper
     CustomEmojiFilter::KEYS,
     ReportFilter::KEYS,
     TagFilter::KEYS,
+    PreviewCardProviderFilter::KEYS,
+    PreviewCardFilter::KEYS,
     InstanceFilter::KEYS,
     InviteFilter::KEYS,
     RelationshipFilter::KEYS,
diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb
new file mode 100644
index 000000000..730724208
--- /dev/null
+++ b/app/helpers/languages_helper.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module LanguagesHelper
+  HUMAN_LOCALES = {
+    af: 'Afrikaans',
+    ar: 'العربية',
+    ast: 'Asturianu',
+    bg: 'Български',
+    bn: 'বাংলা',
+    br: 'Breton',
+    ca: 'Català',
+    co: 'Corsu',
+    cs: 'Čeština',
+    cy: 'Cymraeg',
+    da: 'Dansk',
+    de: 'Deutsch',
+    el: 'Ελληνικά',
+    en: 'English',
+    eo: 'Esperanto',
+    'es-AR': 'Español (Argentina)',
+    'es-MX': 'Español (México)',
+    es: 'Español',
+    et: 'Eesti',
+    eu: 'Euskara',
+    fa: 'فارسی',
+    fi: 'Suomi',
+    fr: 'Français',
+    ga: 'Gaeilge',
+    gd: 'Gàidhlig',
+    gl: 'Galego',
+    he: 'עברית',
+    hi: 'हिन्दी',
+    hr: 'Hrvatski',
+    hu: 'Magyar',
+    hy: 'Հայերեն',
+    id: 'Bahasa Indonesia',
+    io: 'Ido',
+    is: 'Íslenska',
+    it: 'Italiano',
+    ja: '日本語',
+    ka: 'ქართული',
+    kab: 'Taqbaylit',
+    kk: 'Қазақша',
+    kmr: 'Kurmancî',
+    kn: 'ಕನ್ನಡ',
+    ko: '한국어',
+    ku: 'سۆرانی',
+    lt: 'Lietuvių',
+    lv: 'Latviešu',
+    mk: 'Македонски',
+    ml: 'മലയാളം',
+    mr: 'मराठी',
+    ms: 'Bahasa Melayu',
+    nl: 'Nederlands',
+    nn: 'Nynorsk',
+    no: 'Norsk',
+    oc: 'Occitan',
+    pl: 'Polski',
+    'pt-BR': 'Português (Brasil)',
+    'pt-PT': 'Português (Portugal)',
+    pt: 'Português',
+    ro: 'Română',
+    ru: 'Русский',
+    sa: 'संस्कृतम्',
+    sc: 'Sardu',
+    si: 'සිංහල',
+    sk: 'Slovenčina',
+    sl: 'Slovenščina',
+    sq: 'Shqip',
+    'sr-Latn': 'Srpski (latinica)',
+    sr: 'Српски',
+    sv: 'Svenska',
+    ta: 'தமிழ்',
+    te: 'తెలుగు',
+    th: 'ไทย',
+    tr: 'Türkçe',
+    uk: 'Українська',
+    ur: 'اُردُو',
+    vi: 'Tiếng Việt',
+    zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ',
+    'zh-CN': '简体中文',
+    'zh-HK': '繁體中文(香港)',
+    'zh-TW': '繁體中文(臺灣)',
+    zh: '中文',
+  }.freeze
+
+  def human_locale(locale)
+    if locale == 'und'
+      I18n.t('generic.none')
+    else
+      HUMAN_LOCALES[locale.to_sym] || locale
+    end
+  end
+end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index ac4c18746..23739d1cd 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -1,95 +1,8 @@
 # frozen_string_literal: true
 
 module SettingsHelper
-  HUMAN_LOCALES = {
-    af: 'Afrikaans',
-    ar: 'العربية',
-    ast: 'Asturianu',
-    bg: 'Български',
-    bn: 'বাংলা',
-    br: 'Breton',
-    ca: 'Català',
-    co: 'Corsu',
-    cs: 'Čeština',
-    cy: 'Cymraeg',
-    da: 'Dansk',
-    de: 'Deutsch',
-    el: 'Ελληνικά',
-    en: 'English',
-    eo: 'Esperanto',
-    'es-AR': 'Español (Argentina)',
-    'es-MX': 'Español (México)',
-    es: 'Español',
-    et: 'Eesti',
-    eu: 'Euskara',
-    fa: 'فارسی',
-    fi: 'Suomi',
-    fr: 'Français',
-    ga: 'Gaeilge',
-    gd: 'Gàidhlig',
-    gl: 'Galego',
-    he: 'עברית',
-    hi: 'हिन्दी',
-    hr: 'Hrvatski',
-    hu: 'Magyar',
-    hy: 'Հայերեն',
-    id: 'Bahasa Indonesia',
-    io: 'Ido',
-    is: 'Íslenska',
-    it: 'Italiano',
-    ja: '日本語',
-    ka: 'ქართული',
-    kab: 'Taqbaylit',
-    kk: 'Қазақша',
-    kmr: 'Kurmancî',
-    kn: 'ಕನ್ನಡ',
-    ko: '한국어',
-    ku: 'سۆرانی',
-    lt: 'Lietuvių',
-    lv: 'Latviešu',
-    mk: 'Македонски',
-    ml: 'മലയാളം',
-    mr: 'मराठी',
-    ms: 'Bahasa Melayu',
-    nl: 'Nederlands',
-    nn: 'Nynorsk',
-    no: 'Norsk',
-    oc: 'Occitan',
-    pl: 'Polski',
-    'pt-BR': 'Português (Brasil)',
-    'pt-PT': 'Português (Portugal)',
-    pt: 'Português',
-    ro: 'Română',
-    ru: 'Русский',
-    sa: 'संस्कृतम्',
-    sc: 'Sardu',
-    si: 'සිංහල',
-    sk: 'Slovenčina',
-    sl: 'Slovenščina',
-    sq: 'Shqip',
-    'sr-Latn': 'Srpski (latinica)',
-    sr: 'Српски',
-    sv: 'Svenska',
-    ta: 'தமிழ்',
-    te: 'తెలుగు',
-    th: 'ไทย',
-    tr: 'Türkçe',
-    uk: 'Українська',
-    ur: 'اُردُو',
-    vi: 'Tiếng Việt',
-    zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ',
-    'zh-CN': '简体中文',
-    'zh-HK': '繁體中文(香港)',
-    'zh-TW': '繁體中文(臺灣)',
-    zh: '中文',
-  }.freeze
-
-  def human_locale(locale)
-    HUMAN_LOCALES[locale]
-  end
-
   def filterable_languages
-    LanguageDetector.instance.language_names.select(&HUMAN_LOCALES.method(:key?))
+    LanguageDetector.instance.language_names.select(&LanguagesHelper::HUMAN_LOCALES.method(:key?))
   end
 
   def hash_to_object(hash)
diff --git a/app/javascript/mastodon/components/admin/Counter.js b/app/javascript/mastodon/components/admin/Counter.js
index cda572dce..047e864b2 100644
--- a/app/javascript/mastodon/components/admin/Counter.js
+++ b/app/javascript/mastodon/components/admin/Counter.js
@@ -32,6 +32,7 @@ export default class Counter extends React.PureComponent {
     end_at: PropTypes.string.isRequired,
     label: PropTypes.string.isRequired,
     href: PropTypes.string,
+    params: PropTypes.object,
   };
 
   state = {
@@ -40,9 +41,9 @@ export default class Counter extends React.PureComponent {
   };
 
   componentDidMount () {
-    const { measure, start_at, end_at } = this.props;
+    const { measure, start_at, end_at, params } = this.props;
 
-    api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at }).then(res => {
+    api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
       this.setState({
         loading: false,
         data: res.data,
diff --git a/app/javascript/mastodon/components/admin/Dimension.js b/app/javascript/mastodon/components/admin/Dimension.js
index ac6dbd1c7..977c8208d 100644
--- a/app/javascript/mastodon/components/admin/Dimension.js
+++ b/app/javascript/mastodon/components/admin/Dimension.js
@@ -13,6 +13,7 @@ export default class Dimension extends React.PureComponent {
     end_at: PropTypes.string.isRequired,
     limit: PropTypes.number.isRequired,
     label: PropTypes.string.isRequired,
+    params: PropTypes.object,
   };
 
   state = {
@@ -21,9 +22,9 @@ export default class Dimension extends React.PureComponent {
   };
 
   componentDidMount () {
-    const { start_at, end_at, dimension, limit } = this.props;
+    const { start_at, end_at, dimension, limit, params } = this.props;
 
-    api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit }).then(res => {
+    api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
       this.setState({
         loading: false,
         data: res.data,
diff --git a/app/javascript/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js
index 46307a28a..635bdf37d 100644
--- a/app/javascript/mastodon/components/admin/Trends.js
+++ b/app/javascript/mastodon/components/admin/Trends.js
@@ -19,7 +19,7 @@ export default class Trends extends React.PureComponent {
   componentDidMount () {
     const { limit } = this.props;
 
-    api().get('/api/v1/admin/trends', { params: { limit } }).then(res => {
+    api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
       this.setState({
         loading: false,
         data: res.data,
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index 2c78e81be..b8a6c8018 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -325,3 +325,19 @@
     margin-top: 10px;
   }
 }
+
+.batch-table__row--muted .pending-account__header {
+  &,
+  a,
+  strong {
+    color: lighten($ui-base-color, 26%);
+  }
+}
+
+.batch-table__row--attention .pending-account__header {
+  &,
+  a,
+  strong {
+    color: $gold-star;
+  }
+}
diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss
index 5e900e8c5..0a881bc10 100644
--- a/app/javascript/styles/mastodon/dashboard.scss
+++ b/app/javascript/styles/mastodon/dashboard.scss
@@ -100,6 +100,16 @@
       transition: all 200ms ease-out;
     }
 
+    &.positive {
+      background: lighten($ui-base-color, 4%);
+      color: $valid-value-color;
+    }
+
+    &.negative {
+      background: lighten($ui-base-color, 4%);
+      color: $error-value-color;
+    }
+
     span {
       flex: 1 1 auto;
     }
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index d2ec122a4..3aeecb4ec 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -129,8 +129,6 @@ class ActivityPub::Activity
   end
 
   def crawl_links(status)
-    return if status.spoiler_text?
-
     # Spread out crawling randomly to avoid DDoSing the link
     LinkCrawlWorker.perform_in(rand(1..59).seconds, status.id)
   end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 9f778ffb9..6c5d88d18 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -22,9 +22,8 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
         visibility: visibility_from_audience
       )
 
-      original_status.tags.each do |tag|
-        tag.use!(@account)
-      end
+      Trends.tags.register(@status)
+      Trends.links.register(@status)
 
       distribute(@status)
     end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 4c13a80a6..8a0dc9d33 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -164,9 +164,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   def attach_tags(status)
     @tags.each do |tag|
       status.tags << tag
-      tag.use!(@account, status: status, at_time: status.created_at) if status.public_visibility?
+      tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago)
     end
 
+    # If we're processing an old status, this may register tags as being used now
+    # as opposed to when the status was really published, but this is probably
+    # not a big deal
+    Trends.tags.register(status)
+
     @mentions.each do |mention|
       mention.status = status
       mention.save
diff --git a/app/lib/admin/metrics/dimension.rb b/app/lib/admin/metrics/dimension.rb
index 279539f68..d8392ddfc 100644
--- a/app/lib/admin/metrics/dimension.rb
+++ b/app/lib/admin/metrics/dimension.rb
@@ -7,9 +7,14 @@ class Admin::Metrics::Dimension
     servers: Admin::Metrics::Dimension::ServersDimension,
     space_usage: Admin::Metrics::Dimension::SpaceUsageDimension,
     software_versions: Admin::Metrics::Dimension::SoftwareVersionsDimension,
+    tag_servers: Admin::Metrics::Dimension::TagServersDimension,
+    tag_languages: Admin::Metrics::Dimension::TagLanguagesDimension,
   }.freeze
 
-  def self.retrieve(dimension_keys, start_at, end_at, limit)
-    Array(dimension_keys).map { |key| DIMENSIONS[key.to_sym]&.new(start_at, end_at, limit) }.compact
+  def self.retrieve(dimension_keys, start_at, end_at, limit, params)
+    Array(dimension_keys).map do |key|
+      klass = DIMENSIONS[key.to_sym]
+      klass&.new(start_at, end_at, limit, klass.with_params? ? params.require(key.to_sym) : nil)
+    end.compact
   end
 end
diff --git a/app/lib/admin/metrics/dimension/base_dimension.rb b/app/lib/admin/metrics/dimension/base_dimension.rb
index 8ed8d7683..5872c22cb 100644
--- a/app/lib/admin/metrics/dimension/base_dimension.rb
+++ b/app/lib/admin/metrics/dimension/base_dimension.rb
@@ -1,10 +1,15 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Dimension::BaseDimension
-  def initialize(start_at, end_at, limit)
+  def self.with_params?
+    false
+  end
+
+  def initialize(start_at, end_at, limit, params)
     @start_at = start_at&.to_datetime
     @end_at   = end_at&.to_datetime
     @limit    = limit&.to_i
+    @params   = params
   end
 
   def key
@@ -26,6 +31,10 @@ class Admin::Metrics::Dimension::BaseDimension
   protected
 
   def time_period
-    (@start_at...@end_at)
+    (@start_at..@end_at)
+  end
+
+  def params
+    raise NotImplementedError
   end
 end
diff --git a/app/lib/admin/metrics/dimension/languages_dimension.rb b/app/lib/admin/metrics/dimension/languages_dimension.rb
index 2d0ac124e..a6aaf5d21 100644
--- a/app/lib/admin/metrics/dimension/languages_dimension.rb
+++ b/app/lib/admin/metrics/dimension/languages_dimension.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension
+  include LanguagesHelper
+
   def key
     'languages'
   end
@@ -18,6 +20,6 @@ class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension:
 
     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
 
-    rows.map { |row| { key: row['locale'], human_key: SettingsHelper::HUMAN_LOCALES[row['locale'].to_sym], value: row['value'].to_s } }
+    rows.map { |row| { key: row['locale'], human_key: human_locale(row['locale']), value: row['value'].to_s } }
   end
 end
diff --git a/app/lib/admin/metrics/dimension/tag_languages_dimension.rb b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
new file mode 100644
index 000000000..1cfa07478
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimension::BaseDimension
+  include LanguagesHelper
+
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_languages'
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value
+      FROM statuses
+      INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
+      WHERE statuses_tags.tag_id = $1
+        AND statuses.id BETWEEN $2 AND $3
+      GROUP BY COALESCE(statuses.language, 'und')
+      ORDER BY count(*) DESC
+      LIMIT $4
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
+
+    rows.map { |row| { key: row['language'], human_key: human_locale(row['language']), value: row['value'].to_s } }
+  end
+
+  private
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/tag_servers_dimension.rb b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb
new file mode 100644
index 000000000..12c5980d7
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension::BaseDimension
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_servers'
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT accounts.domain, count(*) AS value
+      FROM statuses
+      INNER JOIN accounts ON accounts.id = statuses.account_id
+      INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
+      WHERE statuses_tags.tag_id = $1
+        AND statuses.id BETWEEN $2 AND $3
+      GROUP BY accounts.domain
+      ORDER BY count(*) DESC
+      LIMIT $4
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
+
+    rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
+  end
+
+  private
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/admin/metrics/measure.rb b/app/lib/admin/metrics/measure.rb
index 5cebf0331..a839498a1 100644
--- a/app/lib/admin/metrics/measure.rb
+++ b/app/lib/admin/metrics/measure.rb
@@ -7,9 +7,15 @@ class Admin::Metrics::Measure
     interactions: Admin::Metrics::Measure::InteractionsMeasure,
     opened_reports: Admin::Metrics::Measure::OpenedReportsMeasure,
     resolved_reports: Admin::Metrics::Measure::ResolvedReportsMeasure,
+    tag_accounts: Admin::Metrics::Measure::TagAccountsMeasure,
+    tag_uses: Admin::Metrics::Measure::TagUsesMeasure,
+    tag_servers: Admin::Metrics::Measure::TagServersMeasure,
   }.freeze
 
-  def self.retrieve(measure_keys, start_at, end_at)
-    Array(measure_keys).map { |key| MEASURES[key.to_sym]&.new(start_at, end_at) }.compact
+  def self.retrieve(measure_keys, start_at, end_at, params)
+    Array(measure_keys).map do |key|
+      klass = MEASURES[key.to_sym]
+      klass&.new(start_at, end_at, klass.with_params? ? params.require(key.to_sym) : nil)
+    end.compact
   end
 end
diff --git a/app/lib/admin/metrics/measure/active_users_measure.rb b/app/lib/admin/metrics/measure/active_users_measure.rb
index ac022eb9d..513189780 100644
--- a/app/lib/admin/metrics/measure/active_users_measure.rb
+++ b/app/lib/admin/metrics/measure/active_users_measure.rb
@@ -24,10 +24,10 @@ class Admin::Metrics::Measure::ActiveUsersMeasure < Admin::Metrics::Measure::Bas
   end
 
   def time_period
-    (@start_at.to_date...@end_at.to_date)
+    (@start_at.to_date..@end_at.to_date)
   end
 
   def previous_time_period
-    ((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period))
+    ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period))
   end
 end
diff --git a/app/lib/admin/metrics/measure/base_measure.rb b/app/lib/admin/metrics/measure/base_measure.rb
index 4c336a69e..0107ffd9c 100644
--- a/app/lib/admin/metrics/measure/base_measure.rb
+++ b/app/lib/admin/metrics/measure/base_measure.rb
@@ -1,9 +1,14 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Measure::BaseMeasure
-  def initialize(start_at, end_at)
+  def self.with_params?
+    false
+  end
+
+  def initialize(start_at, end_at, params)
     @start_at = start_at&.to_datetime
     @end_at   = end_at&.to_datetime
+    @params   = params
   end
 
   def key
@@ -33,14 +38,18 @@ class Admin::Metrics::Measure::BaseMeasure
   protected
 
   def time_period
-    (@start_at...@end_at)
+    (@start_at..@end_at)
   end
 
   def previous_time_period
-    ((@start_at - length_of_period)...(@end_at - length_of_period))
+    ((@start_at - length_of_period)..(@end_at - length_of_period))
   end
 
   def length_of_period
     @length_of_period ||= @end_at - @start_at
   end
+
+  def params
+    raise NotImplementedError
+  end
 end
diff --git a/app/lib/admin/metrics/measure/interactions_measure.rb b/app/lib/admin/metrics/measure/interactions_measure.rb
index 9a4ef6d63..b928fdb8f 100644
--- a/app/lib/admin/metrics/measure/interactions_measure.rb
+++ b/app/lib/admin/metrics/measure/interactions_measure.rb
@@ -24,10 +24,10 @@ class Admin::Metrics::Measure::InteractionsMeasure < Admin::Metrics::Measure::Ba
   end
 
   def time_period
-    (@start_at.to_date...@end_at.to_date)
+    (@start_at.to_date..@end_at.to_date)
   end
 
   def previous_time_period
-    ((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period))
+    ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period))
   end
 end
diff --git a/app/lib/admin/metrics/measure/tag_accounts_measure.rb b/app/lib/admin/metrics/measure/tag_accounts_measure.rb
new file mode 100644
index 000000000..ef773081b
--- /dev/null
+++ b/app/lib/admin/metrics/measure/tag_accounts_measure.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::TagAccountsMeasure < Admin::Metrics::Measure::BaseMeasure
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_accounts'
+  end
+
+  def total
+    tag.history.aggregate(time_period).accounts
+  end
+
+  def previous_total
+    tag.history.aggregate(previous_time_period).accounts
+  end
+
+  def data
+    time_period.map { |date| { date: date.to_time(:utc).iso8601, value: tag.history.get(date).accounts.to_s } }
+  end
+
+  protected
+
+  def tag
+    @tag ||= Tag.find(params[:id])
+  end
+
+  def time_period
+    (@start_at.to_date..@end_at.to_date)
+  end
+
+  def previous_time_period
+    ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period))
+  end
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/admin/metrics/measure/tag_servers_measure.rb b/app/lib/admin/metrics/measure/tag_servers_measure.rb
new file mode 100644
index 000000000..8c3e0551a
--- /dev/null
+++ b/app/lib/admin/metrics/measure/tag_servers_measure.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::TagServersMeasure < Admin::Metrics::Measure::BaseMeasure
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_servers'
+  end
+
+  def total
+    tag.statuses.where('statuses.id BETWEEN ? AND ?', Mastodon::Snowflake.id_at(@start_at, with_random: false), Mastodon::Snowflake.id_at(@end_at, with_random: false)).joins(:account).count('distinct accounts.domain')
+  end
+
+  def previous_total
+    tag.statuses.where('statuses.id BETWEEN ? AND ?', Mastodon::Snowflake.id_at(@start_at - length_of_period, with_random: false), Mastodon::Snowflake.id_at(@end_at - length_of_period, with_random: false)).joins(:account).count('distinct accounts.domain')
+  end
+
+  def data
+    sql = <<-SQL.squish
+      SELECT axis.*, (
+        SELECT count(*) AS value
+        FROM statuses
+        WHERE statuses.id BETWEEN $1 AND $2
+          AND date_trunc('day', statuses.created_at)::date = axis.day
+      )
+      FROM (
+        SELECT generate_series(date_trunc('day', $3::timestamp)::date, date_trunc('day', $4::timestamp)::date, ('1 day')::interval) AS day
+      ) as axis
+    SQL
+
+    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @start_at], [nil, @end_at]])
+
+    rows.map { |row| { date: row['day'], value: row['value'].to_s } }
+  end
+
+  protected
+
+  def tag
+    @tag ||= Tag.find(params[:id])
+  end
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/admin/metrics/measure/tag_uses_measure.rb b/app/lib/admin/metrics/measure/tag_uses_measure.rb
new file mode 100644
index 000000000..b7667bc6c
--- /dev/null
+++ b/app/lib/admin/metrics/measure/tag_uses_measure.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::Metrics::Measure::TagUsesMeasure < Admin::Metrics::Measure::BaseMeasure
+  def self.with_params?
+    true
+  end
+
+  def key
+    'tag_uses'
+  end
+
+  def total
+    tag.history.aggregate(time_period).uses
+  end
+
+  def previous_total
+    tag.history.aggregate(previous_time_period).uses
+  end
+
+  def data
+    time_period.map { |date| { date: date.to_time(:utc).iso8601, value: tag.history.get(date).uses.to_s } }
+  end
+
+  protected
+
+  def tag
+    @tag ||= Tag.find(params[:id])
+  end
+
+  def time_period
+    (@start_at.to_date..@end_at.to_date)
+  end
+
+  def previous_time_period
+    ((@start_at.to_date - length_of_period)..(@end_at.to_date - length_of_period))
+  end
+
+  def params
+    @params.permit(:id)
+  end
+end
diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb
index 8b38e8d0c..56ad0717b 100644
--- a/app/lib/link_details_extractor.rb
+++ b/app/lib/link_details_extractor.rb
@@ -4,6 +4,11 @@ class LinkDetailsExtractor
   include ActionView::Helpers::TagHelper
 
   class StructuredData
+    SUPPORTED_TYPES = %w(
+      NewsArticle
+      WebPage
+    ).freeze
+
     def initialize(data)
       @data = data
     end
@@ -16,6 +21,14 @@ class LinkDetailsExtractor
       json['description']
     end
 
+    def language
+      json['inLanguage']
+    end
+
+    def type
+      json['@type']
+    end
+
     def image
       obj = first_of_value(json['image'])
 
@@ -44,6 +57,10 @@ class LinkDetailsExtractor
       publisher['name']
     end
 
+    def publisher_logo
+      publisher.dig('logo', 'url')
+    end
+
     private
 
     def author
@@ -58,8 +75,12 @@ class LinkDetailsExtractor
       arr.is_a?(Array) ? arr.first : arr
     end
 
+    def root_array(root)
+      root.is_a?(Array) ? root : [root]
+    end
+
     def json
-      @json ||= first_of_value(Oj.load(@data))
+      @json ||= root_array(Oj.load(@data)).find { |obj| SUPPORTED_TYPES.include?(obj['@type']) } || {}
     end
   end
 
@@ -75,6 +96,7 @@ class LinkDetailsExtractor
       description: description || '',
       image_remote_url: image,
       type: type,
+      link_type: link_type,
       width: width || 0,
       height: height || 0,
       html: html || '',
@@ -83,6 +105,7 @@ class LinkDetailsExtractor
       author_name: author_name || '',
       author_url: author_url || '',
       embed_url: embed_url || '',
+      language: language,
     }
   end
 
@@ -90,6 +113,14 @@ class LinkDetailsExtractor
     player_url.present? ? :video : :link
   end
 
+  def link_type
+    if structured_data&.type == 'NewsArticle' || opengraph_tag('og:type') == 'article'
+      :article
+    else
+      :unknown
+    end
+  end
+
   def html
     player_url.present? ? content_tag(:iframe, nil, src: player_url, width: width, height: height, allowtransparency: 'true', scrolling: 'no', frameborder: '0') : nil
   end
@@ -138,6 +169,14 @@ class LinkDetailsExtractor
     valid_url_or_nil(opengraph_tag('twitter:player:stream'))
   end
 
+  def language
+    valid_locale_or_nil(structured_data&.language || opengraph_tag('og:locale') || document.xpath('//html').map { |element| element['lang'] }.first)
+  end
+
+  def icon
+    valid_url_or_nil(structured_data&.publisher_icon || link_tag('apple-touch-icon') || link_tag('shortcut icon'))
+  end
+
   private
 
   def player_url
@@ -162,6 +201,14 @@ class LinkDetailsExtractor
     nil
   end
 
+  def valid_locale_or_nil(str)
+    return nil if str.blank?
+
+    code,  = str.split(/_-/) # Strip out the region from e.g. en_US or ja-JA
+    locale = ISO_639.find(code)
+    locale&.alpha2
+  end
+
   def link_tag(name)
     document.xpath("//link[@rel=\"#{name}\"]").map { |link| link['href'] }.first
   end
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index 11fd09e30..0fbd9932d 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -25,13 +25,25 @@ class AdminMailer < ApplicationMailer
     end
   end
 
-  def new_trending_tag(recipient, tag)
-    @tag      = tag
-    @me       = recipient
-    @instance = Rails.configuration.x.local_domain
+  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::REVIEW_THRESHOLD).last
+
+    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::REVIEW_THRESHOLD).last
 
     locale_for_account(@me) do
-      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tag.subject', instance: @instance, name: @tag.name)
+      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_links.subject', instance: @instance)
     end
   end
 end
diff --git a/app/models/account_statuses_cleanup_policy.rb b/app/models/account_statuses_cleanup_policy.rb
index 0a9551ec2..0f78c1a54 100644
--- a/app/models/account_statuses_cleanup_policy.rb
+++ b/app/models/account_statuses_cleanup_policy.rb
@@ -4,8 +4,8 @@
 #
 # Table name: account_statuses_cleanup_policies
 #
-#  id                 :bigint           not null, primary key
-#  account_id         :bigint           not null
+#  id                 :bigint(8)        not null, primary key
+#  account_id         :bigint(8)        not null
 #  enabled            :boolean          default(TRUE), not null
 #  min_status_age     :integer          default(1209600), not null
 #  keep_direct        :boolean          default(TRUE), not null
diff --git a/app/models/form/preview_card_batch.rb b/app/models/form/preview_card_batch.rb
new file mode 100644
index 000000000..5f6e6522a
--- /dev/null
+++ b/app/models/form/preview_card_batch.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class Form::PreviewCardBatch
+  include ActiveModel::Model
+  include Authorization
+
+  attr_accessor :preview_card_ids, :action, :current_account, :precision
+
+  def save
+    case action
+    when 'approve'
+      approve!
+    when 'approve_all'
+      approve_all!
+    when 'reject'
+      reject!
+    when 'reject_all'
+      reject_all!
+    end
+  end
+
+  private
+
+  def preview_cards
+    @preview_cards ||= PreviewCard.where(id: preview_card_ids)
+  end
+
+  def preview_card_providers
+    @preview_card_providers ||= preview_cards.map(&:domain).uniq.map { |domain| PreviewCardProvider.matching_domain(domain) || PreviewCardProvider.new(domain: domain) }
+  end
+
+  def approve!
+    preview_cards.each { |preview_card| authorize(preview_card, :update?) }
+    preview_cards.update_all(trendable: true)
+  end
+
+  def approve_all!
+    preview_card_providers.each do |provider|
+      authorize(provider, :update?)
+      provider.update(trendable: true, reviewed_at: action_time)
+    end
+
+    # Reset any individual overrides
+    preview_cards.update_all(trendable: nil)
+  end
+
+  def reject!
+    preview_cards.each { |preview_card| authorize(preview_card, :update?) }
+    preview_cards.update_all(trendable: false)
+  end
+
+  def reject_all!
+    preview_card_providers.each do |provider|
+      authorize(provider, :update?)
+      provider.update(trendable: false, reviewed_at: action_time)
+    end
+
+    # Reset any individual overrides
+    preview_cards.update_all(trendable: nil)
+  end
+
+  def action_time
+    @action_time ||= Time.now.utc
+  end
+end
diff --git a/app/models/form/preview_card_provider_batch.rb b/app/models/form/preview_card_provider_batch.rb
new file mode 100644
index 000000000..e6ab3d8fa
--- /dev/null
+++ b/app/models/form/preview_card_provider_batch.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Form::PreviewCardProviderBatch
+  include ActiveModel::Model
+  include Authorization
+
+  attr_accessor :preview_card_provider_ids, :action, :current_account
+
+  def save
+    case action
+    when 'approve'
+      approve!
+    when 'reject'
+      reject!
+    end
+  end
+
+  private
+
+  def preview_card_providers
+    PreviewCardProvider.where(id: preview_card_provider_ids)
+  end
+
+  def approve!
+    preview_card_providers.each { |provider| authorize(provider, :update?) }
+    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.update_all(trendable: false, reviewed_at: Time.now.utc)
+  end
+end
diff --git a/app/models/form/tag_batch.rb b/app/models/form/tag_batch.rb
index fd517a1a6..b9330745f 100644
--- a/app/models/form/tag_batch.rb
+++ b/app/models/form/tag_batch.rb
@@ -23,11 +23,15 @@ class Form::TagBatch
 
   def approve!
     tags.each { |tag| authorize(tag, :update?) }
-    tags.update_all(trendable: true, reviewed_at: Time.now.utc)
+    tags.update_all(trendable: true, reviewed_at: action_time)
   end
 
   def reject!
     tags.each { |tag| authorize(tag, :update?) }
-    tags.update_all(trendable: false, reviewed_at: Time.now.utc)
+    tags.update_all(trendable: false, reviewed_at: action_time)
+  end
+
+  def action_time
+    @action_time ||= Time.now.utc
   end
 end
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index bca3a3ce8..f2ab8ecab 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -24,6 +24,11 @@
 #  embed_url                    :string           default(""), not null
 #  image_storage_schema_version :integer
 #  blurhash                     :string
+#  language                     :string
+#  max_score                    :float
+#  max_score_at                 :datetime
+#  trendable                    :boolean
+#  link_type                    :integer
 #
 
 class PreviewCard < ApplicationRecord
@@ -40,6 +45,7 @@ class PreviewCard < ApplicationRecord
   self.inheritance_column = false
 
   enum type: [:link, :photo, :video, :rich]
+  enum link_type: [:unknown, :article]
 
   has_and_belongs_to_many :statuses
 
@@ -54,6 +60,32 @@ class PreviewCard < ApplicationRecord
 
   before_save :extract_dimensions, if: :link?
 
+  def appropriate_for_trends?
+    link? && article? && title.present? && description.present? && image.present? && provider_name.present?
+  end
+
+  def domain
+    @domain ||= Addressable::URI.parse(url).normalized_host
+  end
+
+  def provider
+    @provider ||= PreviewCardProvider.matching_domain(domain)
+  end
+
+  def trendable?
+    if attributes['trendable'].nil?
+      provider&.trendable?
+    else
+      attributes['trendable']
+    end
+  end
+
+  def requires_review_notification?
+    attributes['trendable'].nil? && (provider.nil? || provider.requires_review_notification?)
+  end
+
+  attr_writer :provider
+
   def local?
     false
   end
@@ -69,11 +101,14 @@ class PreviewCard < ApplicationRecord
     save!
   end
 
+  def history
+    @history ||= Trends::History.new('links', id)
+  end
+
   class << self
     private
 
-    # rubocop:disable Naming/MethodParameterName
-    def image_styles(f)
+    def image_styles(file)
       styles = {
         original: {
           geometry: '400x400>',
@@ -83,10 +118,9 @@ class PreviewCard < ApplicationRecord
         },
       }
 
-      styles[:original][:format] = 'jpg' if f.instance.image_content_type == 'image/gif'
+      styles[:original][:format] = 'jpg' if file.instance.image_content_type == 'image/gif'
       styles
     end
-    # rubocop:enable Naming/MethodParameterName
   end
 
   private
diff --git a/app/models/preview_card_filter.rb b/app/models/preview_card_filter.rb
new file mode 100644
index 000000000..8dda9989c
--- /dev/null
+++ b/app/models/preview_card_filter.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class PreviewCardFilter
+  KEYS = %i(
+    trending
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = PreviewCard.unscoped
+
+    params.each do |key, value|
+      next if key.to_s == 'page'
+
+      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)
+    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
+
+    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
+  end
+end
diff --git a/app/models/preview_card_provider.rb b/app/models/preview_card_provider.rb
new file mode 100644
index 000000000..15b24e2bd
--- /dev/null
+++ b/app/models/preview_card_provider.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: preview_card_providers
+#
+#  id                  :bigint(8)        not null, primary key
+#  domain              :string           default(""), not null
+#  icon_file_name      :string
+#  icon_content_type   :string
+#  icon_file_size      :bigint(8)
+#  icon_updated_at     :datetime
+#  trendable           :boolean
+#  reviewed_at         :datetime
+#  requested_review_at :datetime
+#  created_at          :datetime         not null
+#  updated_at          :datetime         not null
+#
+
+class PreviewCardProvider < ApplicationRecord
+  include DomainNormalizable
+  include Attachmentable
+
+  ICON_MIME_TYPES = %w(image/x-icon image/vnd.microsoft.icon image/png).freeze
+  LIMIT = 1.megabyte
+
+  validates :domain, presence: true, uniqueness: true, domain: true
+
+  has_attached_file :icon, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }, validate_media_type: false
+  validates_attachment :icon, content_type: { content_type: ICON_MIME_TYPES }, size: { less_than: LIMIT }
+  remotable_attachment :icon, LIMIT
+
+  scope :trendable, -> { where(trendable: true) }
+  scope :not_trendable, -> { where(trendable: false) }
+  scope :reviewed, -> { where.not(reviewed_at: nil) }
+  scope :pending_review, -> { where(reviewed_at: nil) }
+
+  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
+
+  def self.matching_domain(domain)
+    segments = domain.split('.')
+    where(domain: segments.map.with_index { |_, i| segments[i..-1].join('.') }).order(Arel.sql('char_length(domain) desc')).first
+  end
+end
diff --git a/app/models/preview_card_provider_filter.rb b/app/models/preview_card_provider_filter.rb
new file mode 100644
index 000000000..1e90d3c9d
--- /dev/null
+++ b/app/models/preview_card_provider_filter.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class PreviewCardProviderFilter
+  KEYS = %i(
+    status
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = PreviewCardProvider.unscoped
+
+    params.each do |key, value|
+      next if key.to_s == 'page'
+
+      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+    end
+
+    scope.order(domain: :asc)
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'status'
+      status_scope(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+
+  def status_scope(value)
+    case value.to_s
+    when 'approved'
+      PreviewCardProvider.trendable
+    when 'rejected'
+      PreviewCardProvider.not_trendable
+    when 'pending_review'
+      PreviewCardProvider.pending_review
+    else
+      raise "Unknown status: #{value}"
+    end
+  end
+end
diff --git a/app/models/tag.rb b/app/models/tag.rb
index dcce28391..f35d92b5d 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -36,6 +36,7 @@ class Tag < ApplicationRecord
   scope :usable, -> { where(usable: [true, nil]) }
   scope :listable, -> { where(listable: [true, nil]) }
   scope :trendable, -> { Setting.trendable_by_default ? where(trendable: [true, nil]) : where(trendable: true) }
+  scope :not_trendable, -> { where(trendable: false) }
   scope :recently_used, ->(account) { joins(:statuses).where(statuses: { id: account.statuses.select(:id).limit(1000) }).group(:id).order(Arel.sql('count(*) desc')) }
   scope :matches_name, ->(term) { where(arel_table[:name].lower.matches(arel_table.lower("#{sanitize_sql_like(Tag.normalize(term))}%"), nil, true)) } # Search with case-sensitive to use B-tree index
 
@@ -75,28 +76,12 @@ class Tag < ApplicationRecord
     requested_review_at.present?
   end
 
-  def use!(account, status: nil, at_time: Time.now.utc)
-    TrendingTags.record_use!(self, account, status: status, at_time: at_time)
-  end
-
-  def trending?
-    TrendingTags.trending?(self)
+  def requires_review_notification?
+    requires_review? && !requested_review?
   end
 
   def history
-    days = []
-
-    7.times do |i|
-      day = i.days.ago.beginning_of_day.to_i
-
-      days << {
-        day: day.to_s,
-        uses: Redis.current.get("activity:tags:#{id}:#{day}") || '0',
-        accounts: Redis.current.pfcount("activity:tags:#{id}:#{day}:accounts").to_s,
-      }
-    end
-
-    days
+    @history ||= Trends::History.new('tags', id)
   end
 
   class << self
diff --git a/app/models/tag_filter.rb b/app/models/tag_filter.rb
index 85bfcbea5..ecdb52503 100644
--- a/app/models/tag_filter.rb
+++ b/app/models/tag_filter.rb
@@ -2,13 +2,8 @@
 
 class TagFilter
   KEYS = %i(
-    directory
-    reviewed
-    unreviewed
-    pending_review
-    popular
-    active
-    name
+    trending
+    status
   ).freeze
 
   attr_reader :params
@@ -18,7 +13,13 @@ class TagFilter
   end
 
   def results
-    scope = Tag.unscoped
+    scope = begin
+      if params[:status] == 'pending_review'
+        Tag.unscoped
+      else
+        trending_scope
+      end
+    end
 
     params.each do |key, value|
       next if key.to_s == 'page'
@@ -26,27 +27,40 @@ class TagFilter
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
     end
 
-    scope.order(id: :desc)
+    scope
   end
 
   private
 
   def scope_for(key, value)
     case key.to_s
-    when 'reviewed'
-      Tag.reviewed.order(reviewed_at: :desc)
-    when 'unreviewed'
-      Tag.unreviewed
-    when 'pending_review'
-      Tag.pending_review.order(requested_review_at: :desc)
-    when 'popular'
-      Tag.order('max_score DESC NULLS LAST')
-    when 'active'
-      Tag.order('last_status_at DESC NULLS LAST')
-    when 'name'
-      Tag.matches_name(value)
+    when 'status'
+      status_scope(value)
     else
       raise "Unknown filter: #{key}"
     end
   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
+  end
+
+  def status_scope(value)
+    case value.to_s
+    when 'approved'
+      Tag.trendable
+    when 'rejected'
+      Tag.not_trendable
+    when 'pending_review'
+      Tag.pending_review
+    else
+      raise "Unknown status: #{value}"
+    end
+  end
 end
diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb
deleted file mode 100644
index 31890b082..000000000
--- a/app/models/trending_tags.rb
+++ /dev/null
@@ -1,128 +0,0 @@
-# frozen_string_literal: true
-
-class TrendingTags
-  KEY                  = 'trending_tags'
-  EXPIRE_HISTORY_AFTER = 7.days.seconds
-  EXPIRE_TRENDS_AFTER  = 1.day.seconds
-  THRESHOLD            = 5
-  LIMIT                = 10
-  REVIEW_THRESHOLD     = 3
-  MAX_SCORE_COOLDOWN   = 2.days.freeze
-  MAX_SCORE_HALFLIFE   = 2.hours.freeze
-
-  class << self
-    include Redisable
-
-    def record_use!(tag, account, status: nil, at_time: Time.now.utc)
-      return unless tag.usable? && !account.silenced?
-
-      # Even if a tag is not allowed to trend, we still need to
-      # record the stats since they can be displayed in other places
-      increment_historical_use!(tag.id, at_time)
-      increment_unique_use!(tag.id, account.id, at_time)
-      increment_use!(tag.id, at_time)
-
-      # Only update when the tag was last used once every 12 hours
-      # and only if a status is given (lets use ignore reblogs)
-      tag.update(last_status_at: at_time) if status.present? && (tag.last_status_at.nil? || (tag.last_status_at < at_time && tag.last_status_at < 12.hours.ago))
-    end
-
-    def update!(at_time = Time.now.utc)
-      tag_ids = redis.smembers("#{KEY}:used:#{at_time.beginning_of_day.to_i}") + redis.zrange(KEY, 0, -1)
-      tags    = Tag.trendable.where(id: tag_ids.uniq)
-
-      # First pass to calculate scores and update the set
-
-      tags.each do |tag|
-        expected  = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
-        expected  = 1.0 if expected.zero?
-        observed  = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
-        max_time  = tag.max_score_at
-        max_score = tag.max_score
-        max_score = 0 if max_time.nil? || max_time < (at_time - MAX_SCORE_COOLDOWN)
-
-        score = begin
-          if expected > observed || observed < THRESHOLD
-            0
-          else
-            ((observed - expected)**2) / expected
-          end
-        end
-
-        if score > max_score
-          max_score = score
-          max_time  = at_time
-
-          # Not interested in triggering any callbacks for this
-          tag.update_columns(max_score: max_score, max_score_at: max_time)
-        end
-
-        decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / MAX_SCORE_HALFLIFE.to_f))
-
-        if decaying_score.zero?
-          redis.zrem(KEY, tag.id)
-        else
-          redis.zadd(KEY, decaying_score, tag.id)
-        end
-      end
-
-      users_for_review = User.staff.includes(:account).to_a.select(&:allows_trending_tag_emails?)
-
-      # Second pass to notify about previously unreviewed trends
-
-      tags.each do |tag|
-        current_rank              = redis.zrevrank(KEY, tag.id)
-        needs_review_notification = tag.requires_review? && !tag.requested_review?
-        rank_passes_threshold     = current_rank.present? && current_rank <= REVIEW_THRESHOLD
-
-        next unless !tag.trendable? && rank_passes_threshold && needs_review_notification
-
-        tag.touch(:requested_review_at)
-
-        users_for_review.each do |user|
-          AdminMailer.new_trending_tag(user.account, tag).deliver_later!
-        end
-      end
-
-      # Trim older items
-
-      redis.zremrangebyrank(KEY, 0, -(LIMIT + 1))
-      redis.zremrangebyscore(KEY, '(0.3', '-inf')
-    end
-
-    def get(limit, filtered: true)
-      tag_ids = redis.zrevrange(KEY, 0, LIMIT - 1).map(&:to_i)
-
-      tags = Tag.where(id: tag_ids)
-      tags = tags.trendable if filtered
-      tags = tags.index_by(&:id)
-
-      tag_ids.map { |tag_id| tags[tag_id] }.compact.take(limit)
-    end
-
-    def trending?(tag)
-      rank = redis.zrevrank(KEY, tag.id)
-      rank.present? && rank < LIMIT
-    end
-
-    private
-
-    def increment_historical_use!(tag_id, at_time)
-      key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}"
-      redis.incrby(key, 1)
-      redis.expire(key, EXPIRE_HISTORY_AFTER)
-    end
-
-    def increment_unique_use!(tag_id, account_id, at_time)
-      key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts"
-      redis.pfadd(key, account_id)
-      redis.expire(key, EXPIRE_HISTORY_AFTER)
-    end
-
-    def increment_use!(tag_id, at_time)
-      key = "#{KEY}:used:#{at_time.beginning_of_day.to_i}"
-      redis.sadd(key, tag_id)
-      redis.expire(key, EXPIRE_HISTORY_AFTER)
-    end
-  end
-end
diff --git a/app/models/trends.rb b/app/models/trends.rb
new file mode 100644
index 000000000..7dd3a9c87
--- /dev/null
+++ b/app/models/trends.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Trends
+  def self.table_name_prefix
+    'trends_'
+  end
+
+  def self.links
+    @links ||= Trends::Links.new
+  end
+
+  def self.tags
+    @tags ||= Trends::Tags.new
+  end
+
+  def self.refresh!
+    [links, tags].each(&:refresh)
+  end
+
+  def self.request_review!
+    [links, tags].each(&:request_review) if enabled?
+  end
+
+  def self.enabled?
+    Setting.trends
+  end
+end
diff --git a/app/models/trends/base.rb b/app/models/trends/base.rb
new file mode 100644
index 000000000..b767dcb1a
--- /dev/null
+++ b/app/models/trends/base.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+class Trends::Base
+  include Redisable
+
+  class_attribute :default_options
+
+  attr_reader :options
+
+  # @param [Hash] options
+  # @option options [Integer] :threshold Minimum amount of uses by unique accounts to begin calculating the score
+  # @option options [Integer] :review_threshold Minimum rank (lower = better) before requesting a review
+  # @option options [ActiveSupport::Duration] :max_score_cooldown For this amount of time, the peak score (if bigger than current score) is decayed-from
+  # @option options [ActiveSupport::Duration] :max_score_halflife How quickly a peak score decays
+  def initialize(options = {})
+    @options = self.class.default_options.merge(options)
+  end
+
+  def register(_status)
+    raise NotImplementedError
+  end
+
+  def add(*)
+    raise NotImplementedError
+  end
+
+  def refresh(*)
+    raise NotImplementedError
+  end
+
+  def request_review
+    raise NotImplementedError
+  end
+
+  def get(*)
+    raise NotImplementedError
+  end
+
+  def score(id)
+    redis.zscore("#{key_prefix}:all", id) || 0
+  end
+
+  def rank(id)
+    redis.zrevrank("#{key_prefix}:allowed", id)
+  end
+
+  def currently_trending_ids(allowed, limit)
+    redis.zrevrange(allowed ? "#{key_prefix}:allowed" : "#{key_prefix}:all", 0, limit.positive? ? limit - 1 : limit).map(&:to_i)
+  end
+
+  protected
+
+  def key_prefix
+    raise NotImplementedError
+  end
+
+  def recently_used_ids(at_time = Time.now.utc)
+    redis.smembers(used_key(at_time)).map(&:to_i)
+  end
+
+  def record_used_id(id, at_time = Time.now.utc)
+    redis.sadd(used_key(at_time), id)
+    redis.expire(used_key(at_time), 1.day.seconds)
+  end
+
+  def trim_older_items
+    redis.zremrangebyscore("#{key_prefix}:all", '-inf', '(1')
+    redis.zremrangebyscore("#{key_prefix}:allowed", '-inf', '(1')
+  end
+
+  def score_at_rank(rank)
+    redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0
+  end
+
+  private
+
+  def used_key(at_time)
+    "#{key_prefix}:used:#{at_time.beginning_of_day.to_i}"
+  end
+end
diff --git a/app/models/trends/history.rb b/app/models/trends/history.rb
new file mode 100644
index 000000000..608e33792
--- /dev/null
+++ b/app/models/trends/history.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+class Trends::History
+  include Enumerable
+
+  class Aggregate
+    include Redisable
+
+    def initialize(prefix, id, date_range)
+      @days = date_range.map { |date| Day.new(prefix, id, date.to_time(:utc)) }
+    end
+
+    def uses
+      redis.mget(*@days.map { |day| day.key_for(:uses) }).map(&:to_i).sum
+    end
+
+    def accounts
+      redis.pfcount(*@days.map { |day| day.key_for(:accounts) })
+    end
+  end
+
+  class Day
+    include Redisable
+
+    EXPIRE_AFTER = 14.days.seconds
+
+    def initialize(prefix, id, day)
+      @prefix = prefix
+      @id     = id
+      @day    = day.beginning_of_day
+    end
+
+    attr_reader :day
+
+    def accounts
+      redis.pfcount(key_for(:accounts))
+    end
+
+    def uses
+      redis.get(key_for(:uses))&.to_i || 0
+    end
+
+    def add(account_id)
+      redis.pipelined do
+        redis.incrby(key_for(:uses), 1)
+        redis.pfadd(key_for(:accounts), account_id)
+        redis.expire(key_for(:uses), EXPIRE_AFTER)
+        redis.expire(key_for(:accounts), EXPIRE_AFTER)
+      end
+    end
+
+    def as_json
+      { day: day.to_i.to_s, accounts: accounts.to_s, uses: uses.to_s }
+    end
+
+    def key_for(suffix)
+      case suffix
+      when :accounts
+        "#{key_prefix}:#{suffix}"
+      when :uses
+        key_prefix
+      end
+    end
+
+    def key_prefix
+      "activity:#{@prefix}:#{@id}:#{day.to_i}"
+    end
+  end
+
+  def initialize(prefix, id)
+    @prefix = prefix
+    @id     = id
+  end
+
+  def get(date)
+    Day.new(@prefix, @id, date)
+  end
+
+  def add(account_id, at_time = Time.now.utc)
+    Day.new(@prefix, @id, at_time).add(account_id)
+  end
+
+  def aggregate(date_range)
+    Aggregate.new(@prefix, @id, date_range)
+  end
+
+  def each(&block)
+    if block_given?
+      (0...7).map { |i| block.call(get(i.days.ago)) }
+    else
+      to_enum(:each)
+    end
+  end
+
+  def as_json(*)
+    map(&:as_json)
+  end
+end
diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb
new file mode 100644
index 000000000..a0d65138b
--- /dev/null
+++ b/app/models/trends/links.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+class Trends::Links < Trends::Base
+  PREFIX = 'trending_links'
+
+  self.default_options = {
+    threshold: 15,
+    review_threshold: 10,
+    max_score_cooldown: 2.days.freeze,
+    max_score_halflife: 8.hours.freeze,
+  }
+
+  def register(status, at_time = Time.now.utc)
+    original_status = status.reblog? ? status.reblog : status
+
+    return unless original_status.public_visibility? && status.public_visibility? &&
+                  !original_status.account.silenced? && !status.account.silenced? &&
+                  !original_status.spoiler_text?
+
+    original_status.preview_cards.each do |preview_card|
+      add(preview_card, status.account_id, at_time) if preview_card.appropriate_for_trends?
+    end
+  end
+
+  def add(preview_card, account_id, at_time = Time.now.utc)
+    preview_card.history.add(account_id, at_time)
+    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)
+    trim_older_items
+  end
+
+  def request_review
+    preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1))
+
+    preview_cards_requiring_review = 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?
+        preview_card.provider = PreviewCardProvider.create(domain: preview_card.domain, requested_review_at: Time.now.utc)
+      else
+        preview_card.provider.touch(:requested_review_at)
+      end
+
+      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
+
+  def key_prefix
+    PREFIX
+  end
+
+  private
+
+  def calculate_scores(preview_cards, at_time)
+    preview_cards.each do |preview_card|
+      expected  = preview_card.history.get(at_time - 1.day).accounts.to_f
+      expected  = 1.0 if expected.zero?
+      observed  = preview_card.history.get(at_time).accounts.to_f
+      max_time  = preview_card.max_score_at
+      max_score = preview_card.max_score
+      max_score = 0 if max_time.nil? || max_time < (at_time - options[:max_score_cooldown])
+
+      score = begin
+        if expected > observed || observed < options[:threshold]
+          0
+        else
+          ((observed - expected)**2) / expected
+        end
+      end
+
+      if score > max_score
+        max_score = score
+        max_time  = at_time
+
+        # Not interested in triggering any callbacks for this
+        preview_card.update_columns(max_score: max_score, max_score_at: max_time)
+      end
+
+      decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / 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)
+
+        if preview_card.trendable?
+          redis.zadd("#{PREFIX}:allowed", decaying_score, preview_card.id)
+        else
+          redis.zrem("#{PREFIX}:allowed", preview_card.id)
+        end
+      end
+    end
+  end
+
+  def would_be_trending?(id)
+    score(id) > score_at_rank(options[:review_threshold] - 1)
+  end
+end
diff --git a/app/models/trends/tags.rb b/app/models/trends/tags.rb
new file mode 100644
index 000000000..13e0ab56b
--- /dev/null
+++ b/app/models/trends/tags.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+class Trends::Tags < Trends::Base
+  PREFIX = 'trending_tags'
+
+  self.default_options = {
+    threshold: 15,
+    review_threshold: 10,
+    max_score_cooldown: 2.days.freeze,
+    max_score_halflife: 4.hours.freeze,
+  }
+
+  def register(status, at_time = Time.now.utc)
+    original_status = status.reblog? ? status.reblog : status
+
+    return unless original_status.public_visibility? && status.public_visibility? &&
+                  !original_status.account.silenced? && !status.account.silenced?
+
+    original_status.tags.each do |tag|
+      add(tag, status.account_id, at_time) if tag.usable?
+    end
+  end
+
+  def add(tag, account_id, at_time = Time.now.utc)
+    tag.history.add(account_id, at_time)
+    record_used_id(tag.id, at_time)
+  end
+
+  def refresh(at_time = Time.now.utc)
+    tags = Tag.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq)
+    calculate_scores(tags, at_time)
+    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|
+      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
+
+  def key_prefix
+    PREFIX
+  end
+
+  private
+
+  def calculate_scores(tags, at_time)
+    tags.each do |tag|
+      expected  = tag.history.get(at_time - 1.day).accounts.to_f
+      expected  = 1.0 if expected.zero?
+      observed  = tag.history.get(at_time).accounts.to_f
+      max_time  = tag.max_score_at
+      max_score = tag.max_score
+      max_score = 0 if max_time.nil? || max_time < (at_time - options[:max_score_cooldown])
+
+      score = begin
+        if expected > observed || observed < options[:threshold]
+          0
+        else
+          ((observed - expected)**2) / expected
+        end
+      end
+
+      if score > max_score
+        max_score = score
+        max_time  = at_time
+
+        # Not interested in triggering any callbacks for this
+        tag.update_columns(max_score: max_score, max_score_at: max_time)
+      end
+
+      decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / 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
+    end
+  end
+
+  def would_be_trending?(id)
+    score(id) > score_at_rank(options[:review_threshold] - 1)
+  end
+end
diff --git a/app/policies/preview_card_policy.rb b/app/policies/preview_card_policy.rb
new file mode 100644
index 000000000..4f485d7fc
--- /dev/null
+++ b/app/policies/preview_card_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class PreviewCardPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def update?
+    staff?
+  end
+end
diff --git a/app/policies/preview_card_provider_policy.rb b/app/policies/preview_card_provider_policy.rb
new file mode 100644
index 000000000..598d54a5e
--- /dev/null
+++ b/app/policies/preview_card_provider_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class PreviewCardProviderPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def update?
+    staff?
+  end
+end
diff --git a/app/serializers/rest/trends/link_serializer.rb b/app/serializers/rest/trends/link_serializer.rb
new file mode 100644
index 000000000..232483490
--- /dev/null
+++ b/app/serializers/rest/trends/link_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::Trends::LinkSerializer < REST::PreviewCardSerializer
+  attributes :history
+end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 51956ce7e..94dc6389f 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -50,7 +50,7 @@ class FetchLinkCardService < BaseService
       # We follow redirects, and ideally we want to save the preview card for
       # the destination URL and not any link shortener in-between, so here
       # we set the URL to the one of the last response in the redirect chain
-      @url  = res.request.uri.to_s.to_s
+      @url  = res.request.uri.to_s
       @card = PreviewCard.find_or_initialize_by(url: @url) if @card.url != @url
 
       if res.code == 200 && res.mime_type == 'text/html'
@@ -66,6 +66,7 @@ class FetchLinkCardService < BaseService
   def attach_card
     @status.preview_cards << @card
     Rails.cache.delete(@status)
+    Trends.links.register(@status)
   end
 
   def parse_urls
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 85aaec4d6..294ae43eb 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -91,7 +91,8 @@ class PostStatusService < BaseService
   end
 
   def postprocess_status!
-    LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text?
+    Trends.tags.register(@status)
+    LinkCrawlWorker.perform_async(@status.id)
     DistributionWorker.perform_async(@status.id)
     ActivityPub::DistributionWorker.perform_async(@status.id)
     PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index c42b79db8..47277c56c 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -8,7 +8,7 @@ class ProcessHashtagsService < BaseService
     Tag.find_or_create_by_names(tags) do |tag|
       status.tags << tag
       records << tag
-      tag.use!(status.account, status: status, at_time: status.created_at) if status.public_visibility?
+      tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago)
     end
 
     return unless status.distributable?
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 744bdf567..ece91847a 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -30,12 +30,13 @@ 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)
     DistributionWorker.perform_async(reblog.id)
     ActivityPub::DistributionWorker.perform_async(reblog.id)
 
     create_notification(reblog)
     bump_potential_friendship(account, reblog)
-    record_use(account, reblog)
 
     reblog
   end
@@ -60,16 +61,6 @@ class ReblogService < BaseService
     PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog)
   end
 
-  def record_use(account, reblog)
-    return unless reblog.public_visibility?
-
-    original_status = reblog.reblog
-
-    original_status.tags.each do |tag|
-      tag.use!(account)
-    end
-  end
-
   def build_json(reblog)
     Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account))
   end
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 560eba7b4..895333a58 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -42,7 +42,7 @@
       %span= t('admin.dashboard.pending_users_html', count: @pending_users_count)
       = fa_icon 'chevron-right fw'
 
-    = link_to admin_tags_path(pending_review: '1'), class: 'dashboard__quick-access' do
+    = link_to admin_trends_tags_path(status: 'pending_review'), class: 'dashboard__quick-access' do
       %span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
       = fa_icon 'chevron-right fw'
 
diff --git a/app/views/admin/tags/_tag.html.haml b/app/views/admin/tags/_tag.html.haml
deleted file mode 100644
index ac0c72816..000000000
--- a/app/views/admin/tags/_tag.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-.batch-table__row
-  - if batch_available
-    %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
-      = f.check_box :tag_ids, { multiple: true, include_hidden: false }, tag.id
-
-  .directory__tag
-    = link_to admin_tag_path(tag.id) do
-      %h4
-        = fa_icon 'hashtag'
-        = tag.name
-
-        %small
-          = t('admin.tags.unique_uses_today', count: tag.history.first[:accounts])
-
-          - if tag.trending?
-            = fa_icon 'fire fw'
-            = t('admin.tags.trending_right_now')
-
-      .trends__item__current= friendly_number_to_human tag.history.first[:uses]
diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml
deleted file mode 100644
index d78f3c6d1..000000000
--- a/app/views/admin/tags/index.html.haml
+++ /dev/null
@@ -1,74 +0,0 @@
-- content_for :page_title do
-  = t('admin.tags.title')
-
-- content_for :header_tags do
-  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
-
-.filters
-  .filter-subset
-    %strong= t('admin.tags.review')
-    %ul
-      %li= filter_link_to t('generic.all'), reviewed: nil, unreviewed: nil, pending_review: nil
-      %li= filter_link_to t('admin.tags.unreviewed'), unreviewed: '1', reviewed: nil, pending_review: nil
-      %li= filter_link_to t('admin.tags.reviewed'), reviewed: '1', unreviewed: nil, pending_review: nil
-      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), pending_review: '1', reviewed: nil, unreviewed: nil
-
-  .filter-subset
-    %strong= t('generic.order_by')
-    %ul
-      %li= filter_link_to t('admin.tags.most_recent'), popular: nil, active: nil
-      %li= filter_link_to t('admin.tags.last_active'), active: '1', popular: nil
-      %li= filter_link_to t('admin.tags.most_popular'), popular: '1', active: nil
-
-
-= form_tag admin_tags_url, method: 'GET', class: 'simple_form' do
-  .fields-group
-    - TagFilter::KEYS.each do |key|
-      = hidden_field_tag key, params[key] if params[key].present?
-
-    - %i(name).each do |key|
-      .input.string.optional
-        = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.tags.#{key}")
-
-    .actions
-      %button.button= t('admin.accounts.search')
-      = link_to t('admin.accounts.reset'), admin_tags_path, class: 'button negative'
-
-%hr.spacer/
-
-= form_for(@form, url: batch_admin_tags_path) do |f|
-  = hidden_field_tag :page, params[:page] || 1
-
-  - TagFilter::KEYS.each do |key|
-    = hidden_field_tag key, params[key] if params[key].present?
-
-  .batch-table.optional
-    .batch-table__toolbar
-      - if params[:pending_review] == '1' || params[:unreviewed] == '1'
-        %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.accounts.approve')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-
-          = f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-      - else
-        .batch-table__toolbar__actions
-          %span.neutral-hint= t('generic.no_batch_actions_available')
-
-    .batch-table__body
-      - if @tags.empty?
-        = nothing_here 'nothing-here--under-tabs'
-      - else
-        = render partial: 'tag', collection: @tags, locals: { f: f, batch_available: params[:pending_review] == '1' || params[:unreviewed] == '1' }
-
-= paginate @tags
-
-- if params[:pending_review] == '1' || params[:unreviewed] == '1'
-  %hr.spacer/
-
-  %div.action-buttons
-    %div
-      = link_to t('admin.accounts.approve_all'), approve_all_admin_tags_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button'
-
-    %div
-      = link_to t('admin.accounts.reject_all'), reject_all_admin_tags_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive'
diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml
index c4caffda1..007dc005e 100644
--- a/app/views/admin/tags/show.html.haml
+++ b/app/views/admin/tags/show.html.haml
@@ -1,15 +1,50 @@
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
 - content_for :page_title do
   = "##{@tag.name}"
 
-.dashboard__counters
-  %div
-    = link_to tag_url(@tag), target: '_blank', rel: 'noopener noreferrer' do
-      .dashboard__counters__num= number_with_delimiter @accounts_today
-      .dashboard__counters__label= t 'admin.tags.accounts_today'
-  %div
-    %div
-      .dashboard__counters__num= number_with_delimiter @accounts_week
-      .dashboard__counters__label= t 'admin.tags.accounts_week'
+- content_for :heading_actions do
+  = l(@time_period.first)
+  = ' - '
+  = l(@time_period.last)
+
+.dashboard
+  .dashboard__item
+    = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure')
+  .dashboard__item
+    = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure')
+  .dashboard__item
+    = react_admin_component :counter, measure: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_servers_measure')
+  .dashboard__item
+    = react_admin_component :dimension, dimension: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_servers_dimension')
+  .dashboard__item
+    = react_admin_component :dimension, dimension: 'tag_languages', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_languages_dimension')
+  .dashboard__item
+    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.usable? ? 'positive' : 'negative'] do
+      - if @tag.usable?
+        %span= t('admin.trends.tags.usable')
+        = fa_icon 'check fw'
+      - else
+        %span= t('admin.trends.tags.not_usable')
+        = fa_icon 'lock fw'
+
+    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.trendable? ? 'positive' : 'negative'] do
+      - if @tag.trendable?
+        %span= t('admin.trends.tags.trendable')
+        = fa_icon 'check fw'
+      - else
+        %span= t('admin.trends.tags.not_trendable')
+        = fa_icon 'lock fw'
+
+
+    = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.listable? ? 'positive' : 'negative'] do
+      - if @tag.listable?
+        %span= t('admin.trends.tags.listable')
+        = fa_icon 'check fw'
+      - else
+        %span= t('admin.trends.tags.not_listable')
+        = fa_icon 'lock fw'
 
 %hr.spacer/
 
@@ -26,18 +61,3 @@
 
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
-
-%hr.spacer/
-
-%h3= t 'admin.tags.breakdown'
-
-.table-wrapper
-  %table.table
-    %tbody
-      - total = @usage_by_domain.sum(&:last).to_f
-
-      - @usage_by_domain.each do |(domain, count)|
-        %tr
-          %th= domain || site_hostname
-          %td= number_to_percentage((count / total) * 100, precision: 1)
-          %td= number_with_delimiter count
diff --git a/app/views/admin/trends/links/_preview_card.html.haml b/app/views/admin/trends/links/_preview_card.html.haml
new file mode 100644
index 000000000..dfed13b68
--- /dev/null
+++ b/app/views/admin/trends/links/_preview_card.html.haml
@@ -0,0 +1,30 @@
+.batch-table__row{ class: [preview_card.provider&.requires_review? && 'batch-table__row--attention', !preview_card.provider&.requires_review? && !preview_card.trendable? && 'batch-table__row--muted'] }
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :preview_card_ids, { multiple: true, include_hidden: false }, preview_card.id
+
+  .batch-table__row__content.pending-account
+    .pending-account__header
+      = link_to preview_card.title, preview_card.url
+
+      %br/
+
+      - if preview_card.provider_name.present?
+        = preview_card.provider_name
+        •
+
+      - if preview_card.language.present?
+        = human_locale(preview_card.language)
+        •
+
+      = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts })
+
+      - if preview_card.trendable? && (rank = Trends.links.rank(preview_card.id))
+        •
+        %abbr{ title: t('admin.trends.tags.current_score', score: Trends.links.score(preview_card.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
+
+        - if preview_card.max_score_at && preview_card.max_score_at >= Trends::Links::MAX_SCORE_COOLDOWN.ago && preview_card.max_score_at < 1.day.ago
+          •
+          = t('admin.trends.tags.peaked_on_and_decaying', date: l(preview_card.max_score_at.to_date, format: :short))
+      - elsif preview_card.provider&.requires_review?
+        •
+        = t('admin.trends.pending_review')
diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml
new file mode 100644
index 000000000..240ae722b
--- /dev/null
+++ b/app/views/admin/trends/links/index.html.haml
@@ -0,0 +1,41 @@
+- content_for :page_title do
+  = t('admin.trends.links.title')
+
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+.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'
+
+%hr.spacer/
+
+= form_for(@form, url: batch_admin_trends_links_path) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  - PreviewCardFilter::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.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('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') }
+    .batch-table__body
+      - if @preview_cards.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'preview_card', collection: @preview_cards, locals: { f: f }
+
+= paginate @preview_cards
diff --git a/app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml b/app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml
new file mode 100644
index 000000000..e40e6529d
--- /dev/null
+++ b/app/views/admin/trends/links/preview_card_providers/_preview_card_provider.html.haml
@@ -0,0 +1,16 @@
+.batch-table__row{ class: [preview_card_provider.requires_review? && 'batch-table__row--attention', !preview_card_provider.requires_review? && !preview_card_provider.trendable? && 'batch-table__row--muted'] }
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :preview_card_provider_ids, { multiple: true, include_hidden: false }, preview_card_provider.id
+
+  .batch-table__row__content.pending-account
+    .pending-account__header
+      %strong= preview_card_provider.domain
+
+      %br/
+
+      - if preview_card_provider.requires_review?
+        = t('admin.trends.pending_review')
+      - elsif preview_card_provider.trendable?
+        = t('admin.trends.preview_card_providers.allowed')
+      - else
+        = t('admin.trends.preview_card_providers.rejected')
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
new file mode 100644
index 000000000..eac6e641f
--- /dev/null
+++ b/app/views/admin/trends/links/preview_card_providers/index.html.haml
@@ -0,0 +1,43 @@
+- content_for :page_title do
+  = t('admin.trends.preview_card_providers.title')
+
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+.filters
+  .filter-subset
+    %strong= t('admin.tags.review')
+    %ul
+      %li= filter_link_to t('generic.all'), status: nil
+      %li= filter_link_to t('admin.trends.approved'), status: 'approved'
+      %li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
+      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{PreviewCardProvider.pending_review.count})"], ' '), status: 'pending_review'
+  .back-link
+    = link_to admin_trends_links_path do
+      = fa_icon 'chevron-left fw'
+      = t('admin.trends.links.title')
+
+
+%hr.spacer/
+
+= 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|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .batch-table.optional
+    .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.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.trends.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
+    .batch-table__body
+      - if @preview_card_providers.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'preview_card_provider', collection: @preview_card_providers, locals: { f: f }
+
+= paginate @preview_card_providers
diff --git a/app/views/admin/trends/tags/_tag.html.haml b/app/views/admin/trends/tags/_tag.html.haml
new file mode 100644
index 000000000..c4af77b00
--- /dev/null
+++ b/app/views/admin/trends/tags/_tag.html.haml
@@ -0,0 +1,24 @@
+.batch-table__row{ class: [tag.requires_review? && 'batch-table__row--attention', !tag.requires_review? && !tag.trendable? && 'batch-table__row--muted'] }
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :tag_ids, { multiple: true, include_hidden: false }, tag.id
+
+  .batch-table__row__content.pending-account
+    .pending-account__header
+      = link_to admin_tag_path(tag.id) do
+        = fa_icon 'hashtag'
+        = tag.name
+
+      %br/
+
+      = t('admin.trends.tags.used_by_over_week', count: tag.history.reduce(0) { |sum, day| sum + day.accounts })
+
+      - if tag.trendable? && (rank = Trends.tags.rank(tag.id))
+        •
+        %abbr{ title: t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
+
+        - if tag.max_score_at && tag.max_score_at >= Trends::Tags::MAX_SCORE_COOLDOWN.ago && tag.max_score_at < 1.day.ago
+          •
+          = t('admin.trends.tags.peaked_on_and_decaying', date: l(tag.max_score_at.to_date, format: :short))
+      - elsif tag.requires_review?
+        •
+        = t('admin.trends.pending_review')
diff --git a/app/views/admin/trends/tags/index.html.haml b/app/views/admin/trends/tags/index.html.haml
new file mode 100644
index 000000000..8df0a9920
--- /dev/null
+++ b/app/views/admin/trends/tags/index.html.haml
@@ -0,0 +1,38 @@
+- content_for :page_title do
+  = t('admin.trends.tags.title')
+
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+.filters
+  .filter-subset
+    %strong= t('admin.tags.review')
+    %ul
+      %li= filter_link_to t('generic.all'), status: nil
+      %li= filter_link_to t('admin.trends.approved'), status: 'approved'
+      %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|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .batch-table.optional
+    .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.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.trends.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
+    .batch-table__body
+      - if @tags.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'tag', collection: @tags, locals: { f: f }
+
+= paginate @tags
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..51789aca5
--- /dev/null
+++ b/app/views/admin_mailer/new_trending_links.text.erb
@@ -0,0 +1,16 @@
+<%= 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_tag.text.erb b/app/views/admin_mailer/new_trending_tag.text.erb
deleted file mode 100644
index e4bfdc591..000000000
--- a/app/views/admin_mailer/new_trending_tag.text.erb
+++ /dev/null
@@ -1,5 +0,0 @@
-<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
-
-<%= raw t('admin_mailer.new_trending_tag.body', name: @tag.name) %>
-
-<%= raw t('application_mailer.view')%> <%= admin_tags_url(pending_review: '1') %>
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..5051e8a96
--- /dev/null
+++ b/app/views/admin_mailer/new_trending_tags.text.erb
@@ -0,0 +1,16 @@
+<%= 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(pending_review: '1') %>
diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml
index 7ec91c06a..6826c3b58 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 = TrendingTags.get(3)
+  - trends = Trends.tags.get(true, 3)
 
   - unless trends.empty?
     .endorsements-widget.trends-widget
diff --git a/app/workers/scheduler/trending_tags_scheduler.rb b/app/workers/scheduler/trends/refresh_scheduler.rb
index 94d76d010..b559ba46b 100644
--- a/app/workers/scheduler/trending_tags_scheduler.rb
+++ b/app/workers/scheduler/trends/refresh_scheduler.rb
@@ -1,11 +1,11 @@
 # frozen_string_literal: true
 
-class Scheduler::TrendingTagsScheduler
+class Scheduler::Trends::RefreshScheduler
   include Sidekiq::Worker
 
   sidekiq_options retry: 0
 
   def perform
-    TrendingTags.update! if Setting.trends
+    Trends.refresh!
   end
 end
diff --git a/app/workers/scheduler/trends/review_notifications_scheduler.rb b/app/workers/scheduler/trends/review_notifications_scheduler.rb
new file mode 100644
index 000000000..f334261bd
--- /dev/null
+++ b/app/workers/scheduler/trends/review_notifications_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Scheduler::Trends::ReviewNotificationsScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options retry: 0
+
+  def perform
+    Trends.request_review!
+  end
+end