* Update styling and refine features for analytics page * Remove follower reference from js * Update html * Adjust test
243 lines
9.7 KiB
Ruby
243 lines
9.7 KiB
Ruby
class Reaction < ApplicationRecord
|
|
REACTABLE_TYPES = %w[Comment Article User].freeze
|
|
STATUSES = %w[valid invalid confirmed archived].freeze
|
|
|
|
belongs_to :reactable, polymorphic: true
|
|
belongs_to :user
|
|
|
|
counter_culture :reactable,
|
|
column_name: proc { |model|
|
|
ReactionCategory[model.category].visible_to_public? ? "public_reactions_count" : "reactions_count"
|
|
}
|
|
counter_culture :user
|
|
|
|
scope :public_category, lambda {
|
|
categories = public_reaction_types
|
|
where(category: categories)
|
|
}
|
|
|
|
# Be wary, this is all things on the reading list, but for an end
|
|
# user they might only see readinglist items that are published.
|
|
# See https://github.com/forem/forem/issues/14796
|
|
scope :readinglist, -> { where(category: "readinglist") }
|
|
scope :for_articles, ->(ids) { only_articles.where(reactable_id: ids) }
|
|
scope :only_articles, -> { where(reactable_type: "Article") }
|
|
scope :eager_load_serialized_data, -> { includes(:reactable, :user) }
|
|
scope :article_vomits, -> { where(category: "vomit", reactable_type: "Article") }
|
|
scope :comment_vomits, -> { where(category: "vomit", reactable_type: "Comment") }
|
|
scope :user_vomits, -> { where(category: "vomit", reactable_type: "User") }
|
|
scope :valid_or_confirmed, -> { where(status: %w[valid confirmed]) }
|
|
scope :related_negative_reactions_for_user, lambda { |user|
|
|
article_vomits.where(reactable_id: user.article_ids)
|
|
.or(comment_vomits.where(reactable_id: user.comment_ids))
|
|
.or(user_vomits.where(user_id: user.id))
|
|
}
|
|
scope :privileged_category, -> { where(category: ReactionCategory.privileged.map(&:to_s)) }
|
|
scope :for_user, ->(user) { where(reactable: user) }
|
|
scope :unarchived, -> { where.not(status: "archived") }
|
|
scope :from_user, ->(user) { where(user: user) }
|
|
scope :readinglist_for_user, ->(user) { readinglist.unarchived.only_articles.from_user(user) }
|
|
scope :distinct_categories, -> { select("distinct(reactions.category) as category, reactable_id, reactable_type") }
|
|
scope :live_reactable, lambda {
|
|
joins("LEFT JOIN articles ON reactions.reactable_id = articles.id AND reactions.reactable_type = 'Article'")
|
|
.joins("LEFT JOIN users ON reactions.reactable_id = users.id AND reactions.reactable_type = 'User'")
|
|
.where("
|
|
CASE
|
|
WHEN reactions.reactable_type = 'Article' THEN articles.published = TRUE
|
|
WHEN reactions.reactable_type = 'User' THEN users.username NOT LIKE 'spam_%'
|
|
ELSE TRUE
|
|
END
|
|
")
|
|
}
|
|
|
|
validates :category, inclusion: { in: ReactionCategory.all_slugs.map(&:to_s) }
|
|
validates :reactable_type, inclusion: { in: REACTABLE_TYPES }
|
|
validates :status, inclusion: { in: STATUSES }
|
|
validates :user_id, uniqueness: { scope: %i[reactable_id reactable_type category] }
|
|
validate :permissions
|
|
|
|
before_save :assign_points
|
|
after_create :notify_slack_channel_about_vomit_reaction, if: -> { category == "vomit" }
|
|
before_destroy :bust_reactable_cache_without_delay
|
|
before_destroy :update_reactable, unless: :destroyed_by_association
|
|
after_commit :async_bust
|
|
after_commit :bust_reactable_cache, :update_reactable, on: %i[create update]
|
|
after_commit :record_field_test_event, on: %i[create]
|
|
|
|
class << self
|
|
def count_for_article(id)
|
|
Rails.cache.fetch("count_for_reactable-Article-#{id}", expires_in: 10.hours) do
|
|
reactions = Reaction.where(reactable_id: id, reactable_type: "Article")
|
|
counts = reactions.group(:category).count
|
|
|
|
reaction_types = public_reaction_types
|
|
reaction_types << "readinglist" unless public_reaction_types.include?("readinglist")
|
|
|
|
reaction_types.map do |type|
|
|
{ category: type, count: counts.fetch(type, 0) }
|
|
end
|
|
end
|
|
end
|
|
|
|
def cached_any_reactions_for?(reactable, user, category)
|
|
class_name = reactable.instance_of?(ArticleDecorator) ? "Article" : reactable.class.name
|
|
cache_name = "any_reactions_for-#{class_name}-#{reactable.id}-" \
|
|
"#{user.reactions_count}-#{user.public_reactions_count}-#{category}"
|
|
Rails.cache.fetch(cache_name, expires_in: 24.hours) do
|
|
Reaction.where(reactable_id: reactable.id, reactable_type: class_name, user: user, category: category).any?
|
|
end
|
|
end
|
|
|
|
def public_reaction_types
|
|
@public_reaction_types ||= ReactionCategory.public.map(&:to_s) - ["readinglist"]
|
|
end
|
|
|
|
def for_analytics
|
|
reaction_types = public_reaction_types
|
|
reaction_types << "readinglist" unless public_reaction_types.include?("readinglist")
|
|
where(category: reaction_types)
|
|
end
|
|
|
|
# @param user [User] the user who might be spamming the system
|
|
# @param threshold [Integer] the number of strikes before they are spam
|
|
# @param include_user_profile [Boolean] do we include the user's profile as part of the "check
|
|
# for spamminess"
|
|
#
|
|
# @return [TrueClass] yup, they're spamming the system.
|
|
# @return [FalseClass] they're not (yet) spamming the system
|
|
def user_has_been_given_too_many_spammy_article_reactions?(user:, threshold: 2, include_user_profile: false)
|
|
threshold -= 1 if include_user_profile && user_has_spammy_profile_reaction?(user: user)
|
|
article_vomits.where(reactable_id: user.articles.ids).size > threshold
|
|
end
|
|
|
|
# @param user [User] the user who might be spamming the system
|
|
# @param threshold [Integer] the number of strikes before they are spam
|
|
# @param include_user_profile [Boolean] do we include the user's profile as part of the "check
|
|
# for spamminess"
|
|
#
|
|
# @return [TrueClass] yup, they're spamming the system.
|
|
# @return [FalseClass] they're not (yet) spamming the system
|
|
def user_has_been_given_too_many_spammy_comment_reactions?(user:, threshold: 2, include_user_profile: false)
|
|
threshold -= 1 if include_user_profile && user_has_spammy_profile_reaction?(user: user)
|
|
comment_vomits.where(reactable_id: user.comments.ids).size > threshold
|
|
end
|
|
|
|
# @param user [User] the user who might be spamming the system
|
|
def user_has_spammy_profile_reaction?(user:)
|
|
user_vomits.exists?(reactable_id: user.id)
|
|
end
|
|
|
|
# @param category [String] the reaction category type, see the CATEGORIES var
|
|
# @param reactable_id [Boolean] the ID of the item that was reacted on
|
|
# @param reactable_type [String] the type of the item, see the REACTABLE_TYPES var
|
|
# @param user [User] a moderator user
|
|
|
|
# @return [Array] Reactions that contain a contradictory category to the category that was passed in,
|
|
# example, if we pass in a "thumbsup", then we return reactions that have have a thumbsdown or vomit
|
|
def contradictory_mod_reactions(category:, reactable_id:, reactable_type:, user:)
|
|
negatives = ReactionCategory.negative_privileged.map(&:to_s)
|
|
contradictory_category = negatives if category == "thumbsup"
|
|
contradictory_category = "thumbsup" if category.in?(negatives)
|
|
|
|
Reaction.where(reactable_id: reactable_id,
|
|
reactable_type: reactable_type,
|
|
user: user,
|
|
category: contradictory_category)
|
|
end
|
|
end
|
|
|
|
# no need to send notification if:
|
|
# - reaction is negative
|
|
# - receiver is the same user as the one who reacted
|
|
# - reaction status is marked invalid
|
|
# - reaction is not in a category that should be notified
|
|
def skip_notification_for?(_receiver)
|
|
reactor_id = case reactable
|
|
when User
|
|
reactable.id
|
|
else
|
|
reactable.user_id
|
|
end
|
|
|
|
!should_notify? || (status == "invalid") || points.negative? || (user_id == reactor_id)
|
|
end
|
|
|
|
def reaction_on_organization_article?
|
|
reactable_type == "Article" && reactable.organization.present?
|
|
end
|
|
|
|
def target_user
|
|
reactable_type == "User" ? reactable : reactable.user
|
|
end
|
|
|
|
delegate :negative?, :positive?, :visible_to_public?, to: :reaction_category, allow_nil: true
|
|
|
|
def reaction_category
|
|
ReactionCategory[category.to_sym]
|
|
end
|
|
|
|
def readable_date
|
|
if created_at.year == Time.current.year
|
|
I18n.l(created_at, format: :short)
|
|
else
|
|
I18n.l(created_at, format: :short_with_yy)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def update_reactable
|
|
Reactions::UpdateRelevantScoresWorker.perform_async(id)
|
|
end
|
|
|
|
def bust_reactable_cache
|
|
Reactions::BustReactableCacheWorker.perform_async(id)
|
|
end
|
|
|
|
def async_bust
|
|
Reactions::BustHomepageCacheWorker.perform_async(id)
|
|
end
|
|
|
|
def bust_reactable_cache_without_delay
|
|
Reactions::BustReactableCacheWorker.new.perform(id)
|
|
end
|
|
|
|
def assign_points
|
|
self.points = CalculateReactionPoints.call(self)
|
|
end
|
|
|
|
def permissions
|
|
errors.add(:category, I18n.t("models.reaction.is_not_valid")) if negative_reaction_from_untrusted_user?
|
|
return unless reactable_type == "Article" && !reactable&.published
|
|
|
|
errors.add(:reactable_id, I18n.t("models.reaction.is_not_valid"))
|
|
end
|
|
|
|
def negative_reaction_from_untrusted_user?
|
|
return false if user&.any_admin? || user&.id == Settings::General.mascot_user_id
|
|
|
|
negative? && !user.trusted?
|
|
end
|
|
|
|
def notify_slack_channel_about_vomit_reaction
|
|
Slack::Messengers::ReactionVomit.call(reaction: self)
|
|
end
|
|
|
|
# @see AbExperiment::GoalConversionHandler
|
|
def record_field_test_event
|
|
# TODO: Remove once we know that this test is not over-heating the application. That would be a
|
|
# few days after the deploy to DEV of this change.
|
|
return unless FeatureFlag.accessible?(:field_test_event_for_reactions)
|
|
return if FieldTest.config["experiments"].nil?
|
|
return unless visible_to_public?
|
|
return unless reactable.is_a?(Article)
|
|
return unless user_id
|
|
|
|
Users::RecordFieldTestEventWorker
|
|
.perform_async(user_id, AbExperiment::GoalConversionHandler::USER_CREATES_ARTICLE_REACTION_GOAL)
|
|
end
|
|
|
|
def should_notify?
|
|
ReactionCategory.notifiable.include?(category.to_sym)
|
|
end
|
|
end
|