discourse/lib/topic_view.rb
Sam 6ddd8d9166 FIX: when entering topics "tracking" would not be set
There was a timing issue when subscribing to messages for topics.

Old flow:

- We generate JSON for topic
- We subscribe to messages for topic

New flow:

- We keep track of last id in the topic message bus channel
- We generate JSON
- We subscribe to messages for topic starting at saved message id

This ensures that there is complete overlap for message consumption
and that there are no cases where an update may go missing due to timing
2017-05-16 15:04:21 -04:00

505 lines
14 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
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_id, user=nil, options={})
@message_bus_last_id = MessageBus.last_id("/topic/#{topic_id}")
@user = user
@guardian = Guardian.new(@user)
@topic = find_topic(topic_id)
@print = options[:print].present?
check_and_raise_exceptions
options.each do |key, value|
self.instance_variable_set("@#{key}".to_sym, value)
end
@page = 1 if (!@page || @page.zero?)
@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
added_fields = User.whitelisted_user_custom_fields(@guardian)
if added_fields.present?
@user_custom_fields = User.custom_fields_for_ids(@posts.map(&:user_id), added_fields)
end
end
whitelisted_fields = TopicView.whitelisted_post_custom_fields(@user)
if whitelisted_fields.present? && @posts
@post_custom_fields = Post.custom_fields_for_ids(@posts.map(&:id), whitelisted_fields)
end
@draft_key = @topic.draft_key
@draft_sequence = DraftSequence.current(@user, @draft_key)
end
def canonical_path
path = relative_url
path << if @post_number
page = ((@post_number.to_i - 1) / @limit) + 1
(page > 1) ? "?page=#{page}" : ""
else
(@page && @page.to_i > 1) ? "?page=#{@page}" : ""
end
path
end
def contains_gaps?
@contains_gaps
end
def gaps
return unless @contains_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
if @page && @page > 1 && posts.length > 0
@page - 1
else
nil
end
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.to_i}
@desired_post ||= posts.first
@desired_post
end
def summary
return nil if desired_post.blank?
# TODO, this is actually quite slow, should be cached in the post table
excerpt = desired_post.excerpt(500, strip_links: true, text_entities: true)
(excerpt || "").gsub(/\n/, ' ').strip
end
def read_time
return nil if @post_number.present? && @post_number.to_i != 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.present? && @post_number.to_i != 1 # only show for topic URLs
@topic.like_count
end
def image_url
if @post_number.present? && @post_number.to_i != 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
def post_counts_by_user
@post_counts_by_user ||= Post.where(topic_id: @topic.id)
.where("user_id IS NOT NULL")
.group(:user_id)
.order("count_all DESC")
.limit(24)
.count
end
def participants
@participants ||= begin
participants = {}
User.where(id: post_counts_by_user.map {|k,v| k}).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
def current_post_ids
@current_post_ids ||= if @posts.is_a?(Array)
@posts.map {|p| p.id }
else
@posts.pluck(:post_number)
end
end
# Returns an array of [id, post_number, days_ago] tuples. `days_ago` is there for the timeline
# calculations.
def filtered_post_stream
@filtered_post_stream ||= @filtered_posts.order(:sort_order)
.pluck(:id,
:post_number,
'EXTRACT(DAYS FROM CURRENT_TIMESTAMP - created_at)::INT AS days_ago')
end
def filtered_post_ids
@filtered_post_ids ||= filtered_post_stream.map {|tuple| tuple[0]}
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: current_post_ids)
.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, :reply_to_user, :incoming_email)
.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 = [] 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_id)
# with_deleted covered in #check_and_raise_exceptions
finder = Topic.with_deleted.where(id: topic_id).includes(:category)
finder.first
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
raise Discourse::InvalidAccess.new("can't see #{@topic}", @topic) unless guardian.can_see?(@topic)
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 = @filtered_posts.where("post_number = ?", post_number).limit(1).pluck(:id)
if closest_post.empty?
# less happy path, missing post
closest_post = @filtered_posts.order("@(post_number - #{post_number})").limit(1).pluck(:id)
end
return nil if closest_post.empty?
filtered_post_ids.index(closest_post.first) || filtered_post_ids[0]
end
end