summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2021-04-24 17:01:43 +0200
committerGitHub <noreply@github.com>2021-04-24 17:01:43 +0200
commitdaccc07dc170627b17564402296f6c8631d0cd97 (patch)
treebb1fea8fde8f44b622b9b39cff46026689dc30ca
parent863ae47b5145e53c6cc820bd7eff0efd41339e03 (diff)
Change auto-following admin-selected accounts, show in recommendations (#16078)
-rw-r--r--app/controllers/api/v1/suggestions_controller.rb10
-rw-r--r--app/lib/potential_friendship_tracker.rb10
-rw-r--r--app/models/account_suggestions.rb25
-rw-r--r--app/models/account_suggestions/global_source.rb37
-rw-r--r--app/models/account_suggestions/past_interactions_source.rb36
-rw-r--r--app/models/account_suggestions/setting_source.rb68
-rw-r--r--app/models/account_suggestions/source.rb34
-rw-r--r--app/models/account_suggestions/suggestion.rb7
-rw-r--r--app/models/follow_recommendation.rb15
-rw-r--r--app/models/form/admin_settings.rb2
-rw-r--r--app/services/bootstrap_timeline_service.rb37
-rw-r--r--app/validators/existing_username_validator.rb24
-rw-r--r--app/views/admin/settings/edit.html.haml5
-rw-r--r--config/locales/en.yml7
-rw-r--r--config/settings.yml1
-rw-r--r--spec/services/bootstrap_timeline_service_spec.rb38
16 files changed, 228 insertions, 128 deletions
diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb
index b2788cc76ce..9737ae5cb62 100644
--- a/app/controllers/api/v1/suggestions_controller.rb
+++ b/app/controllers/api/v1/suggestions_controller.rb
@@ -5,20 +5,20 @@ class Api::V1::SuggestionsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!
- before_action :set_accounts
def index
- render json: @accounts, each_serializer: REST::AccountSerializer
+ suggestions = suggestions_source.get(current_account, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT))
+ render json: suggestions.map(&:account), each_serializer: REST::AccountSerializer
end
def destroy
- PotentialFriendshipTracker.remove(current_account.id, params[:id])
+ suggestions_source.remove(current_account, params[:id])
render_empty
end
private
- def set_accounts
- @accounts = PotentialFriendshipTracker.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
+ def suggestions_source
+ AccountSuggestions::PastInteractionsSource.new
end
end
diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb
index e72d454b67b..f5bc2034659 100644
--- a/app/lib/potential_friendship_tracker.rb
+++ b/app/lib/potential_friendship_tracker.rb
@@ -27,15 +27,5 @@ class PotentialFriendshipTracker
def remove(account_id, target_account_id)
redis.zrem("interactions:#{account_id}", target_account_id)
end
-
- def get(account, limit)
- account_ids = redis.zrevrange("interactions:#{account.id}", 0, limit)
-
- return [] if account_ids.empty? || limit < 1
-
- accounts = Account.searchable.where(id: account_ids).index_by(&:id)
-
- account_ids.map { |id| accounts[id.to_i] }.compact
- end
end
end
diff --git a/app/models/account_suggestions.rb b/app/models/account_suggestions.rb
index 7fe9d618efb..d1774e62fae 100644
--- a/app/models/account_suggestions.rb
+++ b/app/models/account_suggestions.rb
@@ -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
diff --git a/app/models/account_suggestions/global_source.rb b/app/models/account_suggestions/global_source.rb
new file mode 100644
index 00000000000..ac764de50f5
--- /dev/null
+++ b/app/models/account_suggestions/global_source.rb
@@ -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
diff --git a/app/models/account_suggestions/past_interactions_source.rb b/app/models/account_suggestions/past_interactions_source.rb
new file mode 100644
index 00000000000..d169394f11a
--- /dev/null
+++ b/app/models/account_suggestions/past_interactions_source.rb
@@ -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
diff --git a/app/models/account_suggestions/setting_source.rb b/app/models/account_suggestions/setting_source.rb
new file mode 100644
index 00000000000..be9eff23350
--- /dev/null
+++ b/app/models/account_suggestions/setting_source.rb
@@ -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
diff --git a/app/models/account_suggestions/source.rb b/app/models/account_suggestions/source.rb
new file mode 100644
index 00000000000..bd1068d201e
--- /dev/null
+++ b/app/models/account_suggestions/source.rb
@@ -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
diff --git a/app/models/account_suggestions/suggestion.rb b/app/models/account_suggestions/suggestion.rb
new file mode 100644
index 00000000000..2c6f4d27f5c
--- /dev/null
+++ b/app/models/account_suggestions/suggestion.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AccountSuggestions::Suggestion < ActiveModelSerializers::Model
+ attributes :account, :source
+
+ delegate :id, to: :account, prefix: true
+end
diff --git a/app/models/follow_recommendation.rb b/app/models/follow_recommendation.rb
index c4355224d9e..6670b656012 100644
--- a/app/models/follow_recommendation.rb
+++ b/app/models/follow_recommendation.rb
@@ -21,19 +21,4 @@ class FollowRecommendation < ApplicationRecord
def readonly?
true
end
-
- def self.get(account, limit, exclude_account_ids = [])
- account_ids = Redis.current.zrevrange("follow_recommendations:#{account.user_locale}", 0, -1).map(&:to_i) - exclude_account_ids - [account.id]
-
- return [] if account_ids.empty? || limit < 1
-
- accounts = Account.followable_by(account)
- .not_excluded_by_account(account)
- .not_domain_blocked_by_account(account)
- .where(id: account_ids)
- .limit(limit)
- .index_by(&:id)
-
- account_ids.map { |id| accounts[id] }.compact
- end
end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index b5c3dcdbe89..6fc7c56fddb 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -16,7 +16,6 @@ class Form::AdminSettings
open_deletion
timeline_preview
show_staff_badge
- enable_bootstrap_timeline_accounts
bootstrap_timeline_accounts
theme
min_invite_role
@@ -41,7 +40,6 @@ class Form::AdminSettings
open_deletion
timeline_preview
show_staff_badge
- enable_bootstrap_timeline_accounts
activity_api_enabled
peers_api_enabled
show_known_fediverse_at_about_page
diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb
index 8412aa7e78b..e1a1b98c323 100644
--- a/app/services/bootstrap_timeline_service.rb
+++ b/app/services/bootstrap_timeline_service.rb
@@ -5,48 +5,13 @@ class BootstrapTimelineService < BaseService
@source_account = source_account
autofollow_inviter!
- autofollow_bootstrap_timeline_accounts! if Setting.enable_bootstrap_timeline_accounts
end
private
def autofollow_inviter!
return unless @source_account&.user&.invite&.autofollow?
- FollowService.new.call(@source_account, @source_account.user.invite.user.account)
- end
-
- def autofollow_bootstrap_timeline_accounts!
- bootstrap_timeline_accounts.each do |target_account|
- begin
- FollowService.new.call(@source_account, target_account)
- rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
- nil
- end
- end
- end
-
- def bootstrap_timeline_accounts
- return @bootstrap_timeline_accounts if defined?(@bootstrap_timeline_accounts)
-
- @bootstrap_timeline_accounts = bootstrap_timeline_accounts_usernames.empty? ? admin_accounts : local_unlocked_accounts(bootstrap_timeline_accounts_usernames)
- end
-
- def bootstrap_timeline_accounts_usernames
- @bootstrap_timeline_accounts_usernames ||= (Setting.bootstrap_timeline_accounts || '').split(',').map { |str| str.strip.gsub(/\A@/, '') }.reject(&:blank?)
- end
- def admin_accounts
- User.admins
- .includes(:account)
- .where(accounts: { locked: false })
- .map(&:account)
- end
-
- def local_unlocked_accounts(usernames)
- Account.local
- .without_suspended
- .where(username: usernames)
- .where(locked: false)
- .where(moved_to_account_id: nil)
+ FollowService.new.call(@source_account, @source_account.user.invite.user.account)
end
end
diff --git a/app/validators/existing_username_validator.rb b/app/validators/existing_username_validator.rb
index 723302ec9bd..afbe0c635cf 100644
--- a/app/validators/existing_username_validator.rb
+++ b/app/validators/existing_username_validator.rb
@@ -4,11 +4,25 @@ class ExistingUsernameValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
- if options[:multiple]
- missing_usernames = value.split(',').map { |username| username.strip.gsub(/\A@/, '') }.filter_map { |username| username unless Account.find_local(username) }
- record.errors.add(attribute, I18n.t('existing_username_validator.not_found_multiple', usernames: missing_usernames.join(', '))) if missing_usernames.any?
- else
- record.errors.add(attribute, I18n.t('existing_username_validator.not_found')) unless Account.find_local(value.strip.gsub(/\A@/, ''))
+ usernames_and_domains = begin
+ value.split(',').map do |str|
+ username, domain = str.strip.gsub(/\A@/, '').split('@')
+ domain = nil if TagManager.instance.local_domain?(domain)
+
+ next if username.blank?
+
+ [str, username, domain]
+ end.compact
+ end
+
+ usernames_with_no_accounts = usernames_and_domains.filter_map do |(str, username, domain)|
+ str unless Account.find_remote(username, domain)
+ end
+
+ if usernames_with_no_accounts.any? && options[:multiple]
+ record.errors.add(attribute, I18n.t('existing_username_validator.not_found_multiple', usernames: usernames_with_no_accounts.join(', ')))
+ elsif usernames_with_no_accounts.any? || usernames_and_domains.size > 1
+ record.errors.add(attribute, I18n.t('existing_username_validator.not_found'))
end
end
end
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 7783dbfebb7..33bfc43d371 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -50,10 +50,7 @@
%hr.spacer/
.fields-group
- = f.input :enable_bootstrap_timeline_accounts, as: :boolean, wrapper: :with_label, label: t('admin.settings.enable_bootstrap_timeline_accounts.title'), hint: t('admin.settings.enable_bootstrap_timeline_accounts.desc_html')
-
- .fields-group
- = f.input :bootstrap_timeline_accounts, wrapper: :with_block_label, label: t('admin.settings.bootstrap_timeline_accounts.title'), hint: t('admin.settings.bootstrap_timeline_accounts.desc_html'), disabled: !Setting.enable_bootstrap_timeline_accounts
+ = f.input :bootstrap_timeline_accounts, wrapper: :with_block_label, label: t('admin.settings.bootstrap_timeline_accounts.title'), hint: t('admin.settings.bootstrap_timeline_accounts.desc_html')
%hr.spacer/
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 765f71250e9..1b41ee0632b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -564,8 +564,8 @@ en:
desc_html: Counts of locally published posts, active users, and new registrations in weekly buckets
title: Publish aggregate statistics about user activity in the API
bootstrap_timeline_accounts:
- desc_html: Separate multiple usernames by comma. Only local and unlocked accounts will work. Default when empty is all local admins.
- title: Default follows for new users
+ desc_html: Separate multiple usernames by comma. These accounts will be guaranteed to be shown in follow recommendations
+ title: Recommend these accounts to new users
contact_information:
email: Business e-mail
username: Contact username
@@ -582,9 +582,6 @@ en:
users: To logged-in local users
domain_blocks_rationale:
title: Show rationale
- enable_bootstrap_timeline_accounts:
- desc_html: Make new users automatically follow configured accounts so their home feed doesn't start out empty
- title: Enable default follows for new users
hero:
desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to server thumbnail
title: Hero image
diff --git a/config/settings.yml b/config/settings.yml
index b79ea620c80..06cee253240 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -62,7 +62,6 @@ defaults: &defaults
- mod
- moderator
disallowed_hashtags: # space separated string or list of hashtags without the hash
- enable_bootstrap_timeline_accounts: true
bootstrap_timeline_accounts: ''
activity_api_enabled: true
peers_api_enabled: true
diff --git a/spec/services/bootstrap_timeline_service_spec.rb b/spec/services/bootstrap_timeline_service_spec.rb
index a28d2407c1c..880ca4f0dbd 100644
--- a/spec/services/bootstrap_timeline_service_spec.rb
+++ b/spec/services/bootstrap_timeline_service_spec.rb
@@ -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