mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 00:54:21 +08:00
Previously, small actions in nested replies' Activity Log modal could not be edited, deleted, or recovered, and refreshes collapsed the modal. This change renders the activity log as proper small action posts with all the actions you expect from flat view.
295 lines
9 KiB
Ruby
Vendored
295 lines
9 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class NestedTopicsController < ApplicationController
|
|
include EmbedModeHandler
|
|
|
|
ACTIVITY_PAGE_SIZE = 50
|
|
ACTIVITY_POST_ATTRIBUTES = %i[
|
|
id
|
|
name
|
|
username
|
|
avatar_template
|
|
created_at
|
|
cooked
|
|
cooked_hidden
|
|
post_number
|
|
post_type
|
|
topic_id
|
|
user_id
|
|
version
|
|
action_code
|
|
action_code_who
|
|
action_code_path
|
|
deleted_at
|
|
user_deleted
|
|
can_edit
|
|
can_delete
|
|
can_recover
|
|
moderator?
|
|
admin?
|
|
staff?
|
|
].freeze
|
|
|
|
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
|
|
before_action :set_embed_class, only: %i[show context]
|
|
after_action :track_visit, only: %i[show context]
|
|
after_action :allow_embed_mode, only: %i[show context]
|
|
|
|
# GET /n/:slug/:topic_id (HTML + JSON)
|
|
# HTML: redirects browser requests to the canonical topic route.
|
|
# JSON page 0: includes topic metadata, OP post, sort, and message_bus_last_id
|
|
# JSON page 1+: returns only roots for pagination
|
|
def show
|
|
return redirect_to topic_route_url, status: topic_route_redirect_status if spa_boot_request?
|
|
|
|
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: redirects browser requests to the canonical topic route.
|
|
# 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?
|
|
return(redirect_to topic_route_url(params[:post_number]), status: topic_route_redirect_status)
|
|
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
|
|
page = params[:page].to_i.clamp(0, 1000)
|
|
posts = activity_posts.offset(page * ACTIVITY_PAGE_SIZE).limit(ACTIVITY_PAGE_SIZE + 1).to_a
|
|
has_more = posts.length > ACTIVITY_PAGE_SIZE
|
|
posts = posts.first(ACTIVITY_PAGE_SIZE)
|
|
posts.each { |post| post.topic = @topic }
|
|
|
|
Post.preload_custom_fields(posts, %w[action_code_who action_code_path])
|
|
actions = serialize_data(posts, PostSerializer, only: ACTIVITY_POST_ATTRIBUTES)
|
|
|
|
if page.zero?
|
|
creator = @topic.user
|
|
actions.unshift(
|
|
{
|
|
synthetic: true,
|
|
action_code: "topic_created",
|
|
created_at: @topic.created_at,
|
|
user_id: creator&.id,
|
|
username: creator&.username,
|
|
avatar_template: creator&.avatar_template,
|
|
},
|
|
)
|
|
end
|
|
|
|
render json: { small_actions: actions, has_more: has_more }
|
|
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
|
|
|
|
TOPIC_ROUTE_QUERY_PARAMS = %w[sort collapse_replies context embed_mode class_name].freeze
|
|
|
|
def activity_posts
|
|
post_types = [Post.types[:small_action]]
|
|
post_types << Post.types[:whisper] if guardian.user&.whisperer?
|
|
|
|
posts = @topic.posts
|
|
posts = posts.with_deleted if guardian.can_see_deleted_posts?(@topic.category)
|
|
|
|
posts =
|
|
posts
|
|
.where(post_type: post_types)
|
|
.where.not(action_code: [nil, ""])
|
|
.includes(:user)
|
|
.order(:created_at, :id)
|
|
|
|
guardian.filter_hidden_posts(posts, category: @topic.category)
|
|
end
|
|
|
|
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 topic_route_redirect_status
|
|
use_crawler_layout? ? :moved_permanently : :found
|
|
end
|
|
|
|
def topic_route_url(post_number = nil)
|
|
url = +"/t/#{@topic.slug}/#{@topic.id}"
|
|
post_number = post_number.to_i
|
|
url << "/#{post_number}" if post_number > 0
|
|
|
|
query = request.query_parameters.slice(*TOPIC_ROUTE_QUERY_PARAMS)
|
|
query.delete("class_name") unless query["embed_mode"] == "true"
|
|
url << "?#{query.to_query}" if query.present?
|
|
url
|
|
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
|
|
|
|
if should_track_visit?
|
|
TopicsController.defer_track_visit(topic_id, user_id)
|
|
self.class.defer_mark_caught_up(topic_id, user_id) if @topic.nested_view?
|
|
end
|
|
|
|
TopicsController.defer_topic_view(topic_id, ip, user_id)
|
|
end
|
|
|
|
# Screen-tracking only advances last_read for posts the viewport renders,
|
|
# so collapsed/hidden replies leave a nested topic stuck unread in the
|
|
# sidebar. Treat the visit itself as catching up.
|
|
def self.defer_mark_caught_up(topic_id, user_id)
|
|
Scheduler::Defer.later "Nested Topic Catch Up" do
|
|
user = User.find_by(id: user_id)
|
|
topic = Topic.find_by(id: topic_id)
|
|
next if user.blank? || topic.blank?
|
|
next unless topic.nested_view?
|
|
|
|
highest =
|
|
if user.whisperer?
|
|
[topic.highest_staff_post_number.to_i, topic.highest_post_number.to_i].max
|
|
else
|
|
topic.highest_post_number.to_i
|
|
end
|
|
next if highest < 1
|
|
|
|
TopicUser.update_last_read(user, topic_id, highest, 0, 0)
|
|
Notification.mark_posts_read(user, topic_id, (1..highest).to_a)
|
|
end
|
|
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
|