diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2022-07-17 13:49:29 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-07-17 13:49:29 +0200 |
commit | c3f0621a59a74d0e20e6db6170894871c48e8f0f (patch) | |
tree | df58568b5fbb86042f9dad4ba772f47d305e6086 /app | |
parent | ecb3bb3256fe1bab0d7a63829cdce914b2b509a9 (diff) |
Add ability to follow hashtags (#18809)
Diffstat (limited to 'app')
-rw-r--r-- | app/controllers/api/v1/featured_tags/suggestions_controller.rb | 2 | ||||
-rw-r--r-- | app/controllers/api/v1/followed_tags_controller.rb | 52 | ||||
-rw-r--r-- | app/controllers/api/v1/tags_controller.rb | 29 | ||||
-rw-r--r-- | app/controllers/api/v1/trends/tags_controller.rb | 2 | ||||
-rw-r--r-- | app/lib/feed_manager.rb | 36 | ||||
-rw-r--r-- | app/models/tag.rb | 5 | ||||
-rw-r--r-- | app/models/tag_follow.rb | 24 | ||||
-rw-r--r-- | app/presenters/tag_relationships_presenter.rb | 15 | ||||
-rw-r--r-- | app/serializers/rest/tag_serializer.rb | 14 | ||||
-rw-r--r-- | app/services/fan_out_on_write_service.rb | 15 | ||||
-rw-r--r-- | app/workers/feed_insert_worker.rb | 8 |
11 files changed, 183 insertions, 19 deletions
diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb index 75545d3c7..76633210a 100644 --- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -6,7 +6,7 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController before_action :set_recently_used_tags, only: :index def index - render json: @recently_used_tags, each_serializer: REST::TagSerializer + render json: @recently_used_tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@recently_used_tags, current_user&.account_id) end private diff --git a/app/controllers/api/v1/followed_tags_controller.rb b/app/controllers/api/v1/followed_tags_controller.rb new file mode 100644 index 000000000..f0dfd044c --- /dev/null +++ b/app/controllers/api/v1/followed_tags_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class Api::V1::FollowedTagsController < Api::BaseController + TAGS_LIMIT = 100 + + before_action -> { doorkeeper_authorize! :follow, :read, :'read:follows' }, except: :show + before_action :require_user! + before_action :set_results + + after_action :insert_pagination_headers, only: :show + + def index + render json: @results.map(&:tag), each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@results.map(&:tag), current_user&.account_id) + end + + private + + def set_results + @results = TagFollow.where(account: current_account).joins(:tag).eager_load(:tag).to_a_paginated_by_id( + limit_param(TAGS_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_followed_tags_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_followed_tags_url pagination_params(since_id: pagination_since_id) unless @results.empty? + end + + def pagination_max_id + @results.last.id + end + + def pagination_since_id + @results.first.id + end + + def records_continue? + @results.size == limit_param(TAG_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb new file mode 100644 index 000000000..d45015ff5 --- /dev/null +++ b/app/controllers/api/v1/tags_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Api::V1::TagsController < Api::BaseController + before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }, except: :show + before_action :require_user!, except: :show + before_action :set_or_create_tag + + override_rate_limit_headers :follow, family: :follows + + def show + render json: @tag, serializer: REST::TagSerializer + end + + def follow + TagFollow.create!(tag: @tag, account: current_account, rate_limit: true) + render json: @tag, serializer: REST::TagSerializer + end + + def unfollow + TagFollow.find_by(account: current_account, tag: @tag)&.destroy! + render json: @tag, serializer: REST::TagSerializer + end + + private + + def set_or_create_tag + @tag = Tag.find_normalized(params[:id]) || Tag.new(name: Tag.normalize(params[:id]), display_name: params[:id]) + end +end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index 41f9ffac1..21adfa2a1 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -8,7 +8,7 @@ class Api::V1::Trends::TagsController < Api::BaseController DEFAULT_TAGS_LIMIT = 10 def index - render json: @tags, each_serializer: REST::TagSerializer + render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id) end private diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 2eb4ba2f4..145352fe8 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -45,6 +45,8 @@ class FeedManager filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status])) when :mentions filter_from_mentions?(status, receiver.id) + when :tags + filter_from_tags?(status, receiver.id, build_crutches(receiver.id, [status])) else false end @@ -56,7 +58,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def push_to_home(account, status, update: false) - return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) + return false unless add_to_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?) trim(:home, account.id) PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}", { 'update' => update }) if push_update_required?("timeline:#{account.id}") @@ -69,7 +71,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def unpush_from_home(account, status, update: false) - return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) + return false unless remove_from_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?) redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update true @@ -81,7 +83,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def push_to_list(list, status, update: false) - return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) trim(:list, list.id) PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}", { 'update' => update }) if push_update_required?("timeline:list:#{list.id}") @@ -94,7 +96,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def unpush_from_list(list, status, update: false) - return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + return false unless remove_from_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update true @@ -120,7 +122,7 @@ class FeedManager statuses.each do |status| next if filter_from_home?(status, into_account.id, crutches) - add_to_feed(:home, into_account.id, status, aggregate) + add_to_feed(:home, into_account.id, status, aggregate_reblogs: aggregate) end trim(:home, into_account.id) @@ -146,7 +148,7 @@ class FeedManager statuses.each do |status| next if filter_from_home?(status, list.account_id, crutches) || filter_from_list?(status, list) - add_to_feed(:list, list.id, status, aggregate) + add_to_feed(:list, list.id, status, aggregate_reblogs: aggregate) end trim(:list, list.id) @@ -161,7 +163,7 @@ class FeedManager timeline_status_ids = redis.zrange(timeline_key, 0, -1) from_account.statuses.select('id, reblog_of_id').where(id: timeline_status_ids).reorder(nil).find_each do |status| - remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?) + remove_from_feed(:home, into_account.id, status, aggregate_reblogs: into_account.user&.aggregates_reblogs?) end end @@ -174,7 +176,7 @@ class FeedManager timeline_status_ids = redis.zrange(timeline_key, 0, -1) from_account.statuses.select('id, reblog_of_id').where(id: timeline_status_ids).reorder(nil).find_each do |status| - remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + remove_from_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) end end @@ -237,7 +239,7 @@ class FeedManager timeline_key = key(:home, account.id) account.statuses.limit(limit).each do |status| - add_to_feed(:home, account.id, status, aggregate) + add_to_feed(:home, account.id, status, aggregate_reblogs: aggregate) end account.following.includes(:account_stat).find_each do |target_account| @@ -257,7 +259,7 @@ class FeedManager statuses.each do |status| next if filter_from_home?(status, account.id, crutches) - add_to_feed(:home, account.id, status, aggregate) + add_to_feed(:home, account.id, status, aggregate_reblogs: aggregate) end trim(:home, account.id) @@ -416,6 +418,16 @@ class FeedManager false end + # Check if a status should not be added to the home feed when it comes + # from a followed hashtag + # @param [Status] status + # @param [Integer] receiver_id + # @param [Hash] crutches + # @return [Boolean] + def filter_from_tags?(status, receiver_id, crutches) + receiver_id != status.account_id && (((crutches[:active_mentions][status.id] || []) + [status.account_id]).any? { |target_account_id| crutches[:blocking][target_account_id] || crutches[:muting][target_account_id] } || crutches[:blocked_by][status.account_id] || crutches[:domain_blocking][status.account.domain]) + end + # Adds a status to an account's feed, returning true if a status was # added, and false if it was not added to the feed. Note that this is # an internal helper: callers must call trim or push updates if @@ -425,7 +437,7 @@ class FeedManager # @param [Status] status # @param [Boolean] aggregate_reblogs # @return [Boolean] - def add_to_feed(timeline_type, account_id, status, aggregate_reblogs = true) + def add_to_feed(timeline_type, account_id, status, aggregate_reblogs: true) timeline_key = key(timeline_type, account_id) reblog_key = key(timeline_type, account_id, 'reblogs') @@ -473,7 +485,7 @@ class FeedManager # @param [Status] status # @param [Boolean] aggregate_reblogs # @return [Boolean] - def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true) + def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs: true) timeline_key = key(timeline_type, account_id) reblog_key = key(timeline_type, account_id, 'reblogs') diff --git a/app/models/tag.rb b/app/models/tag.rb index f078007f2..eebf3b47d 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -22,13 +22,16 @@ class Tag < ApplicationRecord has_and_belongs_to_many :statuses has_and_belongs_to_many :accounts + has_many :passive_relationships, class_name: 'TagFollow', inverse_of: :tag, dependent: :destroy has_many :featured_tags, dependent: :destroy, inverse_of: :tag + has_many :followers, through: :passive_relationships, source: :account HASHTAG_SEPARATORS = "_\u00B7\u200c" HASHTAG_NAME_RE = "([[:alnum:]_][[:alnum:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}][[:alnum:]#{HASHTAG_SEPARATORS}]*[[:alnum:]_])|([[:alnum:]_]*[[:alpha:]][[:alnum:]_]*)" HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i } + validates :display_name, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i } validate :validate_name_change, if: -> { !new_record? && name_changed? } validate :validate_display_name_change, if: -> { !new_record? && display_name_changed? } @@ -99,7 +102,7 @@ class Tag < ApplicationRecord names = Array(name_or_names).map { |str| [normalize(str), str] }.uniq(&:first) names.map do |(normalized_name, display_name)| - tag = matching_name(normalized_name).first || create(name: normalized_name, display_name: display_name) + tag = matching_name(normalized_name).first || create(name: normalized_name, display_name: display_name.gsub(/[^[:alnum:]#{HASHTAG_SEPARATORS}]/, '')) yield tag if block_given? diff --git a/app/models/tag_follow.rb b/app/models/tag_follow.rb new file mode 100644 index 000000000..abe36cd17 --- /dev/null +++ b/app/models/tag_follow.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: tag_follows +# +# id :bigint(8) not null, primary key +# tag_id :bigint(8) not null +# account_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class TagFollow < ApplicationRecord + include RateLimitable + include Paginable + + belongs_to :tag + belongs_to :account + + accepts_nested_attributes_for :tag + + rate_limit by: :account, family: :follows +end diff --git a/app/presenters/tag_relationships_presenter.rb b/app/presenters/tag_relationships_presenter.rb new file mode 100644 index 000000000..c3bdbaf07 --- /dev/null +++ b/app/presenters/tag_relationships_presenter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class TagRelationshipsPresenter + attr_reader :following_map + + def initialize(tags, current_account_id = nil, **options) + @following_map = begin + if current_account_id.nil? + {} + else + TagFollow.select(:tag_id).where(tag_id: tags.map(&:id), account_id: current_account_id).each_with_object({}) { |f, h| h[f.tag_id] = true }.merge(options[:following_map] || {}) + end + end + end +end diff --git a/app/serializers/rest/tag_serializer.rb b/app/serializers/rest/tag_serializer.rb index 52bfaa4ce..7801e77d1 100644 --- a/app/serializers/rest/tag_serializer.rb +++ b/app/serializers/rest/tag_serializer.rb @@ -5,6 +5,8 @@ class REST::TagSerializer < ActiveModel::Serializer attributes :name, :url, :history + attribute :following, if: :current_user? + def url tag_url(object) end @@ -12,4 +14,16 @@ class REST::TagSerializer < ActiveModel::Serializer def name object.display_name end + + def following + if instance_options && instance_options[:relationships] + instance_options[:relationships].following_map[object.id] || false + else + TagFollow.where(tag_id: object.id, account_id: current_user.account_id).exists? + end + end + + def current_user? + !current_user.nil? + end end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index de5c5ebe4..ce20a146e 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -16,6 +16,7 @@ class FanOutOnWriteService < BaseService check_race_condition! fan_out_to_local_recipients! + fan_out_to_public_recipients! if broadcastable? fan_out_to_public_streams! if broadcastable? end @@ -50,6 +51,10 @@ class FanOutOnWriteService < BaseService end end + def fan_out_to_public_recipients! + deliver_to_hashtag_followers! + end + def fan_out_to_public_streams! broadcast_to_hashtag_streams! broadcast_to_public_streams! @@ -83,6 +88,14 @@ class FanOutOnWriteService < BaseService end end + def deliver_to_hashtag_followers! + TagFollow.where(tag_id: @status.tags.map(&:id)).select(:id, :account_id).reorder(nil).find_in_batches do |follows| + FeedInsertWorker.push_bulk(follows) do |follow| + [@status.id, follow.account_id, 'tags', { 'update' => update? }] + end + end + end + def deliver_to_lists! @account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists| FeedInsertWorker.push_bulk(lists) do |list| @@ -100,7 +113,7 @@ class FanOutOnWriteService < BaseService end def broadcast_to_hashtag_streams! - @status.tags.pluck(:name).each do |hashtag| + @status.tags.map(&:name).each do |hashtag| redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", anonymous_payload) redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", anonymous_payload) if @status.local? end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index 40bc9cb6e..758cebd4b 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -9,7 +9,7 @@ class FeedInsertWorker @options = options.symbolize_keys case @type - when :home + when :home, :tags @follower = Account.find(id) when :list @list = List.find(id) @@ -36,6 +36,8 @@ class FeedInsertWorker case @type when :home FeedManager.instance.filter?(:home, @status, @follower) + when :tags + FeedManager.instance.filter?(:tags, @status, @follower) when :list FeedManager.instance.filter?(:list, @status, @list) end @@ -49,7 +51,7 @@ class FeedInsertWorker def perform_push case @type - when :home + when :home, :tags FeedManager.instance.push_to_home(@follower, @status, update: update?) when :list FeedManager.instance.push_to_list(@list, @status, update: update?) @@ -58,7 +60,7 @@ class FeedInsertWorker def perform_unpush case @type - when :home + when :home, :tags FeedManager.instance.unpush_from_home(@follower, @status, update: true) when :list FeedManager.instance.unpush_from_list(@list, @status, update: true) |