| @@ -28,14 +28,14 @@ class PotentialFriendshipTracker | |||
| redis.zrem("interactions:#{account_id}", target_account_id) | |||
| end | |||
| def get(account, limit) | |||
| account_ids = redis.zrevrange("interactions:#{account.id}", 0, limit) | |||
| def get(account, limit, exclude_account_ids = []) | |||
| account_ids = redis.zrevrange("interactions:#{account.id}", 0, limit).map(&:to_i) - exclude_account_ids | |||
| 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 | |||
| account_ids.map { |id| accounts[id] }.compact | |||
| end | |||
| end | |||
| end | |||
| @@ -5,8 +5,38 @@ class AccountSuggestions | |||
| attributes :account, :source | |||
| end | |||
| class StaffPicks | |||
| def self.get(account, limit) | |||
| return [] if Setting.bootstrap_timeline_accounts.blank? | |||
| usernames_and_domains = begin | |||
| Setting.bootstrap_timeline_accounts.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 | |||
| accounts = Account.searchable | |||
| .followable_by(account) | |||
| .not_excluded_by_account(account) | |||
| .not_domain_blocked_by_account(account) | |||
| .where(locked: false) | |||
| .where.not(id: account.id) | |||
| .where(usernames_and_domains.map { |(username, domain)| Arel::Nodes::Grouping.new(Account.arel_table[:username].lower.eq(username.downcase).and(Account.arel_table[:domain].lower.eq(domain&.downcase))) }.reduce(:or)) | |||
| .limit(limit) | |||
| .index_by { |target_account| [target_account.username, target_account.domain] } | |||
| usernames_and_domains.map { |x| accounts[x] }.compact | |||
| end | |||
| end | |||
| def self.get(account, limit) | |||
| suggestions = PotentialFriendshipTracker.get(account, limit).map { |target_account| Suggestion.new(account: target_account, source: :past_interaction) } | |||
| suggestions = StaffPicks.get(account, limit).map { |target_account| Suggestion.new(account: target_account, source: :staff) } | |||
| suggestions.concat(PotentialFriendshipTracker.get(account, limit - suggestions.size, suggestions.map { |suggestion| suggestion.account.id }).map { |target_account| Suggestion.new(account: target_account, source: :past_interaction) }) if suggestions.size < limit | |||
| suggestions.concat(FollowRecommendation.get(account, limit - suggestions.size, suggestions.map { |suggestion| suggestion.account.id }).map { |target_account| Suggestion.new(account: target_account, source: :global) }) if suggestions.size < limit | |||
| suggestions | |||
| end | |||
| @@ -27,7 +27,8 @@ class FollowRecommendation < ApplicationRecord | |||
| return [] if account_ids.empty? || limit < 1 | |||
| accounts = Account.followable_by(account) | |||
| accounts = Account.searchable | |||
| .followable_by(account) | |||
| .not_excluded_by_account(account) | |||
| .not_domain_blocked_by_account(account) | |||
| .where(id: account_ids) | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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', follow_recommendations_path: admin_follow_recommendations_path), disabled: !Setting.enable_bootstrap_timeline_accounts | |||
| %hr.spacer/ | |||
| @@ -564,8 +564,8 @@ en: | |||
| desc_html: Counts of locally posted statuses, active users, and new registrations in weekly buckets | |||
| title: Publish aggregate statistics about user activity | |||
| 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. See also <a href="%{follow_recommendations_path}">follow recommendations</a> | |||
| 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 | |||
| @@ -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 | |||
| @@ -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 | |||