class Comment < ApplicationRecord has_ancestry resourcify include PgSearch::Model include Reactable BODY_MARKDOWN_SIZE_RANGE = (1..25_000) COMMENTABLE_TYPES = %w[Article PodcastEpisode].freeze LOW_QUALITY_THRESHOLD = -75 VALID_SORT_OPTIONS = %w[top latest oldest].freeze URI_REGEXP = %r{ \A (?:https?://)? # optional scheme .+? # host (?::\d+)? # optional port \z }x # The date that we began limiting the number of user mentions in a comment. MAX_USER_MENTION_LIVE_AT = Time.utc(2021, 3, 12).freeze belongs_to :commentable, polymorphic: true, optional: true belongs_to :user counter_culture :commentable counter_culture :user has_many :mentions, as: :mentionable, inverse_of: :mentionable, dependent: :destroy has_many :notifications, as: :notifiable, inverse_of: :notifiable, dependent: :delete_all has_many :notification_subscriptions, as: :notifiable, inverse_of: :notifiable, dependent: :destroy before_validation :evaluate_markdown, if: -> { body_markdown } before_save :set_markdown_character_count, if: :body_markdown before_save :synchronous_spam_score_check before_create :adjust_comment_parent_based_on_depth after_create :after_create_checks after_create :notify_slack_channel_about_warned_users after_update :update_descendant_notifications, if: :deleted after_update :remove_notifications, if: :remove_notifications? before_destroy :before_destroy_actions after_destroy :after_destroy_actions after_save :create_conditional_autovomits after_save :synchronous_bust after_save :bust_cache validate :discussion_not_locked, if: :commentable, on: :create validate :published_article, if: :commentable validate :user_mentions_in_markdown validates :body_markdown, presence: true, length: { in: BODY_MARKDOWN_SIZE_RANGE } validates :body_markdown, uniqueness: { scope: %i[user_id ancestry commentable_id commentable_type] } validates :commentable_id, presence: true, if: :commentable_type validates :commentable_type, inclusion: { in: COMMENTABLE_TYPES }, if: :commentable_id validates :positive_reactions_count, presence: true validates :public_reactions_count, presence: true validates :reactions_count, presence: true validates :commentable, on: :create, presence: { message: lambda do |object, _data| I18n.t("models.comment.has_been_deleted", type: I18n.t("models.comment.type.#{object.commentable_type.presence || 'item'}")) end } after_create_commit :record_field_test_event after_create_commit :send_email_notification, if: :should_send_email_notification? after_commit :calculate_score, on: %i[create update] after_update_commit :update_notifications, if: proc { |comment| comment.saved_changes.include? "body_markdown" } pg_search_scope :search_comments, against: %i[body_markdown], using: { tsearch: { prefix: true, highlight: { StartSel: "", StopSel: "", MaxFragments: 2 } } } scope :eager_load_serialized_data, -> { includes(:user, :commentable) } scope :good_quality, -> { where("score > ?", LOW_QUALITY_THRESHOLD) } alias touch_by_reaction save def self.tree_for(commentable, limit = 0, order = nil) commentable.comments .includes(user: %i[setting profile]) .arrange(order: build_sort_query(order)) .to_a[0..limit - 1] .to_h end def self.title_deleted I18n.t("models.comment.deleted") end def self.title_hidden I18n.t("models.comment.hidden") end def self.title_image_only I18n.t("models.comment.image_only") end def self.build_comment(params, &blk) includes(user: :profile).new(params, &blk) end def self.build_sort_query(order) case order when "latest" "created_at DESC" when "oldest" "created_at ASC" else "score DESC" end end def search_id "comment_#{id}" end def path "/#{user.username}/comment/#{id_code_generated}" rescue StandardError "/404.html" end def parent_or_root_article parent || commentable end def parent_user parent_or_root_article.user end def parent_type parent_or_root_article.class.name.downcase .gsub("article", "post") .gsub("podcastepisode", "episode") end def id_code_generated # 26 is the conversion base # eg. 1000.to_s(26) would be "1cc" id.to_s(26) end def custom_css MarkdownProcessor::Parser.new(body_markdown).tags_used.map do |tag| Rails.application.assets["ltags/#{tag}.css"].to_s end.join end def title(length = 80) return self.class.title_deleted if deleted return self.class.title_hidden if hidden_by_commentable_user text = ActionController::Base.helpers.strip_tags(processed_html).strip return self.class.title_image_only if only_contains_image?(text) truncated_text = ActionController::Base.helpers.truncate(text, length: length).gsub("'", "'").gsub("&", "&") Nokogiri::HTML.fragment(truncated_text).text # unescapes all HTML entities end def video nil end def readable_publish_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 def remove_notifications Notification.remove_all_without_delay(notifiable_ids: id, notifiable_type: "Comment") end def safe_processed_html processed_html.html_safe # rubocop:disable Rails/OutputSafety end def root_exists? ancestry && Comment.exists?(id: ancestry) end def by_staff_account? user == User.staff_account end def privileged_reaction_counts @privileged_reaction_counts ||= reactions.privileged_category.group(:category).count end def calculate_score Comments::CalculateScoreWorker.perform_async(id) end private_class_method :build_sort_query private def remove_notifications? deleted? || hidden_by_commentable_user? end def update_notifications Notification.update_notifications(self) end def update_descendant_notifications return unless has_children? Comment.where(id: descendant_ids).find_each do |comment| Notification.update_notifications(comment) end end def send_to_moderator return if user && user.comments_count > 2 Notification.send_moderation_notification(self) end def evaluate_markdown return unless user renderer = ContentRenderer.new(body_markdown, source: self, user: user) self.processed_html = renderer.process(link_attributes: { rel: "nofollow" }).processed_html wrap_timestamps_if_video_present! if commentable shorten_urls! rescue ContentRenderer::ContentParsingError => e errors.add(:base, ErrorMessages::Clean.call(e.message)) end def adjust_comment_parent_based_on_depth self.parent_id = parent.descendant_ids.last if parent_exists? && (parent.depth > 1 && parent.has_children?) end def wrap_timestamps_if_video_present! return unless commentable_type != "PodcastEpisode" && commentable.video.present? self.processed_html = processed_html.gsub(/(([0-9]:)?)(([0-5][0-9]|[0-9])?):[0-5][0-9]/) do |string| "#{string}" end end def shorten_urls! doc = Nokogiri::HTML.fragment(processed_html) doc.css("a").each do |anchor| next if anchor.inner_html.include?(" 48.hours.ago && body_markdown.include?("http") self.score = -5 if Settings::RateLimit.trigger_spam_for?(text: [title, body_markdown].join("\n")) end def create_conditional_autovomits Spam::Handler.handle_comment!(comment: self) end def should_send_email_notification? parent_exists? && parent_user.class.name != "Podcast" && parent_user != user && parent_user.notification_setting.email_comment_notifications && parent_user.email && user&.badge_achievements_count&.positive? && parent_or_root_article.receive_notifications end def strip_url(url) url.sub!(%r{https://}, "") if url.include?("https://") url.sub!(%r{http://}, "") if url.include?("http://") url.sub!(/www./, "") if url.include?("www.") url = url.truncate(37) unless url.include?(" ") url end def set_markdown_character_count # body_markdown is actually markdown, but that's a separate issue to be fixed soon self.markdown_character_count = body_markdown.size end def discussion_not_locked return unless commentable_type == "Article" && commentable.discussion_lock errors.add(:commentable_id, I18n.t("models.comment.locked")) end def published_article return unless commentable_type == "Article" && !commentable.published errors.add(:commentable_id, I18n.t("models.comment.published_article")) end def user_mentions_in_markdown return if created_at.present? && created_at.before?(MAX_USER_MENTION_LIVE_AT) # The "mentioned-user" css is added by Html::Parser#user_link_if_exists mentions_count = Nokogiri::HTML(processed_html).css(".mentioned-user").size return if mentions_count <= Settings::RateLimit.mention_creation errors.add(:base, I18n.t("models.comment.mention_too_many", count: Settings::RateLimit.mention_creation)) end def record_field_test_event return if FieldTest.config["experiments"].nil? Users::RecordFieldTestEventWorker .perform_async(user_id, AbExperiment::GoalConversionHandler::USER_CREATES_COMMENT_GOAL) end def notify_slack_channel_about_warned_users Slack::Messengers::CommentUserWarned.call(comment: self) end def parent_exists? parent_id && Comment.exists?(id: parent_id) end def only_contains_image?(stripped_text) # If stripped text is blank and processed html has tags, then it's an image-only comment stripped_text.blank? && processed_html.include?("