Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
},
};
});
});
Original file line number Diff line number Diff line change
@@ -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+))?(?:\?(.*))?$/;
Expand All @@ -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;
Expand Down Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -143,26 +148,17 @@ 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);
}

return path;
});

// 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;

Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down
30 changes: 30 additions & 0 deletions plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]
Expand Down
Loading