diff --git a/app/controllers/discourse_mod_categories/messages_controller.rb b/app/controllers/discourse_mod_categories/messages_controller.rb index 179c903..079bd0c 100644 --- a/app/controllers/discourse_mod_categories/messages_controller.rb +++ b/app/controllers/discourse_mod_categories/messages_controller.rb @@ -87,6 +87,7 @@ def update_topic reply_prompt: topic.custom_fields[TOPIC_REPLY_PROMPT_FIELD].to_s, reply_prompt_max_tl: topic.custom_fields[TOPIC_REPLY_PROMPT_TL_FIELD], pinned_post_id: topic.custom_fields[TOPIC_PINNED_POST_FIELD], + pinned_post: DiscourseModCategories.serialized_pinned_post(topic), require_reply_approval: !!topic.custom_fields[TOPIC_REQUIRE_REPLY_APPROVAL_FIELD], private_note: topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD].to_s, private_note_position: diff --git a/assets/javascripts/discourse/connectors/topic-area-bottom/topic-footer-message.gjs b/assets/javascripts/discourse/connectors/topic-area-bottom/topic-footer-message.gjs index 442f56c..a6338ce 100644 --- a/assets/javascripts/discourse/connectors/topic-area-bottom/topic-footer-message.gjs +++ b/assets/javascripts/discourse/connectors/topic-area-bottom/topic-footer-message.gjs @@ -40,6 +40,7 @@ export default class TopicFooterMessage extends Component { @tracked footerMessage = topicFooterMessage(this.topic); @tracked pinnedPostId = this.topic?.mod_topic_pinned_post_id || null; + @tracked pinnedPostPayload = this.topic?.mod_topic_pinned_post || null; @tracked cookedFooterMessage = null; constructor() { @@ -84,6 +85,7 @@ export default class TopicFooterMessage extends Component { readTopicState(topic) { this.footerMessage = topicFooterMessage(topic); this.pinnedPostId = topic?.mod_topic_pinned_post_id || null; + this.pinnedPostPayload = topic?.mod_topic_pinned_post || null; this.cookFooterMessage(); } @@ -106,10 +108,18 @@ export default class TopicFooterMessage extends Component { return this.cookedFooterMessage; } + // Prefer the topic-attached payload (serialized server-side and returned + // by the pin endpoint) so the bottom copy renders immediately, even when + // the pinned post lives outside the currently-loaded post-stream window. + // The `postStream.posts` lookup is the historical fallback — kept so a + // stale topic-view that predates the new field still renders. get pinnedPost() { if (!this.pinnedPostId) { return null; } + if (this.pinnedPostPayload?.id === this.pinnedPostId) { + return this.pinnedPostPayload; + } return ( this.topic?.postStream?.posts?.find((p) => p.id === this.pinnedPostId) || null diff --git a/assets/javascripts/discourse/initializers/mod-pin-post.js b/assets/javascripts/discourse/initializers/mod-pin-post.js index ba16d3d..2f7c815 100644 --- a/assets/javascripts/discourse/initializers/mod-pin-post.js +++ b/assets/javascripts/discourse/initializers/mod-pin-post.js @@ -43,6 +43,7 @@ export default { } ); topic?.set("mod_topic_pinned_post_id", result.pinned_post_id); + topic?.set("mod_topic_pinned_post", result.pinned_post || null); if (topic) { appEvents.trigger("discourse-mod:messages-updated", topic); // Re-render the stream so the in-stream pin badge appears diff --git a/assets/stylesheets/topic-footer-message.scss b/assets/stylesheets/topic-footer-message.scss index 569bc67..4b4ea2c 100644 --- a/assets/stylesheets/topic-footer-message.scss +++ b/assets/stylesheets/topic-footer-message.scss @@ -69,9 +69,11 @@ // The pinned post renders as a regular post at the bottom of the topic // (right after the post stream), not as a footer banner. .topic-footer-pinned-post { - margin: 0; - padding: 1em 0; - border-top: 1px solid var(--primary-low); + margin: 2em 0 0; + padding: 1em 1.25em; + border: 1px solid var(--primary-low); + border-radius: var(--d-border-radius, 0.25em); + background: var(--primary-very-low); .pinned-post { display: flex; diff --git a/spec/requests/mod_messages_spec.rb b/spec/requests/mod_messages_spec.rb index b01e8fe..8b4efb3 100644 --- a/spec/requests/mod_messages_spec.rb +++ b/spec/requests/mod_messages_spec.rb @@ -99,6 +99,35 @@ expect(topic.reload.custom_fields["mod_topic_pinned_post_id"]).to eq(first_post.id) end + it "returns the pinned post's render payload so the frontend can render without a reload" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + pinned_post_id: first_post.id, + } + + expect(response.status).to eq(200) + payload = response.parsed_body["pinned_post"] + expect(payload).to be_present + expect(payload["id"]).to eq(first_post.id) + expect(payload["post_number"]).to eq(first_post.post_number) + expect(payload["cooked"]).to eq(first_post.cooked) + expect(payload["username"]).to eq(first_post.user.username) + expect(payload["avatar_template"]).to eq(first_post.user.avatar_template) + end + + it "returns a null pinned_post when unpinning" do + topic.custom_fields["mod_topic_pinned_post_id"] = first_post.id + topic.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", params: { pinned_post_id: "" } + + expect(response.status).to eq(200) + expect(response.parsed_body["pinned_post"]).to be_nil + end + it "lets a moderator unpin by sending a blank pinned_post_id" do topic.custom_fields["mod_topic_pinned_post_id"] = first_post.id topic.save_custom_fields(true) diff --git a/spec/requests/mod_serialization_spec.rb b/spec/requests/mod_serialization_spec.rb index 9a170db..b017a49 100644 --- a/spec/requests/mod_serialization_spec.rb +++ b/spec/requests/mod_serialization_spec.rb @@ -29,6 +29,29 @@ expect(json["mod_topic_pinned_post_id"]).to eq(post.id) end + it "exposes the pinned post's render payload alongside the id" do + topic.custom_fields["mod_topic_pinned_post_id"] = post.id + topic.save_custom_fields(true) + + get "/t/#{topic.id}.json" + + expect(response.status).to eq(200) + payload = response.parsed_body["mod_topic_pinned_post"] + expect(payload).to be_present + expect(payload["id"]).to eq(post.id) + expect(payload["post_number"]).to eq(post.post_number) + expect(payload["cooked"]).to eq(post.cooked) + expect(payload["username"]).to eq(post.user.username) + expect(payload["avatar_template"]).to eq(post.user.avatar_template) + end + + it "returns a null mod_topic_pinned_post when no post is pinned" do + get "/t/#{topic.id}.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["mod_topic_pinned_post"]).to be_nil + end + it "leaves the topic fields nil when nothing has been set" do get "/t/#{topic.id}.json" @@ -37,6 +60,7 @@ expect(json["mod_topic_footer_message"]).to be_nil expect(json["mod_topic_reply_prompt"]).to be_nil expect(json["mod_topic_pinned_post_id"]).to be_nil + expect(json["mod_topic_pinned_post"]).to be_nil end it "exposes the category new-topic prompt in the categories list" do diff --git a/spec/system/moderator_messages_spec.rb b/spec/system/moderator_messages_spec.rb index 706967d..b2f6058 100644 --- a/spec/system/moderator_messages_spec.rb +++ b/spec/system/moderator_messages_spec.rb @@ -428,6 +428,15 @@ def visit_at(target) find(".mod-pin-post-to-bottom").click expect(page).to have_css(".topic-footer-pinned-post", wait: 10) + # The username and avatar in the bottom copy come from the server + # response payload (mod_topic_pinned_post), not from the loaded + # post-stream. Asserting they render confirms the live-render fix + # for the "footer empty until reload" bug. + expect(page).to have_css( + ".topic-footer-pinned-post .pinned-post-username", + text: post.user.username, + ) + expect(page).to have_css(".topic-footer-pinned-post .pinned-post-avatar") shot("26_post_pinned_to_bottom") expect(topic.reload.custom_fields["mod_topic_pinned_post_id"]).to be_present diff --git a/sub_plugins/mod_categories.rb b/sub_plugins/mod_categories.rb index d103845..bf13b13 100644 --- a/sub_plugins/mod_categories.rb +++ b/sub_plugins/mod_categories.rb @@ -55,6 +55,29 @@ module ::DiscourseModCategories TOPIC_PROMPT_CHECKLIST_FIELD = "mod_topic_prompt_checklist" USER_TOPIC_CHECKLIST_FIELD = "mod_topic_checklist_accepted" + # Render data for the topic's pinned-to-bottom post, or nil when the topic + # has no pinned post (or the pinned post has been deleted out from under + # the custom field). Shared by the `:mod_topic_pinned_post` serializer and + # the `update_topic` controller response so a freshly-pinned post renders + # the bottom copy live, without a page reload, even when the post isn't in + # the currently-loaded post-stream window. + def self.serialized_pinned_post(topic) + return nil unless topic + id = topic.custom_fields[TOPIC_PINNED_POST_FIELD] + return nil if id.blank? + post = topic.posts.find_by(id: id.to_i) + return nil unless post + user = post.user + { + id: post.id, + post_number: post.post_number, + cooked: post.cooked, + username: user&.username, + name: user&.name, + avatar_template: user&.avatar_template, + } + end + # The current checklist config, or nil when none is set. Shape: # { "version" => Integer, "items" => [{ "label" =>, "url" => }], # "updated_at" => ISO8601 String } @@ -368,6 +391,13 @@ class Engine < ::Rails::Engine add_to_serializer(:topic_view, :mod_topic_pinned_post_id) do object.topic.custom_fields[DiscourseModCategories::TOPIC_PINNED_POST_FIELD] end + # The pinned post's render data, attached to the topic so the bottom-copy + # connector renders without needing the post to be in the currently-loaded + # `postStream.posts` window — pinning a post far above the current scroll + # position would otherwise leave the footer blank until reload. + add_to_serializer(:topic_view, :mod_topic_pinned_post) do + DiscourseModCategories.serialized_pinned_post(object.topic) + end add_to_serializer(:topic_view, :mod_topic_require_reply_approval) do !!object.topic.custom_fields[DiscourseModCategories::TOPIC_REQUIRE_REPLY_APPROVAL_FIELD] end