mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-22 23:20:44 +01:00
Change algorithm of follow recommendations (#28314)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
b7bdcd4f39
commit
b5ac61b2c5
35 changed files with 297 additions and 292 deletions
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
|
||||||
|
|
Loading…
Reference in a new issue