@ -1,17 +1,28 @@ | |||
# frozen_string_literal: true | |||
class AccountSuggestions | |||
class Suggestion < ActiveModelSerializers::Model | |||
attributes :account, :source | |||
end | |||
SOURCES = [ | |||
AccountSuggestions::SettingSource, | |||
AccountSuggestions::PastInteractionsSource, | |||
AccountSuggestions::GlobalSource, | |||
].freeze | |||
def self.get(account, limit) | |||
suggestions = PotentialFriendshipTracker.get(account, limit).map { |target_account| Suggestion.new(account: target_account, source: :past_interaction) } | |||
suggestions.concat(FollowRecommendation.get(account, limit - suggestions.size, suggestions.map { |suggestion| suggestion.account.id }).map { |target_account| Suggestion.new(account: target_account, source: :global) }) if suggestions.size < limit | |||
suggestions | |||
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) | |||
end | |||
end | |||
def self.remove(account, target_account_id) | |||
PotentialFriendshipTracker.remove(account.id, target_account_id) | |||
SOURCES.each do |source_class| | |||
source = source_class.new | |||
source.remove(account, target_account_id) | |||
end | |||
end | |||
end |
@ -0,0 +1,37 @@ | |||
# frozen_string_literal: true | |||
class AccountSuggestions::GlobalSource < AccountSuggestions::Source | |||
def key | |||
:global | |||
end | |||
def get(account, skip_account_ids: [], limit: 40) | |||
account_ids = account_ids_for_locale(account.user_locale) - [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 | |||
private | |||
def scope(account) | |||
Account.searchable | |||
.followable_by(account) | |||
.not_excluded_by_account(account) | |||
.not_domain_blocked_by_account(account) | |||
end | |||
def account_ids_for_locale(locale) | |||
Redis.current.zrevrange("follow_recommendations:#{locale}", 0, -1).map(&:to_i) | |||
end | |||
def to_ordered_list_key(account) | |||
account.id | |||
end | |||
end |
@ -0,0 +1,36 @@ | |||
# 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 |
@ -0,0 +1,68 @@ | |||
# frozen_string_literal: true | |||
class AccountSuggestions::SettingSource < AccountSuggestions::Source | |||
def key | |||
:staff | |||
end | |||
def get(account, skip_account_ids: [], limit: 40) | |||
return [] unless setting_enabled? | |||
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 | |||
private | |||
def scope(account) | |||
Account.searchable | |||
.followable_by(account) | |||
.not_excluded_by_account(account) | |||
.not_domain_blocked_by_account(account) | |||
.where(locked: false) | |||
.where.not(id: account.id) | |||
end | |||
def usernames_and_domains | |||
@usernames_and_domains ||= setting_to_usernames_and_domains | |||
end | |||
def setting_enabled? | |||
setting.present? | |||
end | |||
def setting_to_where_condition | |||
usernames_and_domains.map do |(username, domain)| | |||
Arel::Nodes::Grouping.new( | |||
Account.arel_table[:username].lower.eq(username.downcase).and( | |||
Account.arel_table[:domain].lower.eq(domain&.downcase) | |||
) | |||
) | |||
end.reduce(:or) | |||
end | |||
def setting_to_usernames_and_domains | |||
setting.split(',').map do |str| | |||
username, domain = str.strip.gsub(/\A@/, '').split('@', 2) | |||
domain = nil if TagManager.instance.local_domain?(domain) | |||
next if username.blank? | |||
[username, domain] | |||
end.compact | |||
end | |||
def setting | |||
Setting.bootstrap_timeline_accounts | |||
end | |||
def to_ordered_list_key(account) | |||
[account.username, account.domain] | |||
end | |||
end |
@ -0,0 +1,34 @@ | |||
# frozen_string_literal: true | |||
class AccountSuggestions::Source | |||
def key | |||
raise NotImplementedError | |||
end | |||
def get(_account, **kwargs) | |||
raise NotImplementedError | |||
end | |||
def remove(_account, target_account_id) | |||
raise NotImplementedError | |||
end | |||
protected | |||
def as_ordered_suggestions(scope, ordered_list) | |||
return [] if ordered_list.empty? | |||
map = scope.index_by(&method(:to_ordered_list_key)) | |||
ordered_list.map { |ordered_list_key| map[ordered_list_key] }.compact.map do |account| | |||
AccountSuggestions::Suggestion.new( | |||
account: account, | |||
source: key | |||
) | |||
end | |||
end | |||
def to_ordered_list_key(_account) | |||
raise NotImplementedError | |||
end | |||
end |
@ -0,0 +1,7 @@ | |||
# frozen_string_literal: true | |||
class AccountSuggestions::Suggestion < ActiveModelSerializers::Model | |||
attributes :account, :source | |||
delegate :id, to: :account, prefix: true | |||
end |
@ -1,42 +1,4 @@ | |||
require 'rails_helper' | |||
RSpec.describe BootstrapTimelineService, type: :service do | |||
subject { described_class.new } | |||
describe '#call' do | |||
let(:source_account) { Fabricate(:account) } | |||
context 'when setting is empty' do | |||
let!(:admin) { Fabricate(:user, admin: true) } | |||
before do | |||
Setting.bootstrap_timeline_accounts = nil | |||
subject.call(source_account) | |||
end | |||
it 'follows admin accounts from account' do | |||
expect(source_account.following?(admin.account)).to be true | |||
end | |||
end | |||
context 'when setting is set' do | |||
let!(:alice) { Fabricate(:account, username: 'alice') } | |||
let!(:bob) { Fabricate(:account, username: 'bob') } | |||
let!(:eve) { Fabricate(:account, username: 'eve', suspended: true) } | |||
before do | |||
Setting.bootstrap_timeline_accounts = 'alice, @bob, eve, unknown' | |||
subject.call(source_account) | |||
end | |||
it 'follows found accounts from account' do | |||
expect(source_account.following?(alice)).to be true | |||
expect(source_account.following?(bob)).to be true | |||
end | |||
it 'does not follow suspended account' do | |||
expect(source_account.following?(eve)).to be false | |||
end | |||
end | |||
end | |||
end |