mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-11 14:41:10 +08:00
### Super high level description: Adds a nested/threaded view for Discourse topics, allowing posts to be displayed as an indented reply tree instead of the default flat chronological stream. Backend: - New /n/:slug/:topic_id routes serving roots, children (paginated), and context (ancestor chain) endpoints - TreeLoader recursively fetches reply trees with configurable max depth, Sort supports top/new/old ordering - NestedViewPostStat caches per-post reply counts (direct + total descendants, whisper-aware) with a backfill job for existing data - NestedTopic model tracks per-topic opt-in and pinned post Frontend: - Recursive <NestedPost> / <NestedPostChildren> components with lazy-load expansion, cloaking, and scroll tracking - NestedViewCache service preserves expansion state and scroll position across back/forward navigation (15 entries, 10min TTL) - Context view for deep-linking to a specific post with its ancestor chain - Floating actions bar, sort selector, real-time MessageBus updates Site settings (hidden): nested_replies_enabled, nested_replies_default, nested_replies_default_sort, nested_replies_max_depth, nested_replies_cap_nesting_depth, nested_replies_toggle_mode_groups, plus a per-category default override. Co-authored-by: Rafael Silva <xfalcox@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Sérgio Saquetim <saquetim@discourse.org>
126 lines
3.7 KiB
Ruby
126 lines
3.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class NestedTopic::ListChildren
|
|
include Service::Base
|
|
|
|
params do
|
|
attribute :parent_post_number, :integer
|
|
attribute :sort, :string
|
|
attribute :page, :integer
|
|
attribute :depth, :integer
|
|
|
|
validates :parent_post_number, presence: true
|
|
validates :sort, presence: true
|
|
validates :page, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
validates :depth, presence: true, numericality: { greater_than_or_equal_to: 1 }
|
|
end
|
|
|
|
model :loader, :build_loader
|
|
model :preloader, :build_preloader
|
|
model :serializer, :build_serializer
|
|
step :load_children
|
|
only_if(:nested) { step :expand_reply_trees }
|
|
step :prepare_posts
|
|
step :serialize_children
|
|
|
|
private
|
|
|
|
def build_loader(topic_view:, guardian:)
|
|
NestedReplies::TreeLoader.new(topic: topic_view.topic, guardian: guardian)
|
|
end
|
|
|
|
def build_preloader(topic_view:, guardian:)
|
|
NestedReplies::PostPreloader.new(
|
|
topic_view: topic_view,
|
|
topic: topic_view.topic,
|
|
current_user: guardian.user,
|
|
guardian: guardian,
|
|
)
|
|
end
|
|
|
|
def build_serializer(topic_view:, guardian:)
|
|
NestedReplies::PostTreeSerializer.new(
|
|
topic: topic_view.topic,
|
|
topic_view: topic_view,
|
|
guardian: guardian,
|
|
)
|
|
end
|
|
|
|
def nested(params:, loader:)
|
|
!flattened?(params, loader)
|
|
end
|
|
|
|
def load_children(params:, loader:, topic_view:)
|
|
context[:flatten] = flattened?(params, loader)
|
|
per_page = NestedReplies::TreeLoader::CHILDREN_PER_PAGE
|
|
|
|
children_scope =
|
|
if context[:flatten]
|
|
loader.flat_descendants_scope(
|
|
params.parent_post_number,
|
|
sort: params.sort,
|
|
offset: params.page * per_page,
|
|
limit: per_page,
|
|
)
|
|
else
|
|
scope =
|
|
topic_view
|
|
.topic
|
|
.posts
|
|
.where(reply_to_post_number: params.parent_post_number)
|
|
.where(post_number: 2..)
|
|
scope = loader.apply_visibility(scope)
|
|
scope = NestedReplies::Sort.apply(scope, params.sort)
|
|
scope.offset(params.page * per_page).limit(per_page)
|
|
end
|
|
|
|
context[:children_posts] = loader.load_posts_for_tree(children_scope).to_a
|
|
context[:children_map] = {}
|
|
context[:all_posts] = context[:children_posts]
|
|
end
|
|
|
|
def expand_reply_trees(params:, loader:, children_posts:)
|
|
remaining_depth =
|
|
if params.depth < loader.configured_max_depth
|
|
[NestedReplies::TreeLoader::PRELOAD_DEPTH, loader.configured_max_depth - params.depth].min
|
|
else
|
|
0
|
|
end
|
|
tree_data = loader.batch_preload_tree(children_posts, params.sort, max_depth: remaining_depth)
|
|
context[:children_map] = tree_data[:children_map]
|
|
context[:all_posts] = tree_data[:all_posts]
|
|
end
|
|
|
|
def prepare_posts(loader:, preloader:, all_posts:)
|
|
preloader.prepare(all_posts)
|
|
context[:reply_counts] = loader.direct_reply_counts(all_posts.map(&:post_number))
|
|
context[:descendant_counts] = loader.total_descendant_counts(all_posts.map(&:id))
|
|
end
|
|
|
|
def serialize_children(
|
|
params:,
|
|
children_posts:,
|
|
children_map:,
|
|
reply_counts:,
|
|
descendant_counts:,
|
|
serializer:,
|
|
flatten:
|
|
)
|
|
context[:response] = {
|
|
children:
|
|
children_posts.map do |child|
|
|
if flatten
|
|
serializer.serialize_post(child, reply_counts, descendant_counts).merge(children: [])
|
|
else
|
|
serializer.serialize_tree(child, children_map, reply_counts, descendant_counts)
|
|
end
|
|
end,
|
|
has_more: children_posts.size == NestedReplies::TreeLoader::CHILDREN_PER_PAGE,
|
|
page: params.page,
|
|
}
|
|
end
|
|
|
|
def flattened?(params, loader)
|
|
SiteSetting.nested_replies_cap_nesting_depth && params.depth >= loader.configured_max_depth
|
|
end
|
|
end
|