diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb index 284ec85937d..672535a018a 100644 --- a/app/controllers/api/v1/tags_controller.rb +++ b/app/controllers/api/v1/tags_controller.rb @@ -19,6 +19,7 @@ class Api::V1::TagsController < Api::BaseController def unfollow TagFollow.find_by(account: current_account, tag: @tag)&.destroy! + TagUnmergeWorker.perform_async(@tag.id, current_account.id) render json: @tag, serializer: REST::TagSerializer end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 7423d2d092e..ad686c1f1af 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -180,6 +180,26 @@ class FeedManager end end + # Remove a tag's statuses from a home feed + # @param [Tag] from_tag + # @param [Account] into_account + # @return [void] + def unmerge_tag_from_home(from_tag, into_account) + timeline_key = key(:home, into_account.id) + timeline_status_ids = redis.zrange(timeline_key, 0, -1) + + # This is a bit tricky because we need posts tagged with this hashtag that are not + # also tagged with another followed hashtag or from a followed user + scope = from_tag.statuses + .where(id: timeline_status_ids) + .where.not(account: into_account.following) + .tagged_with_none(TagFollow.where(account: into_account).pluck(:tag_id)) + + scope.select('id, reblog_of_id').reorder(nil).find_each do |status| + remove_from_feed(:home, into_account.id, status, aggregate_reblogs: into_account.user&.aggregates_reblogs?) + end + end + # Clear all statuses from or mentioning target_account from a home feed # @param [Account] account # @param [Account] target_account diff --git a/app/workers/tag_unmerge_worker.rb b/app/workers/tag_unmerge_worker.rb new file mode 100644 index 00000000000..1c2a6d1e76b --- /dev/null +++ b/app/workers/tag_unmerge_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class TagUnmergeWorker + include Sidekiq::Worker + include DatabaseHelper + + sidekiq_options queue: 'pull' + + def perform(from_tag_id, into_account_id) + with_primary do + @from_tag = Tag.find(from_tag_id) + @into_account = Account.find(into_account_id) + end + + with_read_replica do + FeedManager.instance.unmerge_tag_from_home(@from_tag, @into_account) + end + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/spec/workers/tag_unmerge_worker_spec.rb b/spec/workers/tag_unmerge_worker_spec.rb new file mode 100644 index 00000000000..5d3a12c4492 --- /dev/null +++ b/spec/workers/tag_unmerge_worker_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe TagUnmergeWorker do + subject { described_class.new } + + describe 'perform' do + let(:follower) { Fabricate(:account) } + let(:followed) { Fabricate(:account) } + let(:followed_tag) { Fabricate(:tag) } + let(:unchanged_followed_tag) { Fabricate(:tag) } + let(:status_from_followed) { Fabricate(:status, created_at: 2.hours.ago, account: followed) } + let(:tagged_status) { Fabricate(:status, created_at: 1.hour.ago) } + let(:unchanged_tagged_status) { Fabricate(:status) } + + before do + tagged_status.tags << followed_tag + unchanged_tagged_status.tags << followed_tag + unchanged_tagged_status.tags << unchanged_followed_tag + + tag_follow = TagFollow.create_with(rate_limit: false).find_or_create_by!(tag: followed_tag, account: follower) + TagFollow.create_with(rate_limit: false).find_or_create_by!(tag: unchanged_followed_tag, account: follower) + + FeedManager.instance.push_to_home(follower, status_from_followed, update: false) + FeedManager.instance.push_to_home(follower, tagged_status, update: false) + FeedManager.instance.push_to_home(follower, unchanged_tagged_status, update: false) + + tag_follow.destroy! + end + + it 'removes the expected status from the feed' do + expect { subject.perform(followed_tag.id, follower.id) } + .to change { HomeFeed.new(follower).get(10).pluck(:id) } + .from([unchanged_tagged_status.id, tagged_status.id, status_from_followed.id]) + .to([unchanged_tagged_status.id, status_from_followed.id]) + end + end +end