class Comment < ApplicationRecord include HtmlCleaner include AfterCommitEverywhere belongs_to :pseud belongs_to :commentable, polymorphic: true belongs_to :parent, polymorphic: true has_many :inbox_comments, foreign_key: 'feedback_comment_id', dependent: :destroy has_many :users, through: :inbox_comments has_many :reviewed_replies, -> { reviewed }, class_name: "Comment", as: :commentable, inverse_of: :commentable has_many :thread_comments, class_name: 'Comment', foreign_key: :thread validates :name, presence: { unless: :pseud_id }, not_forbidden_name: { if: :will_save_change_to_name? } validates :email, email_format: { on: :create, unless: :pseud_id }, email_blacklist: { on: :create, unless: :pseud_id } validates_presence_of :comment_content validates_length_of :comment_content, maximum: ArchiveConfig.COMMENT_MAX, too_long: ts("must be less than %{count} characters long.", count: ArchiveConfig.COMMENT_MAX) delegate :user, to: :pseud, allow_nil: true # Whether the writer of the comment this is replying to allows guest replies validate :guest_can_reply, if: :reply_comment?, unless: :pseud_id, on: :create def guest_can_reply errors.add(:commentable, :guest_replies_off) if commentable.guest_replies_disallowed? end # Whether the writer of this comment disallows guest replies def guest_replies_disallowed? return false unless user user.preference.guest_replies_off && !user.is_author_of?(ultimate_parent) end # Check if the writer of this comment is blocked by the writer of the comment # they're replying to: validates :user, not_blocked: { by: :commentable, if: :reply_comment?, unless: :on_tag?, message: :blocked_reply } # Check if the writer of this comment is blocked by one of the creators of # the work they're replying to: validates :user, not_blocked: { by: :ultimate_parent, unless: :on_tag?, message: :blocked_comment } def on_tag? parent_type == "Tag" end def by_anonymous_creator? ultimate_parent.try(:anonymous?) && user&.is_author_of?(ultimate_parent) end validate :check_for_spam, on: :create def check_for_spam self.approved = skip_spamcheck? || !spam? errors.add(:base, :spam) unless approved end validate :edited_spam, on: :update, if: [:will_save_change_to_edited_at?, :will_save_change_to_comment_content?] def edited_spam return if skip_spamcheck? || !content_too_different?(comment_content, comment_content_in_database, ArchiveConfig.EDITED_COMMENT_SPAM_CHECK_THRESHOLD) errors.add(:base, :spam) if spam? end validates :comment_content, uniqueness: { scope: [:commentable_id, :commentable_type, :name, :email, :pseud_id], unless: :is_deleted?, message: :duplicate_comment } scope :ordered_by_date, -> { order('created_at DESC') } scope :top_level, -> { where.not(commentable_type: "Comment") } scope :include_pseud, -> { includes(:pseud) } scope :not_deleted, -> { where(is_deleted: false) } scope :reviewed, -> { where(unreviewed: false) } scope :unreviewed_only, -> { where(unreviewed: true) } scope :for_display, lambda { includes( pseud: { user: [:roles, :block_of_current_user, :block_by_current_user, :preference] }, parent: { work: [:pseuds, :users] } ).merge(Pseud.with_attached_icon) } # Gets methods and associations from acts_as_commentable plugin acts_as_commentable has_comment_methods def akismet_attributes # While we do have tag comments, those are from logged-in users with special # access granted by admins, so we never spam check them, unlike comments on # works or admin posts. comment_type = ultimate_parent.is_a?(Work) ? "fanwork-comment" : "comment" if pseud_id.nil? user_role = "guest" comment_author = name else user_role = "user" comment_author = user.login end attributes = { comment_type: comment_type, key: ArchiveConfig.AKISMET_KEY, blog: ArchiveConfig.AKISMET_NAME, user_ip: ip_address, user_agent: user_agent, user_role: user_role, comment_author: comment_author, comment_author_email: comment_owner_email, comment_content: comment_content } attributes[:recheck_reason] = "edit" if will_save_change_to_edited_at? && will_save_change_to_comment_content? attributes end after_create :expire_parent_comments_count after_update :expire_parent_comments_count, if: :saved_change_to_visibility? after_destroy :expire_parent_comments_count def expire_parent_comments_count after_commit { parent&.expire_comments_count } end def saved_change_to_visibility? pertinent_attributes = %w[is_deleted hidden_by_admin unreviewed approved] (saved_changes.keys & pertinent_attributes).present? end before_validation :set_parent_and_unreviewed, on: :create before_create :set_depth before_create :set_thread_for_replies before_create :set_parent_and_unreviewed after_create :update_thread before_create :adjust_threading, if: :reply_comment? after_create :update_work_stats after_destroy :update_work_stats # If a comment has changed too much, we might need to put it back in moderation: before_update :recheck_unreviewed def recheck_unreviewed return unless edited_at_changed? && comment_content_changed? && moderated_commenting_enabled? && !is_creator_comment? && content_too_different?(comment_content, comment_content_was, ArchiveConfig.COMMENT_MODERATION_THRESHOLD) self.unreviewed = true end after_update :after_update def after_update users = [] if self.saved_change_to_edited_at? || (self.saved_change_to_unreviewed? && !self.unreviewed?) # Reply to owner of parent comment if this is a reply comment # Potentially we are notifying the original commenter of a newly-approved reply to their comment if (parent_comment_owner = notify_parent_comment_owner) users << parent_comment_owner end end if self.saved_change_to_edited_at? # notify the commenter if self.comment_owner && notify_user_of_own_comments?(self.comment_owner) users << self.comment_owner end if notify_user_by_email?(self.comment_owner) && notify_user_of_own_comments?(self.comment_owner) if self.reply_comment? CommentMailer.comment_reply_sent_notification(self).deliver_after_commit else CommentMailer.comment_sent_notification(self).deliver_after_commit end end # send notification to the owner(s) of the ultimate parent, who can be users or admins # at this point, users contains those who've already been notified if users.empty? users = self.ultimate_parent.commentable_owners else # replace with the owners of the commentable who haven't already been notified users = self.ultimate_parent.commentable_owners - users end users.each do |user| unless user == self.comment_owner && !notify_user_of_own_comments?(user) if notify_user_by_email?(user) || self.ultimate_parent.is_a?(Tag) CommentMailer.edited_comment_notification(user, self).deliver_after_commit end if user.is_a?(User) && notify_user_by_inbox?(user) update_feedback_in_inbox(user) end end end end end after_create :after_create def after_create self.reload # eventually we will set the locale to the user's stored language of choice #Locale.set ArchiveConfig.SUPPORTED_LOCALES[ArchiveConfig.DEFAULT_LOCALE] users = [] # notify the commenter if self.comment_owner && notify_user_of_own_comments?(self.comment_owner) users << self.comment_owner end if notify_user_by_email?(self.comment_owner) && notify_user_of_own_comments?(self.comment_owner) if self.reply_comment? CommentMailer.comment_reply_sent_notification(self).deliver_after_commit else CommentMailer.comment_sent_notification(self).deliver_after_commit end end # Reply to owner of parent comment if this is a reply comment if (parent_comment_owner = notify_parent_comment_owner) users << parent_comment_owner end # send notification to the owner(s) of the ultimate parent, who can be users or admins # at this point, users contains those who've already been notified if users.empty? users = self.ultimate_parent.commentable_owners else # replace with the owners of the commentable who haven't already been notified users = self.ultimate_parent.commentable_owners - users end users.each do |user| unless user == self.comment_owner && !notify_user_of_own_comments?(user) if notify_user_by_email?(user) || self.ultimate_parent.is_a?(Tag) CommentMailer.comment_notification(user, self).deliver_after_commit end if user.is_a?(User) && notify_user_by_inbox?(user) add_feedback_to_inbox(user) end end end end after_create :record_wrangling_activity, if: :on_tag? def record_wrangling_activity self.comment_owner&.update_last_wrangling_activity end protected def notify_user_of_own_comments?(user) if user.nil? || user == User.orphan_account false elsif user.is_a?(Admin) true else !user.preference.comment_copy_to_self_off? end end def notify_user_by_inbox?(user) if user.nil? || user == User.orphan_account false elsif user.is_a?(Admin) true else !user.preference.comment_inbox_off? end end def notify_user_by_email?(user) if user.nil? || user == User.orphan_account false elsif user.is_a?(Admin) true else !user.preference.comment_emails_off? end end def update_feedback_in_inbox(user) if (edited_feedback = user.inbox_comments.find_by(feedback_comment_id: self.id)) edited_feedback.update_attribute(:read, false) else # original inbox comment was deleted add_feedback_to_inbox(user) end end def add_feedback_to_inbox(user) new_feedback = user.inbox_comments.build new_feedback.feedback_comment_id = self.id new_feedback.save end def content_too_different?(new_content, old_content, threshold) # we added more than the threshold # of chars, just return return true if new_content.length > (old_content.length + threshold) # quick and dirty iteration to compare the two strings cost = 0 new_i = 0 old_i = 0 while new_i < new_content.length && old_i < old_content.length if new_content[new_i] == old_content[old_i] new_i += 1 old_i += 1 next end cost += 1 # interrupt as soon as we have changed > threshold chars return true if cost > threshold # peek ahead to see if we can catch up on either side eg if a letter has been inserted/deleted if new_content[new_i + 1] == old_content[old_i] new_i += 1 elsif new_content[new_i] == old_content[old_i + 1] old_i += 1 else # just keep going new_i += 1 old_i += 1 end end cost > threshold end def not_user_commenter?(parent_comment) (!parent_comment.comment_owner && parent_comment.comment_owner_email && parent_comment.comment_owner_name) end def different_owner?(parent_comment) not_user_commenter?(parent_comment) || (parent_comment.comment_owner != self.comment_owner) end def notify_parent_comment_owner return unless self.reply_comment? && !self.unreviewed? parent_comment = self.commentable parent_comment_owner = parent_comment.comment_owner # will be nil if not a user, including if an admin # if I'm replying to a comment you left for me, mark your comment as replied to in my inbox if self.comment_owner && (inbox_comment = self.comment_owner.inbox_comments.find_by(feedback_comment_id: parent_comment.id)) inbox_comment.update(replied_to: true, read: true) end return unless different_owner?(parent_comment) # Never notify people who are not tag wranglers (any more) about comments on tags return if self.ultimate_parent.is_a?(Tag) && !parent_comment_owner&.is_tag_wrangler? # send notification to the owner of the original comment if they're not the same as the commenter if !parent_comment_owner || notify_user_by_email?(parent_comment_owner) || self.ultimate_parent.is_a?(Tag) if self.saved_change_to_edited_at? CommentMailer.edited_comment_reply_notification(parent_comment, self).deliver_after_commit else CommentMailer.comment_reply_notification(parent_comment, self).deliver_after_commit end end if parent_comment_owner && notify_user_by_inbox?(parent_comment_owner) if self.saved_change_to_edited_at? update_feedback_in_inbox(parent_comment_owner) else add_feedback_to_inbox(parent_comment_owner) end end parent_comment_owner end public # Set the depth of the comment: 0 for a first-class comment, increasing with each level of nesting def set_depth self.depth = self.reply_comment? ? self.commentable.depth + 1 : 0 end # The thread value for a reply comment should be the same as its parent comment def set_thread_for_replies self.thread = self.commentable.thread if self.reply_comment? end # Save the ultimate parent and reviewed status def set_parent_and_unreviewed self.parent = self.reply_comment? ? self.commentable.parent : self.commentable # we only mark comments as unreviewed if moderated commenting is enabled on their parent self.unreviewed = self.parent.respond_to?(:moderated_commenting_enabled?) && self.parent.moderated_commenting_enabled? && !User.current_user.try(:is_author_of?, self.ultimate_parent) true end # is this a comment by the creator of the ultimate parent def is_creator_comment? pseud && pseud.user && pseud.user.try(:is_author_of?, ultimate_parent) end def moderated_commenting_enabled? parent.respond_to?(:moderated_commenting_enabled?) && parent.moderated_commenting_enabled? end # We need a unique thread id for replies, so we'll make use of the fact # that ids are unique def update_thread self.update_attribute(:thread, self.id) unless self.thread end def adjust_threading self.commentable.add_child(self) end # Is this a first-class comment? def top_level? !self.reply_comment? end def comment_owner self.pseud.try(:user) end def comment_owner_name self.pseud.try(:name) || self.name end def comment_owner_email comment_owner.try(:email) || self.email end # override this method from commentable_entity.rb # to return the name of the ultimate parent this is on # we have to do this somewhat roundabout because until the comment is # set and saved, the ultimate_parent method will not work (the thread is not set) # and this is being called from before then. def commentable_name self.reply_comment? ? self.commentable.ultimate_parent.commentable_name : self.commentable.commentable_name end # override this method from comment_methods.rb to return ultimate alias :original_ultimate_parent :ultimate_parent def ultimate_parent myparent = self.original_ultimate_parent myparent.kind_of?(Chapter) ? myparent.work : myparent end def self.commentable_object(commentable) commentable.kind_of?(Work) ? commentable.last_posted_chapter : commentable end def find_all_comments self.all_children end def count_all_comments self.children_count end def count_visible_comments self.children_count #FIXME end def skip_spamcheck? return false unless pseud_id on_tag? || !user.should_spam_check_comments? || is_creator_comment? end def spam? return false unless %w[staging production].include?(Rails.env) Akismetor.spam?(akismet_attributes) end def submit_spam Rails.env.production? && Akismetor.submit_spam(akismet_attributes) end def submit_ham Rails.env.production? && Akismetor.submit_ham(akismet_attributes) end def mark_as_spam! update_attribute(:approved, false) submit_spam end def mark_as_ham! update_attribute(:approved, true) submit_ham end # Freeze single comment. def mark_frozen! update_attribute(:iced, true) end # Freeze all comments. def self.mark_all_frozen!(comments) transaction do comments.each(&:mark_frozen!) end end # Unfreeze single comment. def mark_unfrozen! update_attribute(:iced, false) end # Unfreeze all comments. def self.mark_all_unfrozen!(comments) transaction do comments.each(&:mark_unfrozen!) end end def mark_hidden! update_attribute(:hidden_by_admin, true) end def mark_unhidden! update_attribute(:hidden_by_admin, false) end def sanitized_content sanitize_field(self, :comment_content, image_safety_mode: use_image_safety_mode?) end def sanitized_mailer_content sanitize_field(self, :comment_content, image_safety_mode: true) end def use_image_safety_mode? pseud_id.nil? || hidden_by_admin || parent_type.in?(ArchiveConfig.PARENTS_WITH_IMAGE_SAFETY_MODE) end include Responder end