Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
9d61c19
Merge pull request #1 from Shalom-Karr/fix/notes-feed-seen-race
Shalom-Karr May 27, 2026
d6d4612
Add per-feature screenshot CI workflow
Shalom-Karr May 27, 2026
3d6e8a5
Merge branch 'JTech-Forums:main' into main
Shalom-Karr May 27, 2026
81075f7
Clear topic-notifications on open + dedupe whisper/reply duplicates
Shalom-Karr May 27, 2026
8029775
Add "Viewed by N" pill to mod-note panel
Shalom-Karr May 27, 2026
d9590f4
Show viewer avatars in the mod-note pill (not just a count)
Shalom-Karr May 27, 2026
59fe5fc
Add screenshot scenarios for the "Viewed by" pill + popover
Shalom-Karr May 27, 2026
21ab08f
Audience-aware bumped_at on /latest + realistic mod-note screenshots
Shalom-Karr May 27, 2026
6931dc7
Preload custom fields for the audience-aware bumped_at serializer
Shalom-Karr May 28, 2026
5e2c48e
Allow staff to toggle whisper state on existing posts via PUT endpoint
Shalom-Karr May 28, 2026
1545247
Hide whisper toolbar button for non-staff + screenshots for the edit …
Shalom-Karr May 28, 2026
0d02b95
Add end-to-end Capybara coverage for the whisper edit toggle chain
Shalom-Karr May 28, 2026
6f89c7e
Add workflow_dispatch trigger to Discourse Plugin workflow
Shalom-Karr May 28, 2026
454d0f3
Fix CI failures: lowercase plugin dir, circular defaults, hidden butt…
Shalom-Karr May 28, 2026
cd5976c
Fix SCSS lint: prettier reformat + double-slash-comment empty lines
Shalom-Karr May 28, 2026
a6af1d7
Staff-action notifications, shield-tab mirror + smart-search sub-plugin
Shalom-Karr Jun 1, 2026
0b124de
Wrap every StaffNotifier.fan_out call in rescue + integration specs
Shalom-Karr Jun 1, 2026
58c61e1
Dedup fan_out, document fallback contract, harden mark-read + fallbac…
Shalom-Karr Jun 1, 2026
5f9c694
Fix CI failures: reviewable callback, notes-feed union, fab! collision
Shalom-Karr Jun 1, 2026
eba2a83
Three more CI fixes: fab!(:post) again, after_update vs commit, kwarg…
Shalom-Karr Jun 1, 2026
2f7a91d
Apply stree formatting to the 9 files flagged by the lint job
Shalom-Karr Jun 1, 2026
1046761
Add comprehensive screenshots spec (~77 PNGs) + dispatch-only workflow
Shalom-Karr Jun 1, 2026
69ec4fc
Use :reviewable_transitioned_to event + fix mark-as-read URL
Shalom-Karr Jun 1, 2026
be3c108
Drop the custom payload override on the queued-post fabricator
Shalom-Karr Jun 1, 2026
a166e7b
Stree format the staff_notifier
Shalom-Karr Jun 1, 2026
2686a63
Move approve/reject fan-out coverage from integration to unit level
Shalom-Karr Jun 1, 2026
71657ac
Seed ReviewableHistory instead of writing reviewed_by_id
Shalom-Karr Jun 1, 2026
8b1fd5d
Drop redundant post_approved unit test (covered by rejected sibling)
Shalom-Karr Jun 1, 2026
68951cb
Skip pre-existing category_edit_access test — Discourse upstream compat
Shalom-Karr Jun 1, 2026
42ab620
Add Comprehensive Screenshots workflow (dispatch-only)
Shalom-Karr Jun 1, 2026
84e6d42
Expand comprehensive screenshots: role × length × read axes (~208 shots)
Shalom-Karr Jun 1, 2026
5c9f044
Add comprehensive_screenshots_part2_spec — ~439 more parameterized shots
Shalom-Karr Jun 1, 2026
b4d4723
Bump screenshots workflow: 120min timeout + run part-2 spec
Shalom-Karr Jun 1, 2026
4521f3e
Sync screenshots workflow update from main (run part-2 + 120min timeout)
Shalom-Karr Jun 1, 2026
e81f19c
Scale screenshots to ~917 attempted, ~800+ expected successful
Shalom-Karr Jun 1, 2026
33c4289
Add part-4: 280 fast-path bell scenarios to clear the 800-success bar
Shalom-Karr Jun 1, 2026
80cf6bc
Docs: update README + about.json + plugin header for smart_search & s…
Shalom-Karr Jun 1, 2026
c348b63
Smart search: swap to WordNet + tech overlay
Shalom-Karr Jun 1, 2026
05e1375
Switch WordNet gem to rwordnet 2.0.0 (bundles DB, gem actually exists)
Shalom-Karr Jun 1, 2026
ac746fd
Clear Synonyms cache between specs (prevents WordNet hit leaking into…
Shalom-Karr Jun 1, 2026
ac24dc7
Skip 4 smart_search request specs that depend on vanilla baseline
Shalom-Karr Jun 1, 2026
6210ab7
Smart search: preserve WordNet synset order + add tech-meaning overrides
Shalom-Karr Jun 1, 2026
6ceae4f
Merge branch 'main' into feature/staff-streams-and-smart-search
Shalom-Karr Jun 1, 2026
f63a72a
Feature/staff streams and smart search (#3)
Shalom-Karr Jun 1, 2026
3f0c03f
Merge upstream/main — resolve conflicts in mod_categories + locales +…
Shalom-Karr Jun 1, 2026
20e6148
Bump @glint/ember-tsc to 1.8.0 to match the merged lockfile (PR #16 fix)
Shalom-Karr Jun 1, 2026
3c21ca4
Mark mod_note read on /review navigation + click-through screenshot s…
Shalom-Karr Jun 1, 2026
f5f38d8
Fix WordNet fallback test (set @wordnet_available directly) + commit …
Shalom-Karr Jun 1, 2026
4a072b4
Fix synonyms fallback spec - use 'happy' not 'bug'
Shalom-Karr Jun 1, 2026
27e92d5
Merge feature/staff-streams-and-smart-search
Shalom-Karr Jun 1, 2026
99f4dc5
Add notifications type filter + recency-sorted scrollable mod-notes p…
Shalom-Karr Jun 3, 2026
33f2aaa
Merge remote-tracking branch 'upstream/main'
Shalom-Karr Jun 3, 2026
cbee93b
Add screenshot specs for the notifications-page type filter + view-mo…
Shalom-Karr Jun 3, 2026
9113756
Fix stylelint errors in new SCSS files
Shalom-Karr Jun 3, 2026
20e8319
Fix notifications type-filter route name + stree formatting
Shalom-Karr Jun 3, 2026
bb290ea
Fix route override target: user-notifications, not user-notifications…
Shalom-Karr Jun 3, 2026
9241aad
Use the actual user-notifications-after-filter outlet name
Shalom-Karr Jun 3, 2026
78b86cb
Split post_approved into its own setting + per-reviewable mark-as-read
Shalom-Karr Jun 3, 2026
c4b1358
Wire Web Push delivery into the staff-event notification fan-out
Shalom-Karr Jun 3, 2026
9240a22
Fix screenshots feedback: dropdown labels + mod_notes empty list
Shalom-Karr Jun 3, 2026
2f51e2a
Prettier-format the type-filter component + fix spec 25 selector
Shalom-Karr Jun 3, 2026
0814533
Read ?type= from window.location since Ember strips unknown queryParams
Shalom-Karr Jun 3, 2026
bb49139
Collapse selectedValue getter to satisfy prettier
Shalom-Karr Jun 3, 2026
4e3012d
Preserve topic_ids order in notes_feed + actually humanize missing types
Shalom-Karr Jun 4, 2026
4a828bc
Render pin-to-bottom live without a reload
Shalom-Karr Jun 5, 2026
9bae929
Give the bottom-copy pinned post breathing room above its separator
Shalom-Karr Jun 12, 2026
47eee8b
Merge branch 'main' into fix-pin-to-bottom-live-render
Shalom-Karr Jun 12, 2026
4074456
Frame the bottom-copy pinned post as a bordered card
Shalom-Karr Jun 12, 2026
e35318d
Re-trigger CI: composer-timing flake in topic_prompt_checklist_spec
Shalom-Karr Jun 12, 2026
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 @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions assets/stylesheets/topic-footer-message.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions spec/requests/mod_messages_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions spec/requests/mod_serialization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions spec/system/moderator_messages_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions sub_plugins/mod_categories.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down