about summary refs log tree commit diff
path: root/app/models
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2021-04-12 12:37:14 +0200
committerGitHub <noreply@github.com>2021-04-12 12:37:14 +0200
commitf7117646afddb2676e9275d8efe90c3a20c59021 (patch)
treeefb9ba8f7b22d27b0a1ea595cfa30030f5d03b09 /app/models
parentad61265268f13d9b2a04e2e176724d8a7376f85a (diff)
Add cold-start follow recommendations (#15945)
Diffstat (limited to 'app/models')
-rw-r--r--app/models/account.rb3
-rw-r--r--app/models/account_suggestions.rb17
-rw-r--r--app/models/account_summary.rb25
-rw-r--r--app/models/concerns/account_associations.rb3
-rw-r--r--app/models/follow_recommendation.rb39
-rw-r--r--app/models/follow_recommendation_filter.rb26
-rw-r--r--app/models/follow_recommendation_suppression.rb28
-rw-r--r--app/models/form/account_batch.rb18
8 files changed, 158 insertions, 1 deletions
diff --git a/app/models/account.rb b/app/models/account.rb
index d85fd1f6e..80689d4aa 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -110,6 +110,7 @@ class Account < ApplicationRecord
   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
   scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
   scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
+  scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) }
   scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
   scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
   scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
@@ -363,7 +364,7 @@ class Account < ApplicationRecord
   end
 
   def excluded_from_timeline_account_ids
-    Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) }
+    Rails.cache.fetch("exclude_account_ids_for:#{id}") { block_relationships.pluck(:target_account_id) + blocked_by_relationships.pluck(:account_id) + mute_relationships.pluck(:target_account_id) }
   end
 
   def excluded_from_timeline_domains
diff --git a/app/models/account_suggestions.rb b/app/models/account_suggestions.rb
new file mode 100644
index 000000000..7fe9d618e
--- /dev/null
+++ b/app/models/account_suggestions.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AccountSuggestions
+  class Suggestion < ActiveModelSerializers::Model
+    attributes :account, :source
+  end
+
+  def self.get(account, limit)
+    suggestions = PotentialFriendshipTracker.get(account, limit).map { |target_account| Suggestion.new(account: target_account, source: :past_interaction) }
+    suggestions.concat(FollowRecommendation.get(account, limit - suggestions.size, suggestions.map { |suggestion| suggestion.account.id }).map { |target_account| Suggestion.new(account: target_account, source: :global) }) if suggestions.size < limit
+    suggestions
+  end
+
+  def self.remove(account, target_account_id)
+    PotentialFriendshipTracker.remove(account.id, target_account_id)
+  end
+end
diff --git a/app/models/account_summary.rb b/app/models/account_summary.rb
new file mode 100644
index 000000000..6a7e17c6c
--- /dev/null
+++ b/app/models/account_summary.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_summaries
+#
+#  account_id :bigint(8)        primary key
+#  language   :string
+#  sensitive  :boolean
+#
+
+class AccountSummary < ApplicationRecord
+  self.primary_key = :account_id
+
+  scope :safe, -> { where(sensitive: false) }
+  scope :localized, ->(locale) { where(language: locale) }
+  scope :filtered, -> { joins(arel_table.join(FollowRecommendationSuppression.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:account_id].eq(FollowRecommendationSuppression.arel_table[:account_id])).join_sources).where(FollowRecommendationSuppression.arel_table[:id].eq(nil)) }
+
+  def self.refresh
+    Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false)
+  end
+
+  def readonly?
+    true
+  end
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index 98849f8fc..aaf371ebd 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -63,5 +63,8 @@ module AccountAssociations
 
     # Account deletion requests
     has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
+
+    # Follow recommendations
+    has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy
   end
 end
diff --git a/app/models/follow_recommendation.rb b/app/models/follow_recommendation.rb
new file mode 100644
index 000000000..c4355224d
--- /dev/null
+++ b/app/models/follow_recommendation.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: follow_recommendations
+#
+#  account_id :bigint(8)        primary key
+#  rank       :decimal(, )
+#  reason     :text             is an Array
+#
+
+class FollowRecommendation < ApplicationRecord
+  self.primary_key = :account_id
+
+  belongs_to :account_summary, foreign_key: :account_id
+  belongs_to :account, foreign_key: :account_id
+
+  scope :safe, -> { joins(:account_summary).merge(AccountSummary.safe) }
+  scope :localized, ->(locale) { joins(:account_summary).merge(AccountSummary.localized(locale)) }
+  scope :filtered, -> { joins(:account_summary).merge(AccountSummary.filtered) }
+
+  def readonly?
+    true
+  end
+
+  def self.get(account, limit, exclude_account_ids = [])
+    account_ids = Redis.current.zrevrange("follow_recommendations:#{account.user_locale}", 0, -1).map(&:to_i) - exclude_account_ids - [account.id]
+
+    return [] if account_ids.empty? || limit < 1
+
+    accounts = Account.followable_by(account)
+                      .not_excluded_by_account(account)
+                      .not_domain_blocked_by_account(account)
+                      .where(id: account_ids)
+                      .limit(limit)
+                      .index_by(&:id)
+
+    account_ids.map { |id| accounts[id] }.compact
+  end
+end
diff --git a/app/models/follow_recommendation_filter.rb b/app/models/follow_recommendation_filter.rb
new file mode 100644
index 000000000..acf03cd84
--- /dev/null
+++ b/app/models/follow_recommendation_filter.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class FollowRecommendationFilter
+  KEYS = %i(
+    language
+    status
+  ).freeze
+
+  attr_reader :params, :language
+
+  def initialize(params)
+    @language = params.delete('language') || I18n.locale
+    @params   = params
+  end
+
+  def results
+    if params['status'] == 'suppressed'
+      Account.joins(:follow_recommendation_suppression).order(FollowRecommendationSuppression.arel_table[:id].desc).to_a
+    else
+      account_ids = Redis.current.zrevrange("follow_recommendations:#{@language}", 0, -1).map(&:to_i)
+      accounts    = Account.where(id: account_ids).index_by(&:id)
+
+      account_ids.map { |id| accounts[id] }.compact
+    end
+  end
+end
diff --git a/app/models/follow_recommendation_suppression.rb b/app/models/follow_recommendation_suppression.rb
new file mode 100644
index 000000000..170506b85
--- /dev/null
+++ b/app/models/follow_recommendation_suppression.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: follow_recommendation_suppressions
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class FollowRecommendationSuppression < ApplicationRecord
+  include Redisable
+
+  belongs_to :account
+
+  after_commit :remove_follow_recommendations, on: :create
+
+  private
+
+  def remove_follow_recommendations
+    redis.pipelined do
+      I18n.available_locales.each do |locale|
+        redis.zrem("follow_recommendations:#{locale}", account_id)
+      end
+    end
+  end
+end
diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb
index 26d6d3abf..698933c9f 100644
--- a/app/models/form/account_batch.rb
+++ b/app/models/form/account_batch.rb
@@ -21,6 +21,10 @@ class Form::AccountBatch
       approve!
     when 'reject'
       reject!
+    when 'suppress_follow_recommendation'
+      suppress_follow_recommendation!
+    when 'unsuppress_follow_recommendation'
+      unsuppress_follow_recommendation!
     end
   end
 
@@ -79,4 +83,18 @@ class Form::AccountBatch
     records.each { |account| authorize(account.user, :reject?) }
            .each { |account| DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) }
   end
+
+  def suppress_follow_recommendation!
+    authorize(:follow_recommendation, :suppress?)
+
+    accounts.each do |account|
+      FollowRecommendationSuppression.create(account: account)
+    end
+  end
+
+  def unsuppress_follow_recommendation!
+    authorize(:follow_recommendation, :unsuppress?)
+
+    FollowRecommendationSuppression.where(account_id: account_ids).destroy_all
+  end
 end