587 lines
15 KiB
Ruby
587 lines
15 KiB
Ruby
require_dependency 'guardian'
|
|
require_dependency 'topic_query'
|
|
require_dependency 'filter_best_posts'
|
|
require_dependency 'gaps'
|
|
|
|
class TopicView
|
|
|
|
attr_reader :topic, :posts, :guardian, :filtered_posts, :chunk_size, :print, :message_bus_last_id
|
|
attr_accessor :draft, :draft_key, :draft_sequence, :user_custom_fields, :post_custom_fields, :post_number
|
|
|
|
def self.slow_chunk_size
|
|
10
|
|
end
|
|
|
|
def self.print_chunk_size
|
|
1000
|
|
end
|
|
|
|
def self.chunk_size
|
|
20
|
|
end
|
|
|
|
def self.default_post_custom_fields
|
|
@default_post_custom_fields ||= ["action_code_who"]
|
|
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.dup
|
|
@post_number = [@post_number.to_i, 1].max
|
|
@page = [@page.to_i, 1].max
|
|
|
|
@chunk_size =
|
|
case
|
|
when options[:slow_platform] then TopicView.slow_chunk_size
|
|
when @print then TopicView.print_chunk_size
|
|
else TopicView.chunk_size
|
|
end
|
|
|
|
@limit ||= @chunk_size
|
|
|
|
setup_filtered_posts
|
|
|
|
@initial_load = true
|
|
@index_reverse = false
|
|
|
|
filter_posts(options)
|
|
|
|
if @posts
|
|
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)
|
|
end
|
|
|
|
def canonical_path
|
|
path = relative_url
|
|
path <<
|
|
if @page > 1
|
|
"?page=#{@page}"
|
|
else
|
|
page = ((@post_number - 1) / @limit) + 1
|
|
page > 1 ? "?page=#{page}" : ""
|
|
end
|
|
|
|
path
|
|
end
|
|
|
|
def contains_gaps?
|
|
@contains_gaps
|
|
end
|
|
|
|
def gaps
|
|
return unless @contains_gaps
|
|
@gaps ||= Gaps.new(filtered_post_ids, unfiltered_posts.order(:sort_order).pluck(:id))
|
|
end
|
|
|
|
def last_post
|
|
return nil if @posts.blank?
|
|
@last_post ||= @posts.last
|
|
end
|
|
|
|
def prev_page
|
|
@page > 1 && posts.size > 0 ? @page - 1 : nil
|
|
end
|
|
|
|
def next_page
|
|
@next_page ||= begin
|
|
if last_post && (@topic.highest_post_number > last_post.post_number)
|
|
@page + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
def prev_page_path
|
|
if prev_page > 1
|
|
"#{relative_url}?page=#{prev_page}"
|
|
else
|
|
relative_url
|
|
end
|
|
end
|
|
|
|
def next_page_path
|
|
"#{relative_url}?page=#{next_page}"
|
|
end
|
|
|
|
def absolute_url
|
|
"#{Discourse.base_url}#{relative_url}"
|
|
end
|
|
|
|
def relative_url
|
|
"#{@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?
|
|
# 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
|
|
(@topic.word_count / SiteSetting.read_time_word_count).floor if @topic.word_count
|
|
end
|
|
|
|
def like_count
|
|
return nil if @post_number > 1 # only show for topic URLs
|
|
@topic.like_count
|
|
end
|
|
|
|
def image_url
|
|
if @post_number > 1 && @desired_post.present?
|
|
if @desired_post.image_url.present?
|
|
@desired_post.image_url
|
|
elsif @desired_post.user
|
|
# show poster avatar
|
|
@desired_post.user.avatar_template_url.gsub("{size}", "200")
|
|
end
|
|
else
|
|
@topic.image_url
|
|
end
|
|
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?
|
|
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
|
|
|
|
@group_names = result
|
|
end
|
|
|
|
# Find the sort order for a post in the topic
|
|
def sort_order_for_post_number(post_number)
|
|
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)
|
|
end
|
|
|
|
# Filter to all posts near a particular post number
|
|
def filter_posts_near(post_number)
|
|
min_idx, max_idx = get_minmax_ids(post_number)
|
|
filter_posts_in_range(min_idx, max_idx)
|
|
end
|
|
|
|
def filter_posts_paged(page)
|
|
page = [page, 1].max
|
|
min = @limit * (page - 1)
|
|
|
|
# Sometimes we don't care about the OP, for example when embedding comments
|
|
min = 1 if min == 0 && @exclude_first
|
|
|
|
max = (min + @limit) - 1
|
|
|
|
filter_posts_in_range(min, max)
|
|
end
|
|
|
|
def filter_best(max, opts = {})
|
|
filter = FilterBestPosts.new(@topic, @filtered_posts, max, opts)
|
|
@posts = filter.posts
|
|
@filtered_posts = filter.filtered_posts
|
|
end
|
|
|
|
def read?(post_number)
|
|
return true unless @user
|
|
read_posts_set.include?(post_number)
|
|
end
|
|
|
|
def has_deleted?
|
|
@predelete_filtered_posts.with_deleted
|
|
.where("posts.deleted_at IS NOT NULL")
|
|
.where("posts.post_number > 1")
|
|
.exists?
|
|
end
|
|
|
|
def topic_user
|
|
@topic_user ||= begin
|
|
return nil if @user.blank?
|
|
@topic.topic_users.find_by(user_id: @user.id)
|
|
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?
|
|
|
|
sql = <<~SQL
|
|
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
|
|
|
|
Hash[*DB.query_single(sql, post_ids: post_ids)]
|
|
end
|
|
end
|
|
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
|
|
|
|
def participants
|
|
@participants ||= begin
|
|
participants = {}
|
|
User.where(id: post_counts_by_user.keys).includes(:primary_group).each { |u| participants[u.id] = u }
|
|
participants
|
|
end
|
|
end
|
|
|
|
def all_post_actions
|
|
@all_post_actions ||= PostAction.counts_for(@posts, @user)
|
|
end
|
|
|
|
def all_active_flags
|
|
@all_active_flags ||= PostAction.active_flags_counts_for(@posts)
|
|
end
|
|
|
|
def links
|
|
@links ||= TopicLink.topic_map(@guardian, @topic.id)
|
|
end
|
|
|
|
def link_counts
|
|
@link_counts ||= TopicLink.counts_for(@guardian, @topic, posts)
|
|
end
|
|
|
|
# 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 suggested_topics
|
|
@suggested_topics ||= TopicQuery.new(@user).list_suggested_for(topic)
|
|
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.
|
|
# `days_ago` is there for the timeline calculations.
|
|
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
|
|
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
|
|
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(:id).first
|
|
end
|
|
|
|
protected
|
|
|
|
def read_posts_set
|
|
@read_posts_set ||= begin
|
|
result = Set.new
|
|
return result unless @user.present?
|
|
return result unless topic_user.present?
|
|
|
|
post_numbers = PostTiming
|
|
.where(topic_id: @topic.id, user_id: @user.id)
|
|
.where(post_number: @posts.pluck(:post_number))
|
|
.pluck(:post_number)
|
|
|
|
post_numbers.each { |pn| result << pn }
|
|
result
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def filter_post_types(posts)
|
|
visible_types = Topic.visible_post_types(@user)
|
|
|
|
if @user.present?
|
|
posts.where("posts.user_id = ? OR post_type IN (?)", @user.id, visible_types)
|
|
else
|
|
posts.where(post_type: visible_types)
|
|
end
|
|
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)
|
|
.order('sort_order')
|
|
@posts = filter_post_types(@posts)
|
|
@posts = @posts.with_deleted if @guardian.can_see_deleted_posts?
|
|
@posts
|
|
end
|
|
|
|
def filter_posts_in_range(min, max)
|
|
post_count = (filtered_post_ids.length - 1)
|
|
|
|
max = [max, post_count].min
|
|
|
|
return @posts = Post.none if min > max
|
|
|
|
min = [[min, max].min, 0].max
|
|
|
|
@posts = filter_posts_by_ids(filtered_post_ids[min..max])
|
|
@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
|
|
result = filter_post_types(@topic.posts)
|
|
result = result.with_deleted if @guardian.can_see_deleted_posts?
|
|
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
|
|
|
|
# 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?
|
|
usernames = @username_filters.map { |u| u.downcase }
|
|
@filtered_posts = @filtered_posts.where('post_number = 1 OR posts.user_id IN (SELECT u.id FROM users u WHERE 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("deleted_at IS NULL OR 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
|
|
|
|
def get_minmax_ids(post_number)
|
|
# Find the closest number we have
|
|
closest_index = closest_post_to(post_number)
|
|
return nil if closest_index.nil?
|
|
|
|
# Make sure to get at least one post before, even with rounding
|
|
posts_before = (@limit.to_f / 4).floor
|
|
posts_before = 1 if posts_before.zero?
|
|
|
|
min_idx = closest_index - posts_before
|
|
min_idx = 0 if min_idx < 0
|
|
max_idx = min_idx + (@limit - 1)
|
|
|
|
# Get a full page even if at the end
|
|
ensure_full_page(min_idx, max_idx)
|
|
end
|
|
|
|
def ensure_full_page(min, max)
|
|
upper_limit = (filtered_post_ids.length - 1)
|
|
if max >= upper_limit
|
|
return (upper_limit - @limit) + 1, upper_limit
|
|
else
|
|
return min, max
|
|
end
|
|
end
|
|
|
|
def closest_post_to(post_number)
|
|
# happy path
|
|
closest_post_id = filtered_post_id(post_number)
|
|
|
|
if closest_post_id.blank?
|
|
# less happy path, missing post
|
|
closest_post_id = @filtered_posts.order("@(post_number - #{post_number})").limit(1).pluck(:id).first
|
|
end
|
|
|
|
return nil if closest_post_id.blank?
|
|
|
|
filtered_post_ids.index(closest_post_id) || filtered_post_ids[0]
|
|
end
|
|
|
|
MEGA_TOPIC_POSTS_COUNT = 10000
|
|
|
|
def is_mega_topic?
|
|
@is_mega_topic ||= (@topic.posts_count >= MEGA_TOPIC_POSTS_COUNT)
|
|
end
|
|
end
|