Change algorithm of follow recommendations (#28314)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
		
							
								
								
									
										7
									
								
								.github/renovate.json5
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/renovate.json5
									
									
									
									
										vendored
									
									
								
							@@ -102,9 +102,12 @@
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
      // Group actions/*-artifact in the same PR
 | 
					      // Group actions/*-artifact in the same PR
 | 
				
			||||||
      matchManagers: ['github-actions'],
 | 
					      matchManagers: ['github-actions'],
 | 
				
			||||||
      matchPackageNames: ['actions/download-artifact', 'actions/upload-artifact'],
 | 
					      matchPackageNames: [
 | 
				
			||||||
 | 
					        'actions/download-artifact',
 | 
				
			||||||
 | 
					        'actions/upload-artifact',
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
      matchUpdateTypes: ['major'],
 | 
					      matchUpdateTypes: ['major'],
 | 
				
			||||||
      groupName: 'artifact actions (major)'
 | 
					      groupName: 'artifact actions (major)',
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      // Update @types/* packages every week, with one grouped PR
 | 
					      // Update @types/* packages every week, with one grouped PR
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,7 @@ module Admin
 | 
				
			|||||||
      authorize :follow_recommendation, :show?
 | 
					      authorize :follow_recommendation, :show?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      @form     = Form::AccountBatch.new
 | 
					      @form     = Form::AccountBatch.new
 | 
				
			||||||
      @accounts = filtered_follow_recommendations
 | 
					      @accounts = filtered_follow_recommendations.page(params[:page])
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def update
 | 
					    def update
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,22 +3,23 @@
 | 
				
			|||||||
class Api::V1::SuggestionsController < Api::BaseController
 | 
					class Api::V1::SuggestionsController < Api::BaseController
 | 
				
			||||||
  include Authorization
 | 
					  include Authorization
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  before_action -> { doorkeeper_authorize! :read }
 | 
					  before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
 | 
				
			||||||
 | 
					  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
 | 
				
			||||||
  before_action :require_user!
 | 
					  before_action :require_user!
 | 
				
			||||||
 | 
					  before_action :set_suggestions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def index
 | 
					  def index
 | 
				
			||||||
    suggestions = suggestions_source.get(current_account, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT))
 | 
					    render json: @suggestions.get(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:offset].to_i).map(&:account), each_serializer: REST::AccountSerializer
 | 
				
			||||||
    render json: suggestions.map(&:account), each_serializer: REST::AccountSerializer
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def destroy
 | 
					  def destroy
 | 
				
			||||||
    suggestions_source.remove(current_account, params[:id])
 | 
					    @suggestions.remove(params[:id])
 | 
				
			||||||
    render_empty
 | 
					    render_empty
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def suggestions_source
 | 
					  def set_suggestions
 | 
				
			||||||
    AccountSuggestions::PastInteractionsSource.new
 | 
					    @suggestions = AccountSuggestions.new(current_account)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,17 +3,23 @@
 | 
				
			|||||||
class Api::V2::SuggestionsController < Api::BaseController
 | 
					class Api::V2::SuggestionsController < Api::BaseController
 | 
				
			||||||
  include Authorization
 | 
					  include Authorization
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  before_action -> { doorkeeper_authorize! :read }
 | 
					  before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
 | 
				
			||||||
 | 
					  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
 | 
				
			||||||
  before_action :require_user!
 | 
					  before_action :require_user!
 | 
				
			||||||
  before_action :set_suggestions
 | 
					  before_action :set_suggestions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def index
 | 
					  def index
 | 
				
			||||||
    render json: @suggestions, each_serializer: REST::SuggestionSerializer
 | 
					    render json: @suggestions.get(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:offset].to_i), each_serializer: REST::SuggestionSerializer
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def destroy
 | 
				
			||||||
 | 
					    @suggestions.remove(params[:id])
 | 
				
			||||||
 | 
					    render_empty
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def set_suggestions
 | 
					  def set_suggestions
 | 
				
			||||||
    @suggestions = AccountSuggestions.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
 | 
					    @suggestions = AccountSuggestions.new(current_account)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,31 +0,0 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class PotentialFriendshipTracker
 | 
					 | 
				
			||||||
  EXPIRE_AFTER = 90.days.seconds
 | 
					 | 
				
			||||||
  MAX_ITEMS    = 80
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  WEIGHTS = {
 | 
					 | 
				
			||||||
    reply: 1,
 | 
					 | 
				
			||||||
    favourite: 10,
 | 
					 | 
				
			||||||
    reblog: 20,
 | 
					 | 
				
			||||||
  }.freeze
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  class << self
 | 
					 | 
				
			||||||
    include Redisable
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def record(account_id, target_account_id, action)
 | 
					 | 
				
			||||||
      return if account_id == target_account_id
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      key    = "interactions:#{account_id}"
 | 
					 | 
				
			||||||
      weight = WEIGHTS[action]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      redis.zincrby(key, weight, target_account_id)
 | 
					 | 
				
			||||||
      redis.zremrangebyrank(key, 0, -MAX_ITEMS)
 | 
					 | 
				
			||||||
      redis.expire(key, EXPIRE_AFTER)
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def remove(account_id, target_account_id)
 | 
					 | 
				
			||||||
      redis.zrem("interactions:#{account_id}", target_account_id)
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
end
 | 
					 | 
				
			||||||
@@ -19,6 +19,7 @@ class AccountDomainBlock < ApplicationRecord
 | 
				
			|||||||
  validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
 | 
					  validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  after_commit :invalidate_domain_blocking_cache
 | 
					  after_commit :invalidate_domain_blocking_cache
 | 
				
			||||||
 | 
					  after_commit :invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -26,4 +27,8 @@ class AccountDomainBlock < ApplicationRecord
 | 
				
			|||||||
    Rails.cache.delete("exclude_domains_for:#{account_id}")
 | 
					    Rails.cache.delete("exclude_domains_for:#{account_id}")
 | 
				
			||||||
    Rails.cache.delete(['exclude_domains', account_id, domain])
 | 
					    Rails.cache.delete(['exclude_domains', account_id, domain])
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					    Rails.cache.delete("follow_recommendations/#{account_id}")
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,28 +1,48 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountSuggestions
 | 
					class AccountSuggestions
 | 
				
			||||||
 | 
					  include DatabaseHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  SOURCES = [
 | 
					  SOURCES = [
 | 
				
			||||||
    AccountSuggestions::SettingSource,
 | 
					    AccountSuggestions::SettingSource,
 | 
				
			||||||
    AccountSuggestions::PastInteractionsSource,
 | 
					    AccountSuggestions::FriendsOfFriendsSource,
 | 
				
			||||||
 | 
					    AccountSuggestions::SimilarProfilesSource,
 | 
				
			||||||
    AccountSuggestions::GlobalSource,
 | 
					    AccountSuggestions::GlobalSource,
 | 
				
			||||||
  ].freeze
 | 
					  ].freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def self.get(account, limit)
 | 
					  BATCH_SIZE = 40
 | 
				
			||||||
    SOURCES.each_with_object([]) do |source_class, suggestions|
 | 
					 | 
				
			||||||
      source_suggestions = source_class.new.get(
 | 
					 | 
				
			||||||
        account,
 | 
					 | 
				
			||||||
        skip_account_ids: suggestions.map(&:account_id),
 | 
					 | 
				
			||||||
        limit: limit - suggestions.size
 | 
					 | 
				
			||||||
      )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      suggestions.concat(source_suggestions)
 | 
					  def initialize(account)
 | 
				
			||||||
 | 
					    @account = account
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def get(limit, offset = 0)
 | 
				
			||||||
 | 
					    with_read_replica do
 | 
				
			||||||
 | 
					      account_ids_with_sources = Rails.cache.fetch("follow_recommendations/#{@account.id}", expires_in: 15.minutes) do
 | 
				
			||||||
 | 
					        SOURCES.flat_map { |klass| klass.new.get(@account, limit: BATCH_SIZE) }.each_with_object({}) do |(account_id, source), h|
 | 
				
			||||||
 | 
					          (h[account_id] ||= []).concat(Array(source).map(&:to_sym))
 | 
				
			||||||
 | 
					        end.to_a.shuffle
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      # The sources deliver accounts that haven't yet been followed, are not blocked,
 | 
				
			||||||
 | 
					      # and so on. Since we reset the cache on follows, blocks, and so on, we don't need
 | 
				
			||||||
 | 
					      # a complicated query on this end.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      account_ids  = account_ids_with_sources[offset, limit]
 | 
				
			||||||
 | 
					      accounts_map = Account.where(id: account_ids.map(&:first)).includes(:account_stat).index_by(&:id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      account_ids.filter_map do |(account_id, source)|
 | 
				
			||||||
 | 
					        next unless accounts_map.key?(account_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        AccountSuggestions::Suggestion.new(
 | 
				
			||||||
 | 
					          account: accounts_map[account_id],
 | 
				
			||||||
 | 
					          source: source
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def self.remove(account, target_account_id)
 | 
					  def remove(target_account_id)
 | 
				
			||||||
    SOURCES.each do |source_class|
 | 
					    FollowRecommendationMute.create(account_id: @account.id, target_account_id: target_account_id)
 | 
				
			||||||
      source = source_class.new
 | 
					 | 
				
			||||||
      source.remove(account, target_account_id)
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										37
									
								
								app/models/account_suggestions/friends_of_friends_source.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								app/models/account_suggestions/friends_of_friends_source.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountSuggestions::FriendsOfFriendsSource < AccountSuggestions::Source
 | 
				
			||||||
 | 
					  def get(account, limit: 10)
 | 
				
			||||||
 | 
					    Account.find_by_sql([<<~SQL.squish, { id: account.id, limit: limit }]).map { |row| [row.id, key] }
 | 
				
			||||||
 | 
					      WITH first_degree AS (
 | 
				
			||||||
 | 
					          SELECT target_account_id
 | 
				
			||||||
 | 
					          FROM follows
 | 
				
			||||||
 | 
					          JOIN accounts AS target_accounts ON follows.target_account_id = target_accounts.id
 | 
				
			||||||
 | 
					          WHERE account_id = :id
 | 
				
			||||||
 | 
					            AND NOT target_accounts.hide_collections
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					      SELECT accounts.id, COUNT(*) AS frequency
 | 
				
			||||||
 | 
					      FROM accounts
 | 
				
			||||||
 | 
					      JOIN follows ON follows.target_account_id = accounts.id
 | 
				
			||||||
 | 
					      JOIN account_stats ON account_stats.account_id = accounts.id
 | 
				
			||||||
 | 
					      LEFT OUTER JOIN follow_recommendation_mutes ON follow_recommendation_mutes.target_account_id = accounts.id AND follow_recommendation_mutes.account_id = :id
 | 
				
			||||||
 | 
					      WHERE follows.account_id IN (SELECT * FROM first_degree)
 | 
				
			||||||
 | 
					        AND follows.target_account_id NOT IN (SELECT * FROM first_degree)
 | 
				
			||||||
 | 
					        AND follows.target_account_id <> :id
 | 
				
			||||||
 | 
					        AND accounts.discoverable
 | 
				
			||||||
 | 
					        AND accounts.suspended_at IS NULL
 | 
				
			||||||
 | 
					        AND accounts.silenced_at IS NULL
 | 
				
			||||||
 | 
					        AND accounts.moved_to_account_id IS NULL
 | 
				
			||||||
 | 
					        AND follow_recommendation_mutes.target_account_id IS NULL
 | 
				
			||||||
 | 
					      GROUP BY accounts.id, account_stats.id
 | 
				
			||||||
 | 
					      ORDER BY frequency DESC, account_stats.followers_count ASC
 | 
				
			||||||
 | 
					      LIMIT :limit
 | 
				
			||||||
 | 
					    SQL
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def key
 | 
				
			||||||
 | 
					    :friends_of_friends
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -1,39 +1,13 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountSuggestions::GlobalSource < AccountSuggestions::Source
 | 
					class AccountSuggestions::GlobalSource < AccountSuggestions::Source
 | 
				
			||||||
  include Redisable
 | 
					  def get(account, limit: 10)
 | 
				
			||||||
 | 
					    FollowRecommendation.localized(content_locale).joins(:account).merge(base_account_scope(account)).order(rank: :desc).limit(limit).pluck(:account_id, :reason)
 | 
				
			||||||
  def key
 | 
					 | 
				
			||||||
    :global
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def get(account, skip_account_ids: [], limit: 40)
 | 
					 | 
				
			||||||
    account_ids = account_ids_for_locale(I18n.locale.to_s.split(/[_-]/).first) - [account.id] - skip_account_ids
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    as_ordered_suggestions(
 | 
					 | 
				
			||||||
      scope(account).where(id: account_ids),
 | 
					 | 
				
			||||||
      account_ids
 | 
					 | 
				
			||||||
    ).take(limit)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def remove(_account, _target_account_id)
 | 
					 | 
				
			||||||
    nil
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def scope(account)
 | 
					  def content_locale
 | 
				
			||||||
    Account.searchable
 | 
					    I18n.locale.to_s.split(/[_-]/).first
 | 
				
			||||||
           .followable_by(account)
 | 
					 | 
				
			||||||
           .not_excluded_by_account(account)
 | 
					 | 
				
			||||||
           .not_domain_blocked_by_account(account)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def account_ids_for_locale(locale)
 | 
					 | 
				
			||||||
    redis.zrevrange("follow_recommendations:#{locale}", 0, -1).map(&:to_i)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def to_ordered_list_key(account)
 | 
					 | 
				
			||||||
    account.id
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,36 +0,0 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class AccountSuggestions::PastInteractionsSource < AccountSuggestions::Source
 | 
					 | 
				
			||||||
  include Redisable
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def key
 | 
					 | 
				
			||||||
    :past_interactions
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def get(account, skip_account_ids: [], limit: 40)
 | 
					 | 
				
			||||||
    account_ids = account_ids_for_account(account.id, limit + skip_account_ids.size) - skip_account_ids
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    as_ordered_suggestions(
 | 
					 | 
				
			||||||
      scope.where(id: account_ids),
 | 
					 | 
				
			||||||
      account_ids
 | 
					 | 
				
			||||||
    ).take(limit)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def remove(account, target_account_id)
 | 
					 | 
				
			||||||
    redis.zrem("interactions:#{account.id}", target_account_id)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def scope
 | 
					 | 
				
			||||||
    Account.searchable
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def account_ids_for_account(account_id, limit)
 | 
					 | 
				
			||||||
    redis.zrevrange("interactions:#{account_id}", 0, limit).map(&:to_i)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def to_ordered_list_key(account)
 | 
					 | 
				
			||||||
    account.id
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
end
 | 
					 | 
				
			||||||
@@ -1,32 +1,18 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountSuggestions::SettingSource < AccountSuggestions::Source
 | 
					class AccountSuggestions::SettingSource < AccountSuggestions::Source
 | 
				
			||||||
  def key
 | 
					  def get(account, limit: 10)
 | 
				
			||||||
    :staff
 | 
					    if setting_enabled?
 | 
				
			||||||
  end
 | 
					      base_account_scope(account).where(setting_to_where_condition).limit(limit).pluck(:id).zip([key].cycle)
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
  def get(account, skip_account_ids: [], limit: 40)
 | 
					      []
 | 
				
			||||||
    return [] unless setting_enabled?
 | 
					    end
 | 
				
			||||||
 | 
					 | 
				
			||||||
    as_ordered_suggestions(
 | 
					 | 
				
			||||||
      scope(account).where(setting_to_where_condition).where.not(id: skip_account_ids),
 | 
					 | 
				
			||||||
      usernames_and_domains
 | 
					 | 
				
			||||||
    ).take(limit)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def remove(_account, _target_account_id)
 | 
					 | 
				
			||||||
    nil
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def scope(account)
 | 
					  def key
 | 
				
			||||||
    Account.searchable
 | 
					    :featured
 | 
				
			||||||
           .followable_by(account)
 | 
					 | 
				
			||||||
           .not_excluded_by_account(account)
 | 
					 | 
				
			||||||
           .not_domain_blocked_by_account(account)
 | 
					 | 
				
			||||||
           .where(locked: false)
 | 
					 | 
				
			||||||
           .where.not(id: account.id)
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def usernames_and_domains
 | 
					  def usernames_and_domains
 | 
				
			||||||
@@ -61,8 +47,4 @@ class AccountSuggestions::SettingSource < AccountSuggestions::Source
 | 
				
			|||||||
  def setting
 | 
					  def setting
 | 
				
			||||||
    Setting.bootstrap_timeline_accounts
 | 
					    Setting.bootstrap_timeline_accounts
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					 | 
				
			||||||
  def to_ordered_list_key(account)
 | 
					 | 
				
			||||||
    [account.username.downcase, account.domain&.downcase]
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										67
									
								
								app/models/account_suggestions/similar_profiles_source.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								app/models/account_suggestions/similar_profiles_source.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountSuggestions::SimilarProfilesSource < AccountSuggestions::Source
 | 
				
			||||||
 | 
					  class QueryBuilder < AccountSearchService::QueryBuilder
 | 
				
			||||||
 | 
					    def must_clauses
 | 
				
			||||||
 | 
					      [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          more_like_this: {
 | 
				
			||||||
 | 
					            fields: %w(text text.stemmed),
 | 
				
			||||||
 | 
					            like: @query.map { |id| { _index: 'accounts', _id: id } },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          term: {
 | 
				
			||||||
 | 
					            properties: 'discoverable',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def must_not_clauses
 | 
				
			||||||
 | 
					      [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          terms: {
 | 
				
			||||||
 | 
					            id: following_ids,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          term: {
 | 
				
			||||||
 | 
					            properties: 'bot',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def should_clauses
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        term: {
 | 
				
			||||||
 | 
					          properties: {
 | 
				
			||||||
 | 
					            value: 'verified',
 | 
				
			||||||
 | 
					            boost: 2,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def get(account, limit: 10)
 | 
				
			||||||
 | 
					    recently_followed_account_ids = account.active_relationships.recent.limit(5).pluck(:target_account_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if Chewy.enabled? && !recently_followed_account_ids.empty?
 | 
				
			||||||
 | 
					      QueryBuilder.new(recently_followed_account_ids, account).build.limit(limit).hits.pluck('_id').map(&:to_i).zip([key].cycle)
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
 | 
					      []
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  rescue Faraday::ConnectionFailed
 | 
				
			||||||
 | 
					    []
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def key
 | 
				
			||||||
 | 
					    :similar_to_recently_followed
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -1,34 +1,18 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountSuggestions::Source
 | 
					class AccountSuggestions::Source
 | 
				
			||||||
  def key
 | 
					 | 
				
			||||||
    raise NotImplementedError
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def get(_account, **kwargs)
 | 
					  def get(_account, **kwargs)
 | 
				
			||||||
    raise NotImplementedError
 | 
					    raise NotImplementedError
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def remove(_account, target_account_id)
 | 
					 | 
				
			||||||
    raise NotImplementedError
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  protected
 | 
					  protected
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def as_ordered_suggestions(scope, ordered_list)
 | 
					  def base_account_scope(account)
 | 
				
			||||||
    return [] if ordered_list.empty?
 | 
					    Account.searchable
 | 
				
			||||||
 | 
					           .followable_by(account)
 | 
				
			||||||
    map = scope.index_by { |account| to_ordered_list_key(account) }
 | 
					           .not_excluded_by_account(account)
 | 
				
			||||||
 | 
					           .not_domain_blocked_by_account(account)
 | 
				
			||||||
    ordered_list.filter_map { |ordered_list_key| map[ordered_list_key] }.map do |account|
 | 
					           .where.not(id: account.id)
 | 
				
			||||||
      AccountSuggestions::Suggestion.new(
 | 
					           .joins("LEFT OUTER JOIN follow_recommendation_mutes ON follow_recommendation_mutes.target_account_id = accounts.id AND follow_recommendation_mutes.account_id = #{account.id}").where(follow_recommendation_mutes: { target_account_id: nil })
 | 
				
			||||||
        account: account,
 | 
					 | 
				
			||||||
        source: key
 | 
					 | 
				
			||||||
      )
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def to_ordered_list_key(_account)
 | 
					 | 
				
			||||||
    raise NotImplementedError
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,15 +26,20 @@ class Block < ApplicationRecord
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  before_validation :set_uri, only: :create
 | 
					  before_validation :set_uri, only: :create
 | 
				
			||||||
  after_commit :remove_blocking_cache
 | 
					  after_commit :invalidate_blocking_cache
 | 
				
			||||||
 | 
					  after_commit :invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def remove_blocking_cache
 | 
					  def invalidate_blocking_cache
 | 
				
			||||||
    Rails.cache.delete("exclude_account_ids_for:#{account_id}")
 | 
					    Rails.cache.delete("exclude_account_ids_for:#{account_id}")
 | 
				
			||||||
    Rails.cache.delete("exclude_account_ids_for:#{target_account_id}")
 | 
					    Rails.cache.delete("exclude_account_ids_for:#{target_account_id}")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					    Rails.cache.delete("follow_recommendations/#{account_id}")
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def set_uri
 | 
					  def set_uri
 | 
				
			||||||
    self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
 | 
					    self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -64,6 +64,7 @@ module Account::Associations
 | 
				
			|||||||
    has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
 | 
					    has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Follow recommendations
 | 
					    # Follow recommendations
 | 
				
			||||||
 | 
					    has_one :follow_recommendation, inverse_of: :account, dependent: nil
 | 
				
			||||||
    has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy
 | 
					    has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Account statuses cleanup policy
 | 
					    # Account statuses cleanup policy
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -116,8 +116,6 @@ module Account::Interactions
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    rel.save! if rel.changed?
 | 
					    rel.save! if rel.changed?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    remove_potential_friendship(other_account)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    rel
 | 
					    rel
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -131,13 +129,10 @@ module Account::Interactions
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    rel.save! if rel.changed?
 | 
					    rel.save! if rel.changed?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    remove_potential_friendship(other_account)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    rel
 | 
					    rel
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def block!(other_account, uri: nil)
 | 
					  def block!(other_account, uri: nil)
 | 
				
			||||||
    remove_potential_friendship(other_account)
 | 
					 | 
				
			||||||
    block_relationships.create_with(uri: uri)
 | 
					    block_relationships.create_with(uri: uri)
 | 
				
			||||||
                       .find_or_create_by!(target_account: other_account)
 | 
					                       .find_or_create_by!(target_account: other_account)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -148,8 +143,6 @@ module Account::Interactions
 | 
				
			|||||||
    mute.expires_in = duration.zero? ? nil : duration
 | 
					    mute.expires_in = duration.zero? ? nil : duration
 | 
				
			||||||
    mute.save!
 | 
					    mute.save!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    remove_potential_friendship(other_account)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
 | 
					    # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
 | 
				
			||||||
    mute.update!(hide_notifications: notifications) if mute.hide_notifications? != notifications
 | 
					    mute.update!(hide_notifications: notifications) if mute.hide_notifications? != notifications
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -307,10 +300,4 @@ module Account::Interactions
 | 
				
			|||||||
      domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, id),
 | 
					      domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, id),
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					 | 
				
			||||||
  private
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def remove_potential_friendship(other_account)
 | 
					 | 
				
			||||||
    PotentialFriendshipTracker.remove(id, other_account.id)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -116,6 +116,7 @@ module Account::Search
 | 
				
			|||||||
    [].tap do |properties|
 | 
					    [].tap do |properties|
 | 
				
			||||||
      properties << 'bot' if bot?
 | 
					      properties << 'bot' if bot?
 | 
				
			||||||
      properties << 'verified' if fields.any?(&:verified?)
 | 
					      properties << 'verified' if fields.any?(&:verified?)
 | 
				
			||||||
 | 
					      properties << 'discoverable' if discoverable?
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -44,10 +44,10 @@ class Follow < ApplicationRecord
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  before_validation :set_uri, only: :create
 | 
					  before_validation :set_uri, only: :create
 | 
				
			||||||
  after_create :increment_cache_counters
 | 
					  after_create :increment_cache_counters
 | 
				
			||||||
  after_create :invalidate_hash_cache
 | 
					 | 
				
			||||||
  after_destroy :remove_endorsements
 | 
					  after_destroy :remove_endorsements
 | 
				
			||||||
  after_destroy :decrement_cache_counters
 | 
					  after_destroy :decrement_cache_counters
 | 
				
			||||||
  after_destroy :invalidate_hash_cache
 | 
					  after_commit :invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					  after_commit :invalidate_hash_cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -74,4 +74,8 @@ class Follow < ApplicationRecord
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    Rails.cache.delete("followers_hash:#{target_account_id}:#{account.synchronization_uri_prefix}")
 | 
					    Rails.cache.delete("followers_hash:#{target_account_id}:#{account.synchronization_uri_prefix}")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					    Rails.cache.delete("follow_recommendations/#{account_id}")
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,12 +17,9 @@ class FollowRecommendationFilter
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  def results
 | 
					  def results
 | 
				
			||||||
    if params['status'] == 'suppressed'
 | 
					    if params['status'] == 'suppressed'
 | 
				
			||||||
      Account.joins(:follow_recommendation_suppression).order(FollowRecommendationSuppression.arel_table[:id].desc).to_a
 | 
					      Account.includes(:account_stat).joins(:follow_recommendation_suppression).order(FollowRecommendationSuppression.arel_table[:id].desc)
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
      account_ids = redis.zrevrange("follow_recommendations:#{@language}", 0, -1).map(&:to_i)
 | 
					      Account.includes(:account_stat).joins(:follow_recommendation).merge(FollowRecommendation.localized(@language).order(rank: :desc))
 | 
				
			||||||
      accounts    = Account.where(id: account_ids).index_by(&:id)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      account_ids.filter_map { |id| accounts[id] }
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										26
									
								
								app/models/follow_recommendation_mute.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/models/follow_recommendation_mute.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# == Schema Information
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# Table name: follow_recommendation_mutes
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  id                :bigint(8)        not null, primary key
 | 
				
			||||||
 | 
					#  account_id        :bigint(8)        not null
 | 
				
			||||||
 | 
					#  target_account_id :bigint(8)        not null
 | 
				
			||||||
 | 
					#  created_at        :datetime         not null
 | 
				
			||||||
 | 
					#  updated_at        :datetime         not null
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					class FollowRecommendationMute < ApplicationRecord
 | 
				
			||||||
 | 
					  belongs_to :account
 | 
				
			||||||
 | 
					  belongs_to :target_account, class_name: 'Account'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  validates :target_account, uniqueness: { scope: :account_id }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  after_commit :invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					    Rails.cache.delete("follow_recommendations/#{account_id}")
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -11,19 +11,5 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FollowRecommendationSuppression < ApplicationRecord
 | 
					class FollowRecommendationSuppression < ApplicationRecord
 | 
				
			||||||
  include Redisable
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  belongs_to :account
 | 
					  belongs_to :account
 | 
				
			||||||
 | 
					 | 
				
			||||||
  after_commit :remove_follow_recommendations, on: :create
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def remove_follow_recommendations
 | 
					 | 
				
			||||||
    redis.pipelined do |pipeline|
 | 
					 | 
				
			||||||
      I18n.available_locales.each do |locale|
 | 
					 | 
				
			||||||
        pipeline.zrem("follow_recommendations:#{locale}", account_id)
 | 
					 | 
				
			||||||
      end
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -45,10 +45,15 @@ class FollowRequest < ApplicationRecord
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  before_validation :set_uri, only: :create
 | 
					  before_validation :set_uri, only: :create
 | 
				
			||||||
 | 
					  after_commit :invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def set_uri
 | 
					  def set_uri
 | 
				
			||||||
    self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
 | 
					    self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					    Rails.cache.delete("follow_recommendations/#{account_id}")
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,11 +23,16 @@ class Mute < ApplicationRecord
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  validates :account_id, uniqueness: { scope: :target_account_id }
 | 
					  validates :account_id, uniqueness: { scope: :target_account_id }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  after_commit :remove_blocking_cache
 | 
					  after_commit :invalidate_blocking_cache
 | 
				
			||||||
 | 
					  after_commit :invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def remove_blocking_cache
 | 
					  def invalidate_blocking_cache
 | 
				
			||||||
    Rails.cache.delete("exclude_account_ids_for:#{account_id}")
 | 
					    Rails.cache.delete("exclude_account_ids_for:#{account_id}")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def invalidate_follow_recommendations_cache
 | 
				
			||||||
 | 
					    Rails.cache.delete("follow_recommendations/#{account_id}")
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,8 +4,8 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
# Table name: preview_cards_statuses
 | 
					# Table name: preview_cards_statuses
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
#  preview_card_id :bigint(8)        not null
 | 
					#  preview_card_id :bigint(8)        not null, primary key
 | 
				
			||||||
#  status_id       :bigint(8)        not null
 | 
					#  status_id       :bigint(8)        not null, primary key
 | 
				
			||||||
#  url             :string
 | 
					#  url             :string
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
class PreviewCardsStatus < ApplicationRecord
 | 
					class PreviewCardsStatus < ApplicationRecord
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,7 @@ class AccountSearchService < BaseService
 | 
				
			|||||||
              query: {
 | 
					              query: {
 | 
				
			||||||
                bool: {
 | 
					                bool: {
 | 
				
			||||||
                  must: must_clauses,
 | 
					                  must: must_clauses,
 | 
				
			||||||
 | 
					                  must_not: must_not_clauses,
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
              },
 | 
					              },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -49,6 +50,10 @@ class AccountSearchService < BaseService
 | 
				
			|||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def must_not_clauses
 | 
				
			||||||
 | 
					      []
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def should_clauses
 | 
					    def should_clauses
 | 
				
			||||||
      if @account && !@options[:following]
 | 
					      if @account && !@options[:following]
 | 
				
			||||||
        [boost_following_query]
 | 
					        [boost_following_query]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,7 +20,7 @@ class FavouriteService < BaseService
 | 
				
			|||||||
    Trends.statuses.register(status)
 | 
					    Trends.statuses.register(status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    create_notification(favourite)
 | 
					    create_notification(favourite)
 | 
				
			||||||
    bump_potential_friendship(account, status)
 | 
					    increment_statistics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    favourite
 | 
					    favourite
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -37,11 +37,8 @@ class FavouriteService < BaseService
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def bump_potential_friendship(account, status)
 | 
					  def increment_statistics
 | 
				
			||||||
    ActivityTracker.increment('activity:interactions')
 | 
					    ActivityTracker.increment('activity:interactions')
 | 
				
			||||||
    return if account.following?(status.account_id)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    PotentialFriendshipTracker.record(account.id, status.account_id, :favourite)
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def build_json(favourite)
 | 
					  def build_json(favourite)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -178,9 +178,6 @@ class PostStatusService < BaseService
 | 
				
			|||||||
    return if !@status.reply? || @account.id == @status.in_reply_to_account_id
 | 
					    return if !@status.reply? || @account.id == @status.in_reply_to_account_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ActivityTracker.increment('activity:interactions')
 | 
					    ActivityTracker.increment('activity:interactions')
 | 
				
			||||||
    return if @account.following?(@status.in_reply_to_account_id)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    PotentialFriendshipTracker.record(@account.id, @status.in_reply_to_account_id, :reply)
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def status_attributes
 | 
					  def status_attributes
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -33,7 +33,7 @@ class ReblogService < BaseService
 | 
				
			|||||||
    ActivityPub::DistributionWorker.perform_async(reblog.id)
 | 
					    ActivityPub::DistributionWorker.perform_async(reblog.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    create_notification(reblog)
 | 
					    create_notification(reblog)
 | 
				
			||||||
    bump_potential_friendship(account, reblog)
 | 
					    increment_statistics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    reblog
 | 
					    reblog
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -50,12 +50,8 @@ class ReblogService < BaseService
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def bump_potential_friendship(account, reblog)
 | 
					  def increment_statistics
 | 
				
			||||||
    ActivityTracker.increment('activity:interactions')
 | 
					    ActivityTracker.increment('activity:interactions')
 | 
				
			||||||
 | 
					 | 
				
			||||||
    return if account.following?(reblog.reblog.account_id)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog)
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def build_json(reblog)
 | 
					  def build_json(reblog)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -38,3 +38,5 @@
 | 
				
			|||||||
        = nothing_here 'nothing-here--under-tabs'
 | 
					        = nothing_here 'nothing-here--under-tabs'
 | 
				
			||||||
      - else
 | 
					      - else
 | 
				
			||||||
        = render partial: 'account', collection: @accounts, locals: { f: f }
 | 
					        = render partial: 'account', collection: @accounts, locals: { f: f }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					= paginate @accounts
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,61 +2,11 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class Scheduler::FollowRecommendationsScheduler
 | 
					class Scheduler::FollowRecommendationsScheduler
 | 
				
			||||||
  include Sidekiq::Worker
 | 
					  include Sidekiq::Worker
 | 
				
			||||||
  include Redisable
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.day.to_i
 | 
					  sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.day.to_i
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # The maximum number of accounts that can be requested in one page from the
 | 
					 | 
				
			||||||
  # API is 80, and the suggestions API does not allow pagination. This number
 | 
					 | 
				
			||||||
  # leaves some room for accounts being filtered during live access
 | 
					 | 
				
			||||||
  SET_SIZE = 100
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def perform
 | 
					  def perform
 | 
				
			||||||
    # Maintaining a materialized view speeds-up subsequent queries significantly
 | 
					 | 
				
			||||||
    AccountSummary.refresh
 | 
					    AccountSummary.refresh
 | 
				
			||||||
    FollowRecommendation.refresh
 | 
					    FollowRecommendation.refresh
 | 
				
			||||||
 | 
					 | 
				
			||||||
    fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Trends.available_locales.each do |locale|
 | 
					 | 
				
			||||||
      recommendations = if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist
 | 
					 | 
				
			||||||
                          FollowRecommendation.localized(locale).order(rank: :desc).limit(SET_SIZE).map { |recommendation| [recommendation.rank, recommendation.account_id] }
 | 
					 | 
				
			||||||
                        else
 | 
					 | 
				
			||||||
                          []
 | 
					 | 
				
			||||||
                        end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      # Use language-agnostic results if there are not enough language-specific ones
 | 
					 | 
				
			||||||
      missing = SET_SIZE - recommendations.size
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if missing.positive? && fallback_recommendations.size.positive?
 | 
					 | 
				
			||||||
        max_fallback_rank = fallback_recommendations.first.rank || 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Language-specific results should be above language-agnostic ones,
 | 
					 | 
				
			||||||
        # otherwise language-agnostic ones will always overshadow them
 | 
					 | 
				
			||||||
        recommendations.map! { |(rank, account_id)| [rank + max_fallback_rank, account_id] }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        added = 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        fallback_recommendations.each do |recommendation|
 | 
					 | 
				
			||||||
          next if recommendations.any? { |(_, account_id)| account_id == recommendation.account_id }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          recommendations << [recommendation.rank, recommendation.account_id]
 | 
					 | 
				
			||||||
          added += 1
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          break if added >= missing
 | 
					 | 
				
			||||||
        end
 | 
					 | 
				
			||||||
      end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      redis.multi do |multi|
 | 
					 | 
				
			||||||
        multi.del(key(locale))
 | 
					 | 
				
			||||||
        multi.zadd(key(locale), recommendations)
 | 
					 | 
				
			||||||
      end
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def key(locale)
 | 
					 | 
				
			||||||
    "follow_recommendations:#{locale}"
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CreateFollowRecommendationMutes < ActiveRecord::Migration[7.1]
 | 
				
			||||||
 | 
					  def change
 | 
				
			||||||
 | 
					    create_table :follow_recommendation_mutes do |t|
 | 
				
			||||||
 | 
					      t.references :account, null: false, foreign_key: { on_delete: :cascade }, index: false
 | 
				
			||||||
 | 
					      t.references :target_account, null: false, foreign_key: { to_table: 'accounts', on_delete: :cascade }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      t.timestamps
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    add_index :follow_recommendation_mutes, [:account_id, :target_account_id], unique: true
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AddLanguagesIndexToAccountSummaries < ActiveRecord::Migration[7.1]
 | 
				
			||||||
 | 
					  disable_ddl_transaction!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def change
 | 
				
			||||||
 | 
					    add_index :account_summaries, [:account_id, :language, :sensitive], algorithm: :concurrently
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										14
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								db/schema.rb
									
									
									
									
									
								
							@@ -10,7 +10,7 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
# It's strongly recommended that you check this file into your version control system.
 | 
					# It's strongly recommended that you check this file into your version control system.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ActiveRecord::Schema[7.0].define(version: 2023_10_06_183200) do
 | 
					ActiveRecord::Schema[7.1].define(version: 2023_12_12_073317) do
 | 
				
			||||||
  # These are extensions that must be enabled in order to support this database
 | 
					  # These are extensions that must be enabled in order to support this database
 | 
				
			||||||
  enable_extension "plpgsql"
 | 
					  enable_extension "plpgsql"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -474,6 +474,15 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_06_183200) do
 | 
				
			|||||||
    t.index ["tag_id"], name: "index_featured_tags_on_tag_id"
 | 
					    t.index ["tag_id"], name: "index_featured_tags_on_tag_id"
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  create_table "follow_recommendation_mutes", force: :cascade do |t|
 | 
				
			||||||
 | 
					    t.bigint "account_id", null: false
 | 
				
			||||||
 | 
					    t.bigint "target_account_id", null: false
 | 
				
			||||||
 | 
					    t.datetime "created_at", null: false
 | 
				
			||||||
 | 
					    t.datetime "updated_at", null: false
 | 
				
			||||||
 | 
					    t.index ["account_id", "target_account_id"], name: "idx_on_account_id_target_account_id_a8c8ddf44e", unique: true
 | 
				
			||||||
 | 
					    t.index ["target_account_id"], name: "index_follow_recommendation_mutes_on_target_account_id"
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  create_table "follow_recommendation_suppressions", force: :cascade do |t|
 | 
					  create_table "follow_recommendation_suppressions", force: :cascade do |t|
 | 
				
			||||||
    t.bigint "account_id", null: false
 | 
					    t.bigint "account_id", null: false
 | 
				
			||||||
    t.datetime "created_at", null: false
 | 
					    t.datetime "created_at", null: false
 | 
				
			||||||
@@ -1209,6 +1218,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_06_183200) do
 | 
				
			|||||||
  add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
 | 
					  add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "featured_tags", "accounts", on_delete: :cascade
 | 
					  add_foreign_key "featured_tags", "accounts", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "featured_tags", "tags", on_delete: :cascade
 | 
					  add_foreign_key "featured_tags", "tags", on_delete: :cascade
 | 
				
			||||||
 | 
					  add_foreign_key "follow_recommendation_mutes", "accounts", column: "target_account_id", on_delete: :cascade
 | 
				
			||||||
 | 
					  add_foreign_key "follow_recommendation_mutes", "accounts", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "follow_recommendation_suppressions", "accounts", on_delete: :cascade
 | 
					  add_foreign_key "follow_recommendation_suppressions", "accounts", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
 | 
					  add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
 | 
					  add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
 | 
				
			||||||
@@ -1341,6 +1352,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_06_183200) do
 | 
				
			|||||||
    WHERE ((accounts.suspended_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false))
 | 
					    WHERE ((accounts.suspended_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false))
 | 
				
			||||||
    GROUP BY accounts.id;
 | 
					    GROUP BY accounts.id;
 | 
				
			||||||
  SQL
 | 
					  SQL
 | 
				
			||||||
 | 
					  add_index "account_summaries", ["account_id", "language", "sensitive"], name: "idx_on_account_id_language_sensitive_250461e1eb"
 | 
				
			||||||
  add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
 | 
					  add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
 | 
					  create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,13 +13,12 @@ RSpec.describe 'Suggestions' do
 | 
				
			|||||||
      get '/api/v1/suggestions', headers: headers, params: params
 | 
					      get '/api/v1/suggestions', headers: headers, params: params
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let(:bob)    { Fabricate(:account) }
 | 
					    let(:bob) { Fabricate(:account) }
 | 
				
			||||||
    let(:jeff)   { Fabricate(:account) }
 | 
					    let(:jeff) { Fabricate(:account) }
 | 
				
			||||||
    let(:params) { {} }
 | 
					    let(:params) { {} }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    before do
 | 
					    before do
 | 
				
			||||||
      PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
 | 
					      Setting.bootstrap_timeline_accounts = [bob, jeff].map(&:acct).join(',')
 | 
				
			||||||
      PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it_behaves_like 'forbidden for wrong scope', 'write'
 | 
					    it_behaves_like 'forbidden for wrong scope', 'write'
 | 
				
			||||||
@@ -65,17 +64,15 @@ RSpec.describe 'Suggestions' do
 | 
				
			|||||||
      delete "/api/v1/suggestions/#{jeff.id}", headers: headers
 | 
					      delete "/api/v1/suggestions/#{jeff.id}", headers: headers
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let(:suggestions_source) { instance_double(AccountSuggestions::PastInteractionsSource, remove: nil) }
 | 
					    let(:bob) { Fabricate(:account) }
 | 
				
			||||||
    let(:bob)                { Fabricate(:account) }
 | 
					    let(:jeff) { Fabricate(:account) }
 | 
				
			||||||
    let(:jeff)               { Fabricate(:account) }
 | 
					    let(:scopes) { 'write' }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    before do
 | 
					    before do
 | 
				
			||||||
      PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
 | 
					      Setting.bootstrap_timeline_accounts = [bob, jeff].map(&:acct).join(',')
 | 
				
			||||||
      PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
 | 
					 | 
				
			||||||
      allow(AccountSuggestions::PastInteractionsSource).to receive(:new).and_return(suggestions_source)
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it_behaves_like 'forbidden for wrong scope', 'write'
 | 
					    it_behaves_like 'forbidden for wrong scope', 'read'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it 'returns http success' do
 | 
					    it 'returns http success' do
 | 
				
			||||||
      subject
 | 
					      subject
 | 
				
			||||||
@@ -86,8 +83,7 @@ RSpec.describe 'Suggestions' do
 | 
				
			|||||||
    it 'removes the specified suggestion' do
 | 
					    it 'removes the specified suggestion' do
 | 
				
			||||||
      subject
 | 
					      subject
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(suggestions_source).to have_received(:remove).with(user.account, jeff.id.to_s).once
 | 
					      expect(FollowRecommendationMute.exists?(account: user.account, target_account: jeff)).to be true
 | 
				
			||||||
      expect(suggestions_source).to_not have_received(:remove).with(user.account, bob.id.to_s)
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    context 'without an authorization header' do
 | 
					    context 'without an authorization header' do
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -29,14 +29,12 @@ describe Scheduler::FollowRecommendationsScheduler do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      it 'creates recommendations' do
 | 
					      it 'creates recommendations' do
 | 
				
			||||||
        expect { scheduled_run }.to change(FollowRecommendation, :count).from(0).to(target_accounts.size)
 | 
					        expect { scheduled_run }.to change(FollowRecommendation, :count).from(0).to(target_accounts.size)
 | 
				
			||||||
        expect(redis.zrange('follow_recommendations:en', 0, -1)).to match_array(target_accounts.pluck(:id).map(&:to_s))
 | 
					 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    context 'when there are no accounts to recommend' do
 | 
					    context 'when there are no accounts to recommend' do
 | 
				
			||||||
      it 'does not create follow recommendations' do
 | 
					      it 'does not create follow recommendations' do
 | 
				
			||||||
        expect { scheduled_run }.to_not change(FollowRecommendation, :count)
 | 
					        expect { scheduled_run }.to_not change(FollowRecommendation, :count)
 | 
				
			||||||
        expect(redis.zrange('follow_recommendations:en', 0, -1)).to be_empty
 | 
					 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user