about summary refs log tree commit diff
path: root/app/models/trends/statuses.rb
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2022-02-25 00:34:14 +0100
committerGitHub <noreply@github.com>2022-02-25 00:34:14 +0100
commit27965ce5edff20db2de1dd233c88f8393bb0da0b (patch)
tree6714a950c1b9facc8c7bd1907e81e777257e5538 /app/models/trends/statuses.rb
parenta29a982eaa0536a741b43ffb3397c74e3abe7196 (diff)
Add trending statuses (#17431)
* Add trending statuses

* Fix dangling items with stale scores in localized sets

* Various fixes and improvements

- Change approve_all/reject_all to approve_accounts/reject_accounts
- Change Trends::Query methods to not mutate the original query
- Change Trends::Query#skip to offset
- Change follow recommendations to be refreshed in a transaction

* Add tests for trending statuses filtering behaviour

* Fix not applying filtering scope in controller
Diffstat (limited to 'app/models/trends/statuses.rb')
-rw-r--r--app/models/trends/statuses.rb142
1 files changed, 142 insertions, 0 deletions
diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb
new file mode 100644
index 000000000..e785413ec
--- /dev/null
+++ b/app/models/trends/statuses.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+class Trends::Statuses < Trends::Base
+  PREFIX = 'trending_statuses'
+
+  self.default_options = {
+    threshold: 5,
+    review_threshold: 3,
+    score_halflife: 2.hours.freeze,
+  }
+
+  class Query < Trends::Query
+    def filtered_for!(account)
+      @account = account
+      self
+    end
+
+    def filtered_for(account)
+      clone.filtered_for!(account)
+    end
+
+    private
+
+    def apply_scopes(scope)
+      scope.includes(:account)
+    end
+
+    def perform_queries
+      return super if @account.nil?
+
+      statuses        = super
+      account_ids     = statuses.map(&:account_id)
+      account_domains = statuses.map(&:account_domain)
+
+      preloaded_relations = {
+        blocking: Account.blocking_map(account_ids, @account.id),
+        blocked_by: Account.blocked_by_map(account_ids, @account.id),
+        muting: Account.muting_map(account_ids, @account.id),
+        following: Account.following_map(account_ids, @account.id),
+        domain_blocking_by_domain: Account.domain_blocking_map_by_domain(account_domains, @account.id),
+      }
+
+      statuses.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
+    end
+  end
+
+  def register(status, at_time = Time.now.utc)
+    add(status.proper, status.account_id, at_time) if eligible?(status)
+  end
+
+  def add(status, _account_id, at_time = Time.now.utc)
+    # We rely on the total reblogs and favourites count, so we
+    # don't record which account did the what and when here
+
+    record_used_id(status.id, at_time)
+  end
+
+  def query
+    Query.new(key_prefix, klass)
+  end
+
+  def refresh(at_time = Time.now.utc)
+    statuses = Status.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq).includes(:account, :media_attachments)
+    calculate_scores(statuses, at_time)
+    trim_older_items
+  end
+
+  def request_review
+    statuses = Status.where(id: currently_trending_ids(false, -1)).includes(:account)
+
+    statuses.filter_map do |status|
+      next unless would_be_trending?(status.id) && !status.trendable? && status.requires_review_notification?
+
+      status.account.touch(:requested_review_at)
+      status
+    end
+  end
+
+  protected
+
+  def key_prefix
+    PREFIX
+  end
+
+  def klass
+    Status
+  end
+
+  private
+
+  def eligible?(status)
+    original_status = status.proper
+
+    original_status.public_visibility? &&
+      original_status.account.discoverable? && !original_status.account.silenced? &&
+      original_status.spoiler_text.blank? && !original_status.sensitive? && !original_status.reply?
+  end
+
+  def calculate_scores(statuses, at_time)
+    redis.pipelined do
+      statuses.each do |status|
+        expected  = 1.0
+        observed  = (status.reblogs_count + status.favourites_count).to_f
+
+        score = begin
+          if expected > observed || observed < options[:threshold]
+            0
+          else
+            ((observed - expected)**2) / expected
+          end
+        end
+
+        decaying_score = score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
+
+        add_to_and_remove_from_subsets(status.id, decaying_score, {
+          all: true,
+          allowed: status.trendable? && status.account.discoverable?,
+        })
+
+        next unless valid_locale?(status.language)
+
+        add_to_and_remove_from_subsets(status.id, decaying_score, {
+          "all:#{status.language}" => true,
+          "allowed:#{status.language}" => status.trendable? && status.account.discoverable?,
+        })
+      end
+
+      # Clean up localized sets by calculating the intersection with the main
+      # set. We do this instead of just deleting the localized sets to avoid
+      # having moments where the API returns empty results
+
+      Trends.available_locales.each do |locale|
+        redis.zinterstore("#{key_prefix}:all:#{locale}", ["#{key_prefix}:all:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
+        redis.zinterstore("#{key_prefix}:allowed:#{locale}", ["#{key_prefix}:allowed:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
+      end
+    end
+  end
+
+  def would_be_trending?(id)
+    score(id) > score_at_rank(options[:review_threshold] - 1)
+  end
+end