discourse/lib/topic_view.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

775 lines
20 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
2013-02-05 14:16:51 -05:00
class TopicView
MEGA_TOPIC_POSTS_COUNT = 10000
MIN_POST_READ_TIME = 4.0
2013-02-05 14:16:51 -05:00
attr_reader(
:topic,
:posts,
:guardian,
:filtered_posts,
:chunk_size,
:print,
:message_bus_last_id,
:queued_posts_enabled,
:personal_message,
:can_review_topic
)
attr_accessor(
:draft,
:draft_key,
:draft_sequence,
:user_custom_fields,
:post_custom_fields,
:post_number
)
2013-02-05 14:16:51 -05:00
def self.print_chunk_size
1000
end
def self.chunk_size
20
end
def self.default_post_custom_fields
@default_post_custom_fields ||= [Post::NOTICE_TYPE, Post::NOTICE_ARGS, "action_code_who", "requested_group_id"]
end
def self.post_custom_fields_whitelisters
@post_custom_fields_whitelisters ||= Set.new
end
def self.add_post_custom_fields_whitelister(&block)
post_custom_fields_whitelisters << block
end
def self.whitelisted_post_custom_fields(user)
wpcf = default_post_custom_fields + post_custom_fields_whitelisters.map { |w| w.call(user) }
wpcf.flatten.uniq
end
def initialize(topic_or_topic_id, user = nil, options = {})
@topic = find_topic(topic_or_topic_id)
@user = user
@guardian = Guardian.new(@user)
check_and_raise_exceptions
@message_bus_last_id = MessageBus.last_id("/topic/#{@topic.id}")
@print = options[:print].present?
options.each do |key, value|
self.instance_variable_set("@#{key}".to_sym, value)
end
@post_number = [@post_number.to_i, 1].max
@page = [@page.to_i, 1].max
@include_suggested = options.fetch(:include_suggested) { true }
@include_related = options.fetch(:include_related) { true }
2017-07-27 21:20:09 -04:00
@chunk_size =
case
when @print then TopicView.print_chunk_size
else TopicView.chunk_size
end
@limit ||= @chunk_size
2013-02-05 14:16:51 -05:00
setup_filtered_posts
2013-02-05 14:16:51 -05:00
@initial_load = true
@index_reverse = false
2013-02-05 14:16:51 -05:00
filter_posts(options)
if @posts && !@skip_custom_fields
if (added_fields = User.whitelisted_user_custom_fields(@guardian)).present?
@user_custom_fields = User.custom_fields_for_ids(@posts.pluck(:user_id), added_fields)
end
if (whitelisted_fields = TopicView.whitelisted_post_custom_fields(@user)).present?
@post_custom_fields = Post.custom_fields_for_ids(@posts.pluck(:id), whitelisted_fields)
end
end
@draft_key = @topic.draft_key
@draft_sequence = DraftSequence.current(@user, @draft_key)
@can_review_topic = @guardian.can_review_topic?(@topic)
@queued_posts_enabled = NewPostManager.queue_enabled?
@personal_message = @topic.private_message?
2013-02-05 14:16:51 -05:00
end
def show_read_indicator?
return false unless @user || topic.private_message?
topic.allowed_groups.any? do |group|
group.publish_read_state? && group.users.include?(@user)
end
end
def canonical_path
if SiteSetting.embed_set_canonical_url
topic_embed = topic.topic_embed
return topic_embed.embed_url if topic_embed
end
path = relative_url.dup
2017-07-27 21:20:09 -04:00
path <<
if @page > 1
"?page=#{@page}"
2017-07-27 21:20:09 -04:00
else
page = ((@post_number - 1) / @limit) + 1
page > 1 ? "?page=#{page}" : ""
2017-07-27 21:20:09 -04:00
end
path
2013-02-05 14:16:51 -05:00
end
def contains_gaps?
@contains_gaps
end
def gaps
return unless @contains_gaps
@gaps ||= begin
if is_mega_topic?
nil
else
Gaps.new(filtered_post_ids, unfiltered_posts.order(:sort_order).pluck(:id))
end
end
end
def last_post
return nil if @posts.blank?
@last_post ||= @posts.last
end
2014-03-03 12:56:37 -05:00
def prev_page
@page > 1 && posts.size > 0 ? @page - 1 : nil
2014-03-03 12:56:37 -05:00
end
def next_page
@next_page ||= begin
if last_post && highest_post_number && (highest_post_number > last_post.post_number)
@page + 1
end
end
end
2014-03-03 12:56:37 -05:00
def prev_page_path
if prev_page > 1
"#{relative_url}?page=#{prev_page}"
2014-03-03 12:56:37 -05:00
else
relative_url
2014-03-03 12:56:37 -05:00
end
end
def next_page_path
"#{relative_url}?page=#{next_page}"
end
def absolute_url
"#{Discourse.base_url_no_prefix}#{relative_url}"
end
def relative_url
2016-08-08 23:53:08 -04:00
"#{@topic.relative_url}#{@print ? '/print' : ''}"
end
def page_title
title = @topic.title
if SiteSetting.topic_page_title_includes_category
if @topic.category_id != SiteSetting.uncategorized_category_id && @topic.category_id && @topic.category
title += " - #{@topic.category.name}"
elsif SiteSetting.tagging_enabled && @topic.tags.exists?
title += " - #{@topic.tags.order('tags.topic_count DESC').first.name}"
end
end
title
end
def title
@topic.title
end
def desired_post
return @desired_post if @desired_post.present?
return nil if posts.blank?
@desired_post = posts.detect { |p| p.post_number == @post_number }
@desired_post ||= posts.first
@desired_post
end
def summary(opts = {})
return nil if desired_post.blank?
2013-09-04 19:33:30 -04:00
# TODO, this is actually quite slow, should be cached in the post table
excerpt = desired_post.excerpt(500, opts.merge(strip_links: true, text_entities: true))
(excerpt || "").gsub(/\n/, ' ').strip
end
def read_time
return nil if @post_number > 1 # only show for topic URLs
if @topic.word_count && SiteSetting.read_time_word_count > 0
[
@topic.word_count / SiteSetting.read_time_word_count,
@topic.posts_count * MIN_POST_READ_TIME / 60
].max.ceil
end
end
def like_count
return nil if @post_number > 1 # only show for topic URLs
@topic.like_count
end
def published_time
return nil if desired_post.blank?
if desired_post.wiki && desired_post.post_number == 1 && desired_post.revisions.size > 0
desired_post.revisions.last.updated_at.strftime('%FT%T%:z')
else
desired_post.created_at.strftime('%FT%T%:z')
end
end
def image_url
url = desired_post&.image_url if @post_number > 1
url || @topic.image_url
end
def filter_posts(opts = {})
return filter_posts_near(opts[:post_number].to_i) if opts[:post_number].present?
return filter_posts_by_ids(opts[:post_ids]) if opts[:post_ids].present?
if opts[:filter_post_number].present?
return filter_posts_by_post_number(opts[:filter_post_number], opts[:asc])
end
return filter_best(opts[:best], opts) if opts[:best].present?
filter_posts_paged(@page)
end
def primary_group_names
return @group_names if @group_names
primary_group_ids = Set.new
@posts.each do |p|
primary_group_ids << p.user.primary_group_id if p.user.try(:primary_group_id)
end
result = {}
unless primary_group_ids.empty?
Group.where(id: primary_group_ids.to_a).pluck(:id, :name).each do |g|
result[g[0]] = g[1]
end
end
2015-09-28 02:38:34 -04:00
@group_names = result
end
# Find the sort order for a post in the topic
def sort_order_for_post_number(post_number)
2015-09-10 16:01:23 -04:00
posts = Post.where(topic_id: @topic.id, post_number: post_number).with_deleted
posts = filter_post_types(posts)
posts.select(:sort_order).first.try(:sort_order)
2013-02-05 14:16:51 -05:00
end
# Filter to all posts near a particular post number
def filter_posts_near(post_number)
posts_before = (@limit.to_f / 4).floor
posts_before = 1 if posts_before.zero?
sort_order = get_sort_order(post_number)
before_post_ids = @filtered_posts.order(sort_order: :desc)
.where("posts.sort_order < ?", sort_order)
.limit(posts_before)
.pluck(:id)
post_ids = before_post_ids + @filtered_posts.order(sort_order: :asc)
.where("posts.sort_order >= ?", sort_order)
.limit(@limit - before_post_ids.length)
.pluck(:id)
if post_ids.length < @limit
post_ids = post_ids + @filtered_posts.order(sort_order: :desc)
.where("posts.sort_order < ?", sort_order)
.offset(before_post_ids.length)
.limit(@limit - post_ids.length)
.pluck(:id)
end
filter_posts_by_ids(post_ids)
end
2013-02-05 14:16:51 -05:00
def filter_posts_paged(page)
page = [page, 1].max
2014-01-03 12:52:24 -05:00
min = @limit * (page - 1)
# Sometimes we don't care about the OP, for example when embedding comments
min = 1 if min == 0 && @exclude_first
@posts = filter_posts_by_ids(
@filtered_posts.order(:sort_order)
.offset(min)
.limit(@limit)
.pluck(:id)
)
2013-02-05 14:16:51 -05:00
end
2017-07-27 21:20:09 -04:00
def filter_best(max, opts = {})
filter = FilterBestPosts.new(@topic, @filtered_posts, max, opts)
@posts = filter.posts
@filtered_posts = filter.filtered_posts
2013-02-05 14:16:51 -05:00
end
def read?(post_number)
return true unless @user
2013-02-05 14:16:51 -05:00
read_posts_set.include?(post_number)
end
def has_deleted?
@predelete_filtered_posts.with_deleted
2017-07-27 21:20:09 -04:00
.where("posts.deleted_at IS NOT NULL")
.where("posts.post_number > 1")
.exists?
end
2013-02-05 14:16:51 -05:00
def topic_user
@topic_user ||= begin
return nil if @user.blank?
@topic.topic_users.find_by(user_id: @user.id)
2013-02-05 14:16:51 -05:00
end
end
MAX_PARTICIPANTS = 24
def post_counts_by_user
@post_counts_by_user ||= begin
if is_mega_topic?
{}
else
post_ids = unfiltered_post_ids
return {} if post_ids.blank?
2017-09-12 02:34:43 -04:00
sql = <<~SQL
2019-02-27 08:49:07 -05:00
SELECT user_id, count(*) AS count_all
FROM posts
WHERE id in (:post_ids)
AND user_id IS NOT NULL
GROUP BY user_id
ORDER BY count_all DESC
LIMIT #{MAX_PARTICIPANTS}
SQL
2017-09-12 02:34:43 -04:00
Hash[*DB.query_single(sql, post_ids: post_ids)]
end
end
2013-02-05 14:16:51 -05:00
end
# if a topic has more that N posts no longer attempt to
# get accurate participant count, instead grab cached count
# from topic
MAX_POSTS_COUNT_PARTICIPANTS = 500
def participant_count
@participant_count ||=
begin
if participants.size == MAX_PARTICIPANTS
if @topic.posts_count > MAX_POSTS_COUNT_PARTICIPANTS
@topic.participant_count
else
sql = <<~SQL
SELECT COUNT(DISTINCT user_id)
FROM posts
WHERE id IN (:post_ids)
AND user_id IS NOT NULL
SQL
DB.query_single(sql, post_ids: unfiltered_post_ids).first.to_i
end
else
participants.size
end
end
end
2013-02-05 14:16:51 -05:00
def participants
@participants ||= begin
participants = {}
User.where(id: post_counts_by_user.keys).includes(:primary_group).each { |u| participants[u.id] = u }
2013-02-05 14:16:51 -05:00
participants
end
end
def group_allowed_user_ids
return @group_allowed_user_ids unless @group_allowed_user_ids.nil?
group_ids = @topic.allowed_groups.map(&:id)
@group_allowed_user_ids = Set.new(GroupUser.where(group_id: group_ids).pluck('distinct user_id'))
end
2013-02-05 14:16:51 -05:00
def all_post_actions
@all_post_actions ||= PostAction.counts_for(@posts, @user)
2013-02-05 14:16:51 -05:00
end
def links
@links ||= TopicLink.topic_map(@guardian, @topic.id)
2013-02-05 14:16:51 -05:00
end
Improving bookmarks part 1 (#8466) Note: All of this functionality is hidden behind a hidden, default false, site setting called `enable_bookmarks_with_reminders`. Also, any feedback on Ember code would be greatly appreciated! This is part 1 of the bookmark improvements. The next PR will address the backend logic to send reminder notifications for bookmarked posts to users. This PR adds the following functionality: * We are adding a new `bookmarks` table and `Bookmark` model to make the bookmarks a first-class citizen and to allow attaching reminders to them. * Posts now have a new button in their actions menu that has the icon of an actual book * Clicking the button opens the new bookmark modal. * Both name and the reminder type are optional. * If you close the modal without doing anything, the bookmark is saved with no reminder. * If you click the Cancel button, no bookmark is saved at all. * All of the reminder type tiles are dynamic and the times they show will be based on your user timezone set in your profile (this should already be set for you). * If for some reason a user does not have their timezone set they will not be able to set a reminder, but they will still be able to create a bookmark. * A bookmark can be deleted by clicking on the book icon again which will be red if the post is bookmarked. This PR does NOT do anything to migrate or change existing bookmarks in the form of `PostActions`, the two features live side-by-side here. Also this does nothing to the topic bookmarking.
2019-12-10 23:04:02 -05:00
def user_post_bookmarks
@user_post_bookmarks ||= Bookmark.where(user: @user, post_id: unfiltered_post_ids)
end
def reviewable_counts
if @reviewable_counts.nil?
post_ids = @posts.map(&:id)
sql = <<~SQL
SELECT target_id,
MAX(r.id) reviewable_id,
COUNT(*) total,
SUM(CASE WHEN s.status = :pending THEN 1 ELSE 0 END) pending
FROM reviewables r
JOIN reviewable_scores s ON reviewable_id = r.id
WHERE r.target_id IN (:post_ids) AND
r.target_type = 'Post'
GROUP BY target_id
SQL
@reviewable_counts = {}
DB.query(
sql,
pending: ReviewableScore.statuses[:pending],
post_ids: post_ids
).each do |row|
@reviewable_counts[row.target_id] = {
total: row.total,
pending: row.pending,
reviewable_id: row.reviewable_id
}
end
end
@reviewable_counts
end
def pending_posts
@pending_posts ||= ReviewableQueuedPost.pending.where(created_by: @user, topic: @topic).order(:created_at)
end
def actions_summary
return @actions_summary unless @actions_summary.nil?
@actions_summary = []
return @actions_summary unless post = posts&.first
PostActionType.topic_flag_types.each do |sym, id|
@actions_summary << {
id: id,
count: 0,
hidden: false,
can_act: @guardian.post_can_act?(post, sym)
}
end
@actions_summary
end
2013-02-05 14:16:51 -05:00
def link_counts
@link_counts ||= TopicLink.counts_for(@guardian, @topic, posts)
2013-02-25 11:42:20 -05:00
end
2013-02-05 14:16:51 -05:00
# Are we the initial page load? If so, we can return extra information like
# user post counts, etc.
def initial_load?
@initial_load
end
def pm_params
@pm_params ||= TopicQuery.new(@user).get_pm_params(topic)
end
2013-02-05 14:16:51 -05:00
def suggested_topics
if @include_suggested
@suggested_topics ||= TopicQuery.new(@user).list_suggested_for(topic, pm_params: pm_params)
else
nil
end
end
def related_messages
if @include_related
@related_messages ||= TopicQuery.new(@user).list_related_for(topic, pm_params: pm_params)
else
nil
end
2013-02-05 14:16:51 -05:00
end
# This is pending a larger refactor, that allows custom orders
# for now we need to look for the highest_post_number in the stream
# the cache on topics is not correct if there are deleted posts at
# the end of the stream (for mods), nor is it correct for filtered
# streams
def highest_post_number
@highest_post_number ||= @filtered_posts.maximum(:post_number)
end
def recent_posts
@filtered_posts.by_newest.with_user.first(25)
end
# Returns an array of [id, days_ago] tuples.
2017-08-04 11:28:25 -04:00
# `days_ago` is there for the timeline calculations.
2016-05-17 13:03:08 -04:00
def filtered_post_stream
@filtered_post_stream ||= begin
posts = @filtered_posts
.order(:sort_order)
columns = [:id]
if !is_mega_topic?
columns << 'EXTRACT(DAYS FROM CURRENT_TIMESTAMP - created_at)::INT AS days_ago'
end
posts.pluck(*columns)
end
2016-05-17 13:03:08 -04:00
end
def filtered_post_ids
@filtered_post_ids ||= filtered_post_stream.map do |tuple|
if is_mega_topic?
tuple
else
tuple[0]
end
end
end
def unfiltered_post_ids
@unfiltered_post_ids ||=
begin
if @contains_gaps
2017-12-13 01:36:36 -05:00
unfiltered_posts.pluck(:id)
else
filtered_post_ids
end
end
end
def filtered_post_id(post_number)
@filtered_posts.where(post_number: post_number).pluck_first(:id)
end
def is_mega_topic?
@is_mega_topic ||= (@topic.posts_count >= MEGA_TOPIC_POSTS_COUNT)
end
def first_post_id
@filtered_posts.order(sort_order: :asc).pluck_first(:id)
end
def last_post_id
@filtered_posts.order(sort_order: :desc).pluck_first(:id)
end
def current_post_number
if highest_post_number.present?
post_number > highest_post_number ? highest_post_number : post_number
end
end
def queued_posts_count
ReviewableQueuedPost.viewable_by(@user).where(topic_id: @topic.id).pending.count
end
2013-02-05 14:16:51 -05:00
protected
def read_posts_set
@read_posts_set ||= begin
result = Set.new
return result unless @user.present?
return result unless topic_user.present?
2013-02-05 14:16:51 -05:00
post_numbers = PostTiming
2017-07-27 21:20:09 -04:00
.where(topic_id: @topic.id, user_id: @user.id)
2017-09-07 06:41:44 -04:00
.where(post_number: @posts.pluck(:post_number))
2017-07-27 21:20:09 -04:00
.pluck(:post_number)
2013-02-05 14:16:51 -05:00
2017-07-27 21:20:09 -04:00
post_numbers.each { |pn| result << pn }
result
2013-02-05 14:16:51 -05:00
end
end
private
def get_sort_order(post_number)
sql = <<~SQL
2019-02-27 08:49:07 -05:00
SELECT posts.sort_order
FROM posts
WHERE posts.post_number = #{post_number.to_i}
AND posts.topic_id = #{@topic.id.to_i}
LIMIT 1
SQL
sort_order = DB.query_single(sql).first
if !sort_order
sql = <<~SQL
2019-02-27 08:49:07 -05:00
SELECT posts.sort_order
FROM posts
WHERE posts.topic_id = #{@topic.id.to_i}
ORDER BY @(post_number - #{post_number.to_i})
LIMIT 1
SQL
sort_order = DB.query_single(sql).first
end
sort_order
end
2015-09-10 16:01:23 -04:00
def filter_post_types(posts)
visible_types = Topic.visible_post_types(@user)
2015-09-10 16:01:23 -04:00
if @user.present?
posts.where("posts.user_id = ? OR post_type IN (?)", @user.id, visible_types)
2015-09-10 16:01:23 -04:00
else
posts.where(post_type: visible_types)
end
end
def filter_posts_by_post_number(post_number, asc)
sort_order = get_sort_order(post_number)
posts =
if asc
@filtered_posts
.where("sort_order > ?", sort_order)
.order(sort_order: :asc)
else
@filtered_posts
.where("sort_order < ?", sort_order)
.order(sort_order: :desc)
end
posts = posts.limit(@limit) if !@skip_limit
filter_posts_by_ids(posts.pluck(:id))
@posts = @posts.unscope(:order).order(sort_order: :desc) if !asc
end
def filter_posts_by_ids(post_ids)
# TODO: Sort might be off
@posts = Post.where(id: post_ids, topic_id: @topic.id)
.includes({ user: :primary_group }, :reply_to_user, :deleted_by, :incoming_email, :topic)
2017-07-27 21:20:09 -04:00
.order('sort_order')
2015-09-10 16:01:23 -04:00
@posts = filter_post_types(@posts)
@posts = @posts.with_deleted if @guardian.can_see_deleted_posts?
@posts
end
def find_topic(topic_or_topic_id)
if topic_or_topic_id.is_a?(Topic)
topic_or_topic_id
else
# with_deleted covered in #check_and_raise_exceptions
finder = Topic.with_deleted.where(id: topic_or_topic_id).includes(:category)
finder.first
end
end
def unfiltered_posts
2015-09-10 16:01:23 -04:00
result = filter_post_types(@topic.posts)
result = result.with_deleted if @guardian.can_see_deleted_posts?
2016-05-03 15:19:59 -04:00
result = result.where("user_id IS NOT NULL") if @exclude_deleted_users
result = result.where(hidden: false) if @exclude_hidden
result
end
def setup_filtered_posts
# Certain filters might leave gaps between posts. If that's true, we can return a gap structure
@contains_gaps = false
@filtered_posts = unfiltered_posts
sql = <<~SQL
SELECT ignored_user_id
FROM ignored_users as ig
JOIN users as u ON u.id = ig.ignored_user_id
WHERE ig.user_id = :current_user_id
AND ig.ignored_user_id <> :current_user_id
AND NOT u.admin
AND NOT u.moderator
SQL
ignored_user_ids = DB.query_single(sql, current_user_id: @user&.id)
if ignored_user_ids.present?
@filtered_posts = @filtered_posts.where.not("user_id IN (?) AND id <> ?", ignored_user_ids, first_post_id)
@contains_gaps = true
2019-02-27 08:49:07 -05:00
end
# Filters
if @filter == 'summary'
@filtered_posts = @filtered_posts.summary(@topic.id)
@contains_gaps = true
end
if @best.present?
@filtered_posts = @filtered_posts.where('posts.post_type = ?', Post.types[:regular])
@contains_gaps = true
end
# Username filters
if @username_filters.present?
2017-07-27 21:20:09 -04:00
usernames = @username_filters.map { |u| u.downcase }
@filtered_posts = @filtered_posts.where('
posts.post_number = 1
OR posts.user_id IN (SELECT u.id FROM users u WHERE u.username_lower IN (?))
', usernames)
@contains_gaps = true
end
# Deleted
# This should be last - don't want to tell the admin about deleted posts that clicking the button won't show
# copy the filter for has_deleted? method
@predelete_filtered_posts = @filtered_posts.spawn
if @guardian.can_see_deleted_posts? && !@show_deleted && has_deleted?
@filtered_posts = @filtered_posts.where(
"posts.deleted_at IS NULL OR posts.post_number = 1"
)
@contains_gaps = true
end
end
def check_and_raise_exceptions
raise Discourse::NotFound if @topic.blank?
# Special case: If the topic is private and the user isn't logged in, ask them
# to log in!
if @topic.present? && @topic.private_message? && @user.blank?
raise Discourse::NotLoggedIn.new
end
# can user see this topic?
raise Discourse::InvalidAccess.new("can't see #{@topic}", @topic) unless @guardian.can_see?(@topic)
# log personal message views
if SiteSetting.log_personal_messages_views && @topic.present? && @topic.private_message? && @topic.all_allowed_users.where(id: @user.id).blank?
unless UserHistory.where(acting_user_id: @user.id, action: UserHistory.actions[:check_personal_message], topic_id: @topic.id).where("created_at > ?", 1.hour.ago).exists?
StaffActionLogger.new(@user).log_check_personal_message(@topic)
end
end
end
2013-02-05 14:16:51 -05:00
end