diff --git a/app/controllers/discourse_nested_replies/nested_topics_controller.rb b/app/controllers/discourse_nested_replies/nested_topics_controller.rb index 683c90d..7f33f5f 100644 --- a/app/controllers/discourse_nested_replies/nested_topics_controller.rb +++ b/app/controllers/discourse_nested_replies/nested_topics_controller.rb @@ -5,13 +5,27 @@ class NestedTopicsController < ::ApplicationController requires_plugin PLUGIN_NAME before_action :ensure_nested_replies_enabled - before_action :find_topic, except: [:respond] + before_action :find_topic, except: %i[respond check] # Serves the Ember app shell for hard refreshes on /nested/:slug/:topic_id def respond render end + # GET /nested/check/:topic_id + # Lightweight endpoint for the frontend redirect logic. + # Returns only the nested view flag without loading posts or full TopicView. + def check + topic = Topic.find_by(id: params[:topic_id].to_i) + raise Discourse::NotFound unless topic + guardian.ensure_can_see!(topic) + + render json: { + is_nested_view: + !!topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD], + } + end + # GET /nested/:slug/:topic_id/roots # On page 0 (initial load), includes topic metadata, OP post, sort, and message_bus_last_id. # On subsequent pages, returns only roots for pagination. @@ -252,6 +266,19 @@ def pin render json: { pinned_post_number: post_number } end + # PUT /nested/:slug/:topic_id/toggle + # Staff-only: enable or disable nested replies for the topic. + def toggle + guardian.ensure_can_edit!(@topic) + raise Discourse::InvalidAccess unless guardian.is_staff? + + enabled = params[:enabled].to_s == "true" + @topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] = enabled + @topic.save_custom_fields + + render json: { is_nested_view: enabled } + end + private def ensure_nested_replies_enabled diff --git a/assets/javascripts/discourse/api-initializers/nested-replies-topic-admin-button.js b/assets/javascripts/discourse/api-initializers/nested-replies-topic-admin-button.js new file mode 100644 index 0000000..1d915b4 --- /dev/null +++ b/assets/javascripts/discourse/api-initializers/nested-replies-topic-admin-button.js @@ -0,0 +1,48 @@ +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { apiInitializer } from "discourse/lib/api"; +import DiscourseURL from "discourse/lib/url"; + +export default apiInitializer((api) => { + const siteSettings = api.container.lookup("service:site-settings"); + + api.addTopicAdminMenuButton((topic) => { + if (!siteSettings.nested_replies_enabled) { + return; + } + + if (!api.getCurrentUser()?.staff) { + return; + } + + const isNested = topic.get("is_nested_view"); + + return { + icon: "nested-thread", + className: "topic-admin-nested-replies", + label: isNested + ? "discourse_nested_replies.topic_admin_menu.disable_nested_replies" + : "discourse_nested_replies.topic_admin_menu.enable_nested_replies", + action: () => { + const newValue = !isNested; + const topicId = topic.get("id"); + const slug = topic.get("slug"); + + ajax(`/nested/${slug}/${topicId}/toggle`, { + type: "PUT", + data: { enabled: newValue }, + }) + .then(() => { + topic.set("is_nested_view", newValue || null); + + if (newValue) { + DiscourseURL.routeTo(`/nested/${slug}/${topicId}`); + } else { + DiscourseURL.routeTo(`/t/${slug}/${topicId}`); + } + }) + .catch(popupAjaxError); + }, + }; + }); +}); diff --git a/assets/javascripts/discourse/api-initializers/nested-view-redirect.js b/assets/javascripts/discourse/api-initializers/nested-view-redirect.js index 67a51ff..24b93f1 100644 --- a/assets/javascripts/discourse/api-initializers/nested-view-redirect.js +++ b/assets/javascripts/discourse/api-initializers/nested-view-redirect.js @@ -1,6 +1,5 @@ import { ajax } from "discourse/lib/ajax"; import { apiInitializer } from "discourse/lib/api"; -import Category from "discourse/models/category"; import nestedPostUrl from "../lib/nested-post-url"; const TOPIC_URL_RE = /^\/t\/([^/]+)\/(\d+)(?:\/(\d+))?(?:\?(.*))?$/; @@ -13,30 +12,36 @@ function buildNestedPath(slug, topicId, postNumber) { return path; } -function isNestedDefault(siteSettings, categoryId) { - if (siteSettings.nested_replies_default) { - return true; - } - - if (categoryId) { - const category = Category.findById(categoryId); - if (category?.nested_replies_default) { - return true; - } - } - - return false; -} - export default apiInitializer((api) => { const siteSettings = api.container.lookup("service:site-settings"); const router = api.container.lookup("service:router"); const appEvents = api.container.lookup("service:app-events"); - const topicTrackingState = api.container.lookup( - "service:topic-tracking-state" - ); let composerSaveInfo = null; + // Tracks which topic IDs have is_nested_view, populated as topics + // flow through topic lists and topic view responses. + const nestedTopicIds = new Set(); + + api.registerValueTransformer( + "topic-list-item-class", + ({ value, context }) => { + if (context.topic?.is_nested_view) { + nestedTopicIds.add(context.topic.id); + } + return value; + } + ); + + api.registerValueTransformer( + "latest-topic-list-item-class", + ({ value, context }) => { + if (context.topic?.is_nested_view) { + nestedTopicIds.add(context.topic.id); + } + return value; + } + ); + appEvents.on("composer:saved", () => { if (!siteSettings.nested_replies_enabled) { return; @@ -86,7 +91,7 @@ export default apiInitializer((api) => { } const { topic } = context; - if (isNestedDefault(siteSettings, topic.category_id)) { + if (topic.is_nested_view) { const slug = topic.slug || "topic"; return `/nested/${slug}/${topic.id}`; } @@ -143,15 +148,7 @@ export default apiInitializer((api) => { const id = parseInt(topicId, 10); - // Look up category from topic tracking state (most reliable source) - // or fall back to session topic list for discovery-loaded topics. - let categoryId; - const trackedState = topicTrackingState.findState(id); - if (trackedState) { - categoryId = trackedState.category_id; - } - - if (isNestedDefault(siteSettings, categoryId)) { + if (nestedTopicIds.has(id)) { return buildNestedPath(slug, topicId, postNumber); } @@ -159,10 +156,9 @@ export default apiInitializer((api) => { }); // Fallback: if a topic URL wasn't intercepted by the route-to-url - // transformer (e.g. direct URL entry where the topic isn't in tracking - // state yet), intercept before the topic route's model hook runs. - // When the topic isn't in tracking state, abort and fetch topic info - // to determine the category before deciding which view to load. + // transformer (e.g. direct URL entry where the topic isn't known to + // be nested yet), intercept before the topic route's model hook runs + // and fetch topic info to check the is_nested_view field. const checkedTopicIds = new Map(); const CHECKED_TOPIC_TTL_MS = 60_000; @@ -199,24 +195,7 @@ export default apiInitializer((api) => { return; } - const tracked = topicTrackingState.findState(topicId); - if (tracked) { - if (!isNestedDefault(siteSettings, tracked.category_id)) { - return; - } - - transition.abort(); - const nearPost = transition.to?.params?.nearPost; - const queryParams = {}; - if (nearPost) { - queryParams.post_number = nearPost; - } - router.transitionTo("nested", slug, topicId, { queryParams }); - return; - } - - // Topic not in tracking state (e.g. direct URL entry). Abort, look - // up the category via a lightweight request, then redirect or resume. + // Already checked this topic recently and it wasn't nested — let it through const checkedAt = checkedTopicIds.get(topicId); if (checkedAt && Date.now() - checkedAt < CHECKED_TOPIC_TTL_MS) { return; @@ -236,14 +215,15 @@ export default apiInitializer((api) => { const fromRoute = router.currentRouteName; transition.abort(); - ajax(`/t/${topicId}.json`, { data: { track_visit: false } }) + ajax(`/nested/check/${topicId}.json`) .then((data) => { // Bail if user navigated away during the async lookup if (router.currentRouteName !== fromRoute) { return; } - if (isNestedDefault(siteSettings, data.category_id)) { + if (data.is_nested_view) { + nestedTopicIds.add(topicId); const queryParams = {}; const nearPost = transition.to?.params?.nearPost; if (nearPost) { diff --git a/assets/javascripts/discourse/connectors/topic-navigation/nested-view-link.gjs b/assets/javascripts/discourse/connectors/topic-navigation/nested-view-link.gjs index 487bc38..b393c3e 100644 --- a/assets/javascripts/discourse/connectors/topic-navigation/nested-view-link.gjs +++ b/assets/javascripts/discourse/connectors/topic-navigation/nested-view-link.gjs @@ -1,6 +1,5 @@ import Component from "@glimmer/component"; import getURL from "discourse/lib/get-url"; -import Category from "discourse/models/category"; import { i18n } from "discourse-i18n"; export default class NestedViewLink extends Component { @@ -13,18 +12,10 @@ export default class NestedViewLink extends Component { return true; } - if (context.siteSettings.nested_replies_default) { + if (args.topic?.is_nested_view) { return true; } - const categoryId = args.topic?.category_id; - if (categoryId) { - const category = Category.findById(categoryId); - if (category?.nested_replies_default) { - return true; - } - } - return false; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d442e1f..f00bb7d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -36,6 +36,9 @@ en: pinned_reply: "Pinned" pin_reply: "Pin to top" unpin_reply: "Unpin" + topic_admin_menu: + enable_nested_replies: "Enable nested replies" + disable_nested_replies: "Disable nested replies" context: banner: "You are viewing a single thread from this topic." view_full_topic: "View full topic" diff --git a/config/routes.rb b/config/routes.rb index af4cfec..0a0d4d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true DiscourseNestedReplies::Engine.routes.draw do + get "/nested/check/:topic_id" => "nested_topics#check", :constraints => { topic_id: /\d+/ } + scope "/nested/:slug/:topic_id", constraints: { topic_id: /\d+/ } do get "/" => "nested_topics#respond" get "/roots" => "nested_topics#roots" get "/children/:post_number" => "nested_topics#children" get "/context/:post_number" => "nested_topics#context" put "/pin" => "nested_topics#pin" + put "/toggle" => "nested_topics#toggle" end end diff --git a/plugin.rb b/plugin.rb index 6c3651b..3ae26ad 100644 --- a/plugin.rb +++ b/plugin.rb @@ -17,6 +17,7 @@ module ::DiscourseNestedReplies PLUGIN_NAME = "discourse-nested-replies" CATEGORY_DEFAULT_FIELD = "nested_replies_default_for_category" PINNED_POST_NUMBER_FIELD = "nested_replies_pinned_post_number" + TOPIC_NESTED_VIEW_FIELD = "nested" end require_relative "lib/discourse_nested_replies/engine" @@ -94,6 +95,35 @@ module ::DiscourseNestedReplies staff_only: true, ) + # --- Topic-level nested view field --- + # Each topic carries its own "nested" flag so the frontend can decide + # which view to render without looking up category settings every time. + register_topic_custom_field_type(DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD, :boolean) + add_preloaded_topic_list_custom_field(DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD) + + add_to_serializer(:topic_list_item, :is_nested_view) do + object.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] + end + + add_to_serializer(:topic_view, :is_nested_view) do + object.topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] + end + + # Auto-set the nested field on new topics when the category or global + # setting calls for nested view. + on(:topic_created) do |topic, _opts, _user| + next unless SiteSetting.nested_replies_enabled + + is_nested = + SiteSetting.nested_replies_default || + topic.category&.custom_fields&.dig(DiscourseNestedReplies::CATEGORY_DEFAULT_FIELD) + + if is_nested + topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] = true + topic.save_custom_fields + end + end + # --- Preserve ?post_number through URL canonicalization redirects --- register_modifier(:redirect_to_correct_topic_additional_query_parameters) do |params| params + %w[post_number] diff --git a/spec/plugin_spec.rb b/spec/plugin_spec.rb new file mode 100644 index 0000000..3add35b --- /dev/null +++ b/spec/plugin_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseNestedReplies do + fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } + fab!(:category) + fab!(:nested_category) { Fabricate(:category, name: "Nested Category") } + + before do + SiteSetting.nested_replies_enabled = true + nested_category.custom_fields[DiscourseNestedReplies::CATEGORY_DEFAULT_FIELD] = true + nested_category.save_custom_fields + end + + describe "topic_created event" do + it "sets the nested field when category has nested default enabled" do + post = + PostCreator.create!( + user, + title: "Test nested topic in category", + raw: "This is a test topic in a nested category", + category: nested_category.id, + ) + + expect(post.topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD]).to eq(true) + end + + it "sets the nested field when global nested_replies_default is enabled" do + SiteSetting.nested_replies_default = true + + post = + PostCreator.create!( + user, + title: "Test nested topic globally", + raw: "This is a test topic with global nested default", + category: category.id, + ) + + expect(post.topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD]).to eq(true) + end + + it "does not set the nested field for topics in regular categories" do + post = + PostCreator.create!( + user, + title: "Test normal topic", + raw: "This is a test topic in a regular category", + category: category.id, + ) + + expect(post.topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD]).to be_blank + end + + it "does not set the nested field when plugin is disabled" do + SiteSetting.nested_replies_enabled = false + + post = + PostCreator.create!( + user, + title: "Test topic plugin disabled", + raw: "This is a test topic with plugin disabled", + category: nested_category.id, + ) + + expect(post.topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD]).to be_blank + end + end + + describe "serialization" do + fab!(:topic) { Fabricate(:topic, user: user, category: nested_category) } + fab!(:op) { Fabricate(:post, topic: topic, user: user, post_number: 1) } + + before do + topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] = true + topic.save_custom_fields + end + + it "includes is_nested_view on TopicListItemSerializer" do + Topic.preload_custom_fields([topic], TopicList.preloaded_custom_fields) + + json = TopicListItemSerializer.new(topic, scope: Guardian.new(user), root: false).as_json + + expect(json[:is_nested_view]).to eq(true) + end + + it "includes is_nested_view on TopicViewSerializer" do + topic_view = TopicView.new(topic.id, user) + json = TopicViewSerializer.new(topic_view, scope: Guardian.new(user), root: false).as_json + + expect(json[:is_nested_view]).to eq(true) + end + + it "returns nil for is_nested_view when field is not set" do + topic.custom_fields.delete(DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD) + topic.save_custom_fields + + topic_view = TopicView.new(topic.id, user) + json = TopicViewSerializer.new(topic_view, scope: Guardian.new(user), root: false).as_json + + expect(json[:is_nested_view]).to be_nil + end + end +end diff --git a/spec/requests/nested_topics_controller_spec.rb b/spec/requests/nested_topics_controller_spec.rb index c51fabd..fdaa1d1 100644 --- a/spec/requests/nested_topics_controller_spec.rb +++ b/spec/requests/nested_topics_controller_spec.rb @@ -891,4 +891,78 @@ def pin_url(topic) end end end + + describe "GET check" do + def check_url(topic) + "/nested/check/#{topic.id}.json" + end + + it "returns true when topic has nested view enabled" do + topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] = true + topic.save_custom_fields + + sign_in(user) + get check_url(topic) + expect(response.status).to eq(200) + expect(response.parsed_body["is_nested_view"]).to eq(true) + end + + it "returns false when topic does not have nested view enabled" do + sign_in(user) + get check_url(topic) + expect(response.status).to eq(200) + expect(response.parsed_body["is_nested_view"]).to eq(false) + end + + it "returns 403 for a private topic the user cannot see" do + private_category = Fabricate(:private_category, group: Fabricate(:group)) + private_topic = Fabricate(:topic, category: private_category) + + sign_in(user) + get check_url(private_topic) + expect(response.status).to eq(403) + end + + it "returns 404 when plugin is disabled" do + SiteSetting.nested_replies_enabled = false + sign_in(user) + get check_url(topic) + expect(response.status).to eq(404) + end + end + + describe "PUT toggle" do + def toggle_url(topic) + "/nested/#{topic.slug}/#{topic.id}/toggle.json" + end + + it "returns 403 for non-staff users" do + sign_in(user) + put toggle_url(topic), params: { enabled: true } + expect(response.status).to eq(403) + end + + it "allows staff to enable nested view" do + sign_in(admin) + put toggle_url(topic), params: { enabled: true } + expect(response.status).to eq(200) + expect(response.parsed_body["is_nested_view"]).to eq(true) + + topic.reload + expect(topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD]).to eq(true) + end + + it "allows staff to disable nested view" do + topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] = true + topic.save_custom_fields + + sign_in(admin) + put toggle_url(topic), params: { enabled: false } + expect(response.status).to eq(200) + expect(response.parsed_body["is_nested_view"]).to eq(false) + + topic.reload + expect(topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD]).to eq(false) + end + end end diff --git a/spec/system/nested_category_default_spec.rb b/spec/system/nested_category_default_spec.rb index f06bc55..82d2ec5 100644 --- a/spec/system/nested_category_default_spec.rb +++ b/spec/system/nested_category_default_spec.rb @@ -20,6 +20,8 @@ SiteSetting.nested_replies_enabled = true nested_category.custom_fields[DiscourseNestedReplies::CATEGORY_DEFAULT_FIELD] = true nested_category.save_custom_fields + topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] = true + topic.save_custom_fields end describe "category settings UI" do diff --git a/spec/system/nested_topic_admin_toggle_spec.rb b/spec/system/nested_topic_admin_toggle_spec.rb new file mode 100644 index 0000000..c38c280 --- /dev/null +++ b/spec/system/nested_topic_admin_toggle_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "../support/nested_replies_helpers" + +RSpec.describe "Nested replies topic admin toggle" do + include NestedRepliesHelpers + + fab!(:admin) + fab!(:topic) { Fabricate(:topic, user: admin) } + fab!(:op) { Fabricate(:post, topic: topic, user: admin, post_number: 1) } + fab!(:reply) { Fabricate(:post, topic: topic, user: Fabricate(:user), raw: "A reply") } + + let(:topic_page) { PageObjects::Pages::Topic.new } + let(:nested_view) { PageObjects::Pages::NestedView.new } + + before do + SiteSetting.nested_replies_enabled = true + sign_in(admin) + end + + it "enables nested replies from the flat view and routes to nested" do + topic_page.visit_topic(topic) + + topic_page.click_admin_menu_button + find(".topic-admin-nested-replies").click + + expect(page).to have_current_path(%r{/nested/#{topic.slug}/#{topic.id}}) + expect(nested_view).to have_nested_view + + topic.reload + expect(topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD]).to eq(true) + end + + it "disables nested replies from the nested view and routes to flat" do + topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] = true + topic.save_custom_fields + + nested_view.visit_nested(topic) + nested_view.open_admin_menu + find(".topic-admin-nested-replies").click + + expect(page).to have_current_path(%r{/t/#{topic.slug}/#{topic.id}}) + expect(nested_view).to have_no_nested_view + + topic.reload + expect(topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD]).to eq(false) + end +end diff --git a/spec/system/view_as_nested_button_spec.rb b/spec/system/view_as_nested_button_spec.rb index 86b501b..5d5f1e9 100644 --- a/spec/system/view_as_nested_button_spec.rb +++ b/spec/system/view_as_nested_button_spec.rb @@ -19,6 +19,8 @@ SiteSetting.nested_replies_enabled = true nested_category.custom_fields[DiscourseNestedReplies::CATEGORY_DEFAULT_FIELD] = true nested_category.save_custom_fields + nested_topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] = true + nested_topic.save_custom_fields sign_in(user) end @@ -37,8 +39,9 @@ expect(nested_view).to have_view_as_nested_link end - it "still shows the link when nested_replies_default is enabled globally" do - SiteSetting.nested_replies_default = true + it "still shows the link when topic has is_nested_view field" do + topic.custom_fields[DiscourseNestedReplies::TOPIC_NESTED_VIEW_FIELD] = true + topic.save_custom_fields page.visit("/t/#{topic.slug}/#{topic.id}?flat=1")