mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-01 12:58:30 +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>
132 lines
5.2 KiB
Ruby
132 lines
5.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Jobs
|
|
class BackfillNestedReplyStats < ::Jobs::Scheduled
|
|
every 5.minutes
|
|
|
|
cluster_concurrency 1
|
|
|
|
def execute(_ = nil)
|
|
return unless SiteSetting.nested_replies_enabled
|
|
|
|
topic_ids =
|
|
DB.query_single(<<~SQL, batch_size: SiteSetting.nested_replies_backfill_batch_size)
|
|
SELECT t.id FROM topics t
|
|
INNER JOIN nested_topics nt ON nt.topic_id = t.id
|
|
LEFT JOIN nested_view_post_stats s ON s.post_id = (
|
|
SELECT p.id FROM posts p
|
|
WHERE p.topic_id = t.id AND p.post_number = 1
|
|
LIMIT 1
|
|
)
|
|
WHERE t.deleted_at IS NULL
|
|
AND t.archetype = 'regular'
|
|
AND s.post_id IS NULL
|
|
ORDER BY t.id DESC
|
|
LIMIT :batch_size
|
|
SQL
|
|
|
|
return if topic_ids.empty?
|
|
|
|
topic_ids.each do |topic_id|
|
|
backfill_topic(topic_id)
|
|
ensure_op_stat_row(topic_id)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
# Guarantees the OP has a stats row so the selector (which keys on
|
|
# s.post_id IS NULL for post_number = 1) will not re-pick this topic
|
|
# on the next run when it has no qualifying replies. When a reply later
|
|
# arrives, nested_replies_increment_stats upserts into the same row.
|
|
def ensure_op_stat_row(topic_id)
|
|
DB.exec(<<~SQL, topic_id: topic_id)
|
|
INSERT INTO nested_view_post_stats
|
|
(post_id, direct_reply_count, whisper_direct_reply_count,
|
|
total_descendant_count, whisper_total_descendant_count,
|
|
created_at, updated_at)
|
|
SELECT p.id, 0, 0, 0, 0, NOW(), NOW()
|
|
FROM posts p
|
|
WHERE p.topic_id = :topic_id AND p.post_number = 1
|
|
ON CONFLICT (post_id) DO NOTHING
|
|
SQL
|
|
end
|
|
|
|
def backfill_topic(topic_id)
|
|
DB.exec(<<~SQL, topic_id: topic_id, whisper_type: Post.types[:whisper])
|
|
WITH RECURSIVE
|
|
direct_counts AS (
|
|
SELECT reply_to_post_number AS parent_number, post_type,
|
|
COUNT(*) AS cnt
|
|
FROM posts
|
|
WHERE topic_id = :topic_id
|
|
AND reply_to_post_number IS NOT NULL
|
|
AND post_number > 1
|
|
GROUP BY reply_to_post_number, post_type
|
|
),
|
|
direct_agg AS (
|
|
SELECT parent_number,
|
|
SUM(cnt) AS direct_reply_count,
|
|
SUM(CASE WHEN post_type = :whisper_type THEN cnt ELSE 0 END) AS whisper_direct_reply_count
|
|
FROM direct_counts
|
|
GROUP BY parent_number
|
|
),
|
|
edges AS (
|
|
SELECT id, post_number, reply_to_post_number, post_type
|
|
FROM posts
|
|
WHERE topic_id = :topic_id
|
|
AND reply_to_post_number IS NOT NULL
|
|
AND post_number > 1
|
|
),
|
|
ancestor_walk AS (
|
|
SELECT e.reply_to_post_number AS ancestor_number,
|
|
1 AS descendant_count,
|
|
CASE WHEN e.post_type = :whisper_type THEN 1 ELSE 0 END AS whisper_descendant_count,
|
|
1 AS depth
|
|
FROM edges e
|
|
UNION ALL
|
|
SELECT p.reply_to_post_number,
|
|
a.descendant_count,
|
|
a.whisper_descendant_count,
|
|
a.depth + 1
|
|
FROM ancestor_walk a
|
|
JOIN edges p ON p.post_number = a.ancestor_number
|
|
WHERE a.depth < 500
|
|
),
|
|
descendant_agg AS (
|
|
SELECT ancestor_number,
|
|
COUNT(*) AS total_descendant_count,
|
|
SUM(whisper_descendant_count) AS whisper_total_descendant_count
|
|
FROM ancestor_walk
|
|
GROUP BY ancestor_number
|
|
),
|
|
combined AS (
|
|
SELECT p.id AS post_id,
|
|
COALESCE(d.direct_reply_count, 0) AS direct_reply_count,
|
|
COALESCE(d.whisper_direct_reply_count, 0) AS whisper_direct_reply_count,
|
|
COALESCE(t.total_descendant_count, 0) AS total_descendant_count,
|
|
COALESCE(t.whisper_total_descendant_count, 0) AS whisper_total_descendant_count
|
|
FROM posts p
|
|
LEFT JOIN direct_agg d ON d.parent_number = p.post_number
|
|
LEFT JOIN descendant_agg t ON t.ancestor_number = p.post_number
|
|
WHERE p.topic_id = :topic_id
|
|
AND (d.parent_number IS NOT NULL OR t.ancestor_number IS NOT NULL)
|
|
)
|
|
INSERT INTO nested_view_post_stats
|
|
(post_id, direct_reply_count, whisper_direct_reply_count,
|
|
total_descendant_count, whisper_total_descendant_count,
|
|
created_at, updated_at)
|
|
SELECT post_id, direct_reply_count, whisper_direct_reply_count,
|
|
total_descendant_count, whisper_total_descendant_count,
|
|
NOW(), NOW()
|
|
FROM combined
|
|
ON CONFLICT (post_id) DO UPDATE SET
|
|
direct_reply_count = GREATEST(EXCLUDED.direct_reply_count, nested_view_post_stats.direct_reply_count),
|
|
whisper_direct_reply_count = GREATEST(EXCLUDED.whisper_direct_reply_count, nested_view_post_stats.whisper_direct_reply_count),
|
|
total_descendant_count = GREATEST(EXCLUDED.total_descendant_count, nested_view_post_stats.total_descendant_count),
|
|
whisper_total_descendant_count = GREATEST(EXCLUDED.whisper_total_descendant_count, nested_view_post_stats.whisper_total_descendant_count),
|
|
updated_at = NOW()
|
|
SQL
|
|
end
|
|
end
|
|
end
|