discourse/app/controllers/nested_topics_controller.rb
Mark VanLandingham c879820aa6
FEATURE: activity log modal for small actions (#39380)
In nested view it adds an "activity log" link to open the modal. This contains whispers with action code, and small action posts, which are not rendered on the nested view.
2026-04-30 10:17:14 -05:00

236 lines
7.3 KiB
Ruby

# frozen_string_literal: true
class NestedTopicsController < ApplicationController
skip_before_action :check_xhr, only: %i[show context]
before_action :ensure_nested_replies_enabled
before_action :find_topic_with_topic_view, only: %i[show children context]
before_action :find_topic, only: %i[pin toggle activity]
before_action :ensure_not_pm
after_action :track_visit, only: %i[show context]
# GET /n/:slug/:topic_id (HTML + JSON)
# HTML: preloads initial data into the Ember shell (crawlers redirect to flat view)
# JSON page 0: includes topic metadata, OP post, sort, and message_bus_last_id
# JSON page 1+: returns only roots for pagination
def show
if spa_boot_request?
if use_crawler_layout?
redirect_to "/t/#{params[:slug]}/#{params[:topic_id]}", status: :moved_permanently
return
end
store_preloaded("nested_topic_#{@topic.id}", MultiJson.dump(list_roots_response(page: 0)))
render "default/empty"
return
end
page = params[:page].to_i.clamp(0, 1000)
render json: list_roots_response(page: page)
end
# GET /n/:slug/:topic_id/children/:post_number
def children
NestedTopic::ListChildren.call(
service_params.deep_merge(
params: {
parent_post_number: params[:post_number].to_i,
sort: validated_sort,
page: params[:page].to_i.clamp(0, 1000),
depth: params[:depth].to_i.clamp(1, 100),
},
topic_view: @topic_view,
),
) do
on_success { |response:| render json: response }
on_failed_contract { raise Discourse::NotFound }
on_failure { raise Discourse::NotFound }
end
end
# GET /n/:slug/:topic_id/:post_number (HTML + JSON)
# HTML: preloads context data into the Ember shell (crawlers redirect to flat view)
# JSON param: context (integer) -- controls ancestor depth.
# nil/absent = windowed ancestor chain capped at max_depth (deep-links, notifications)
# 0 = no ancestors, target at depth 0 ("Continue this thread")
def context
if spa_boot_request?
if use_crawler_layout?
redirect_to "/t/#{params[:slug]}/#{params[:topic_id]}/#{params[:post_number]}",
status: :moved_permanently
return
end
store_preloaded("nested_topic_#{@topic.id}", MultiJson.dump(show_context_response))
render "default/empty"
return
end
render json: show_context_response
end
# PUT /n/:slug/:topic_id/pin
def pin
NestedTopic::TogglePin.call(service_params.deep_merge(params: { topic_id: @topic.id })) do
on_success { |nested_topic:| render json: { pinned_post_ids: nested_topic.pinned_post_ids } }
on_failed_contract { raise Discourse::NotFound }
on_model_not_found(:topic) { raise Discourse::NotFound }
on_model_not_found(:post) { raise Discourse::NotFound }
on_failed_policy(:staff_can_edit) { raise Discourse::InvalidAccess }
on_failed_policy(:post_is_root) { raise Discourse::InvalidParameters.new(:post_id) }
on_failed_policy(:within_pin_limit) { raise Discourse::InvalidParameters.new(:post_id) }
on_failure { raise Discourse::InvalidParameters }
end
end
# GET /n/:slug/:topic_id/activity
def activity
post_types = [Post.types[:small_action]]
post_types << Post.types[:whisper] if guardian.user&.whisperer?
posts =
@topic
.posts
.where(post_type: post_types)
.where.not(action_code: [nil, ""])
.includes(:user)
.order(:created_at)
Post.preload_custom_fields(posts, %w[action_code_who action_code_path])
creator = @topic.user
actions = [
{
action_code: "topic_created",
created_at: @topic.created_at,
username: creator&.username,
avatar_template: creator&.avatar_template,
},
]
actions.concat(
posts.map do |post|
{
id: post.id,
action_code: post.action_code,
action_code_who: post.custom_fields["action_code_who"],
action_code_path: post.custom_fields["action_code_path"],
created_at: post.created_at,
username: post.user&.username,
avatar_template: post.user&.avatar_template,
cooked: post.cooked,
}
end,
)
render json: { small_actions: actions }
end
# PUT /n/:slug/:topic_id/toggle
def toggle
NestedTopic::Toggle.call(service_params.deep_merge(params: { topic_id: @topic.id })) do
on_success { |params:| render json: { is_nested_view: params.enabled } }
on_failed_contract { raise Discourse::InvalidParameters }
on_model_not_found(:topic) { raise Discourse::NotFound }
on_failed_policy(:staff_can_edit) { raise Discourse::InvalidAccess }
on_failure { raise Discourse::InvalidParameters }
end
end
private
def list_roots_response(page:)
result = nil
NestedTopic::ListRoots.call(
service_params.deep_merge(
params: {
sort: validated_sort,
page: page,
},
topic_view: @topic_view,
),
) do
on_success { |response:| result = response }
on_failed_contract { raise Discourse::NotFound }
on_failure { raise Discourse::NotFound }
end
result
end
def show_context_response
result = nil
NestedTopic::ShowContext.call(
service_params.deep_merge(
params: {
target_post_number: params[:post_number].to_i,
sort: validated_sort,
context_depth: params[:context]&.to_i,
},
topic_view: @topic_view,
),
) do
on_success { |response:| result = response }
on_failed_contract { raise Discourse::NotFound }
on_model_not_found(:target_post) { raise Discourse::NotFound }
on_failure { raise Discourse::NotFound }
end
result
end
def ensure_nested_replies_enabled
raise Discourse::NotFound unless SiteSetting.nested_replies_enabled
end
def ensure_not_pm
return unless @topic.private_message?
if request.get?
url = "/t/#{@topic.slug}/#{@topic.id}"
post_number = params[:post_number].to_i
url << "/#{post_number}" if post_number > 0
redirect_to url, status: :found
else
raise Discourse::NotFound
end
end
def find_topic_with_topic_view
topic_id = params[:topic_id].to_i
@topic_view =
TopicView.new(topic_id, current_user, skip_custom_fields: true, skip_post_loading: true)
@topic = @topic_view.topic
guardian.ensure_can_see!(@topic)
if should_track_visit?
@topic_view.draft = Draft.get(current_user, @topic_view.draft_key, @topic_view.draft_sequence)
end
rescue Discourse::InvalidAccess
raise Discourse::NotFound
end
def find_topic
@topic = Topic.find_by(id: params[:topic_id].to_i)
raise Discourse::NotFound if @topic.blank? || !guardian.can_see?(@topic)
end
def track_visit
return if response.redirect?
topic_id = @topic.id
user_id = current_user&.id
ip = request.remote_ip
TopicsController.defer_track_visit(topic_id, user_id) if should_track_visit?
TopicsController.defer_topic_view(topic_id, ip, user_id)
end
def should_track_visit?
!!((!request.format.json? || params[:track_visit]) && current_user)
end
def validated_sort
sort = params[:sort].to_s.downcase
NestedReplies::Sort.valid?(sort) ? sort : SiteSetting.nested_replies_default_sort
end
end