* Moving Experiments into AbExperiment namespace This commit entails two major concepts: 1. Extracting logic out of a worker. 2. Cleaning the conditions in which we convert experiments. In addition, I revisited some spec names to tidy them up for clarification and structure. **Extracting Logic...** For workers, my preference is that they be a simple router to another object. A non-worker object, with it's initialize method, can both better manage instance variables. Further, workers are somewhat "flat" in hierarchy, In this case, having the AbExperiment be knowledgeable both in setting the experiment and handling conversions makes (to me) organizational sense. For example, the AbExperiment is constructed as a wrapper to the FieldTest gem. But the RecordFieldTestEventWorker had knowledge of FieldTest. With this refactor, it does not have that knowledge. This also provided an opportunity to replace magic strings with constants. **Cleaning the conditions...** This relates directly to and closes forem/forem#17530. I was looking at the experiments after 2 days, and realized that there shouldn't be results for `user_views_pages_on_at_least_nine_different_days_within_two_weeks`; after all the experiment started 2 days ago, how can we have results that look at 2 weeks. By adding the "max" function calls, we ensure a clear boundary of "before the experiment began" versus "while the experiment is running". Closes forem/forem#17530 * Update spec/models/ab_experiment_spec.rb Co-authored-by: Mac Siri <krairit.siri@gmail.com> * Apply suggestions from code review Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com> Co-authored-by: Mac Siri <krairit.siri@gmail.com> Co-authored-by: Julianna Tetreault <32834804+juliannatetreault@users.noreply.github.com>
359 lines
10 KiB
Ruby
359 lines
10 KiB
Ruby
class Comment < ApplicationRecord
|
|
has_ancestry
|
|
resourcify
|
|
|
|
include PgSearch::Model
|
|
include Reactable
|
|
|
|
BODY_MARKDOWN_SIZE_RANGE = (1..25_000)
|
|
|
|
COMMENTABLE_TYPES = %w[Article PodcastEpisode].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_create_commit :create_first_reaction
|
|
after_create_commit :send_to_moderator
|
|
|
|
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: "<mark>",
|
|
StopSel: "</mark>",
|
|
MaxFragments: 2
|
|
}
|
|
}
|
|
}
|
|
|
|
scope :eager_load_serialized_data, -> { includes(:user, :commentable) }
|
|
|
|
alias touch_by_reaction save
|
|
|
|
def self.tree_for(commentable, limit = 0)
|
|
commentable.comments
|
|
.includes(user: %i[setting profile])
|
|
.arrange(order: "score DESC")
|
|
.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 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
|
|
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
|
|
|
|
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
|
|
fixed_body_markdown = MarkdownProcessor::Fixer::FixForComment.call(body_markdown)
|
|
parsed_markdown = MarkdownProcessor::Parser.new(fixed_body_markdown, source: self, user: user)
|
|
self.processed_html = parsed_markdown.finalize(link_attributes: { rel: "nofollow" })
|
|
wrap_timestamps_if_video_present! if commentable
|
|
shorten_urls!
|
|
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|
|
|
"<a href='#{commentable.path}?t=#{string}'>#{string}</a>"
|
|
end
|
|
end
|
|
|
|
def shorten_urls!
|
|
doc = Nokogiri::HTML.fragment(processed_html)
|
|
doc.css("a").each do |anchor|
|
|
next if anchor.inner_html.include?("<img")
|
|
|
|
urls = anchor.content.scan(URI_REGEXP).flatten
|
|
anchor_content = anchor.content
|
|
urls.each do |url|
|
|
anchor_content.sub!(/#{Regexp.escape(url)}/, strip_url(url))
|
|
end
|
|
anchor.inner_html = anchor.inner_html.sub(/#{Regexp.escape(anchor.content)}/, anchor_content)
|
|
end
|
|
self.processed_html = doc.to_html.html_safe # rubocop:disable Rails/OutputSafety
|
|
end
|
|
|
|
def calculate_score
|
|
Comments::CalculateScoreWorker.perform_async(id)
|
|
end
|
|
|
|
def after_create_checks
|
|
create_id_code
|
|
touch_user
|
|
end
|
|
|
|
def create_id_code
|
|
update_column(:id_code, id.to_s(26))
|
|
end
|
|
|
|
def touch_user
|
|
user&.touch(:updated_at, :last_comment_at)
|
|
end
|
|
|
|
def expire_root_fragment
|
|
if root_exists?
|
|
root.touch
|
|
else
|
|
touch
|
|
end
|
|
end
|
|
|
|
def create_first_reaction
|
|
Comments::CreateFirstReactionWorker.perform_async(id, user_id)
|
|
end
|
|
|
|
def after_destroy_actions
|
|
Users::BustCacheWorker.perform_async(user_id)
|
|
user.touch(:last_comment_at)
|
|
end
|
|
|
|
def before_destroy_actions
|
|
commentable.touch(:last_comment_at) if commentable.respond_to?(:last_comment_at)
|
|
ancestors.update_all(updated_at: Time.current)
|
|
Comments::BustCacheWorker.new.perform(id)
|
|
end
|
|
|
|
def bust_cache
|
|
Comments::BustCacheWorker.perform_async(id)
|
|
end
|
|
|
|
def synchronous_bust
|
|
commentable.touch(:last_comment_at) if commentable.respond_to?(:last_comment_at)
|
|
user.touch(:last_comment_at)
|
|
EdgeCache::Bust.call(commentable.path.to_s) if commentable
|
|
expire_root_fragment
|
|
end
|
|
|
|
def send_email_notification
|
|
Comments::SendEmailNotificationWorker.perform_async(id)
|
|
end
|
|
|
|
def synchronous_spam_score_check
|
|
return unless Settings::RateLimit.trigger_spam_for?(text: [title, body_markdown].join("\n"))
|
|
|
|
self.score = -1 # ensure notification is not sent if possibly spammy
|
|
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 &&
|
|
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.is_not_valid"))
|
|
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
|
|
end
|