From 943f27f4377cd74bc07794f15299ad05ef8a7d4f Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Mon, 17 Jul 2023 13:56:28 +0200
Subject: [PATCH] Remove unfollowed hashtag posts from home feed (#26028)

---
 app/controllers/api/v1/tags_controller.rb |  1 +
 app/lib/feed_manager.rb                   | 20 ++++++++++++
 app/workers/tag_unmerge_worker.rb         | 21 ++++++++++++
 spec/workers/tag_unmerge_worker_spec.rb   | 39 +++++++++++++++++++++++
 4 files changed, 81 insertions(+)
 create mode 100644 app/workers/tag_unmerge_worker.rb
 create mode 100644 spec/workers/tag_unmerge_worker_spec.rb

diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb
index 284ec8593..672535a01 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 7423d2d09..ad686c1f1 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 000000000..1c2a6d1e7
--- /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 000000000..5d3a12c44
--- /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