Files

164 lines
4.9 KiB
Ruby

# frozen_string_literal: true
class Post < ApplicationRecord
extend FriendlyId
include Likeable
friendly_id :author_date_subject, use: %i(slugged finders)
include PhotoCapable
#
# Relationships
belongs_to :author, class_name: 'Member', inverse_of: :posts
belongs_to :forum, optional: true
belongs_to :card_photo, class_name: 'Photo', optional: true, inverse_of: false
has_many :comments, as: :commentable, dependent: :destroy
has_many :crop_posts, dependent: :delete_all
has_many :crops, through: :crop_posts
has_many :notifications, as: :notifiable, dependent: :destroy
#
# Triggers
before_save :set_published_at
after_save :update_crop_posts_association
after_save :send_notification, if: -> { saved_change_to_published_at? && published? }
# Drafts have no published_at and are left out of every list and lookup, so
# they cannot leak. The author's own pages ask for them with `with_drafts`.
default_scope { joins(:author).merge(Member.kept).where.not(published_at: nil) } # Kept: ensures the owner still exists
scope :with_drafts, -> { unscope(where: :published_at) }
# Not stored. `true` saves a draft, `false` publishes one, and nil (the
# default) publishes a new post and leaves an existing one as it is.
attribute :draft, :boolean
#
# Validations
# A draft can be saved blank (a photo needs a saved post to go on); a post
# being published needs both.
validates :subject, presence: true, if: :publishing?
validates :subject, length: { maximum: 255 }
validates :body, presence: true, if: :publishing?
validate :card_photo_is_in_post, if: :card_photo_id?
WORDS_PER_MINUTE = 200
# The card photo the author chose, else the most liked photo on the post.
def default_photo
card_photo || most_liked_photo
end
def published?
published_at.present?
end
def draft?
!published?
end
def read_time_minutes
[(body.to_s.split.size.to_f / WORDS_PER_MINUTE).ceil, 1].max
end
def more_from_author(limit = 3)
author.posts.where.not(id:).order(created_at: :desc).limit(limit)
end
# A draft's address follows its title until it is published.
def should_generate_new_friendly_id?
slug.blank? || (draft? && subject_changed?)
end
def author_date_subject
return unless author
# slugs are created before created_at is set
time = created_at || Time.zone.now
"#{author.login_name} #{time.strftime('%Y%m%d')} #{subject}"
end
def comment_count
comments.size
end
# return the timestamp of the most recent activity on this post
# i.e. the time of the most recent comment, or of the post itself if
# there are no comments.
def recent_activity
comments.present? ? comments.order(created_at: :desc).first.created_at : created_at
end
# return posts sorted by recent activity
def self.recently_active
left_joins(:comments)
.select('posts.*, COALESCE(MAX(comments.created_at), posts.created_at) AS last_activity_at')
.group('posts.id')
.order(Arel.sql('last_activity_at DESC'))
end
def owner_id
author_id
end
def to_s
subject
end
private
# Being published by this save: a published post stays so, a new one is
# unless it is a draft, and a draft is when asked with `draft: false`.
def publishing?
return true if published?
return false if draft == true
new_record? || draft == false
end
def set_published_at
self.published_at = Time.current if !published? && publishing?
end
def card_photo_is_in_post
errors.add(:card_photo, :invalid) unless photos.exists?(card_photo_id)
end
def update_crop_posts_association
crops.clear
# look for crops mentioned in the post. eg. [tomato](crop)
body.scan(Haml::Filters::GrowstuffMarkdown::CROP_REGEX) do |_m|
crop_name = Regexp.last_match(1)
crop = Crop.case_insensitive_name(crop_name).first
# create association
crops << crop if crop && crops.exclude?(crop)
end
end
def send_notification
recipients = []
sender = author.id
body.scan(Haml::Filters::GrowstuffMarkdown::MEMBER_REGEX) do |_m|
# find member case-insensitively and add to list of recipients
member = Member.case_insensitive_login_name(Regexp.last_match(1)).first
recipients << member if member && recipients.exclude?(member)
end
body.scan(Haml::Filters::GrowstuffMarkdown::MEMBER_AT_REGEX) do |_m|
# find member case-insensitively and add to list of recipients
member = Member.case_insensitive_login_name(Regexp.last_match(1)[1..]).first
recipients << member if member && recipients.exclude?(member)
end
# don't send notifications to yourself
recipients.map(&:id).each do |recipient_id|
next unless recipient_id != sender
Notification.create(
recipient_id:,
sender_id: sender,
notifiable: self,
subject: "#{author} mentioned you in their post #{subject}",
body:
)
end
end
end