diff --git a/app/controllers/discourse_mod_categories/messages_controller.rb b/app/controllers/discourse_mod_categories/messages_controller.rb index e5dac2b..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: @@ -358,24 +359,41 @@ def mark_topic_notifications_seen # Marks the current user's mod_note notifications whose `url` points # at /review/... as read. Called by the frontend whenever the user # navigates to /review or /review/:id — so flag_note / post_rejected - # notifications (which link to /review/:id rather than to a topic - # page) get marked read on direct navigation, not only via the bell- - # click or shield-tab-open paths. The data-column LIKE pins the - # update to mod_note rows whose URL starts with /review so we don't - # touch unrelated notifications. + # / post_approved notifications (which link to /review/:id rather + # than to a topic page) get marked read on direct navigation, not + # only via the bell-click or shield-tab-open paths. + # + # Scope rules: + # - `reviewable_id` param present → mark ONLY notifications whose + # data.url is exactly /review/ (or starts with /review// + # for sub-paths). Prevents clicking one queued-post notification + # from sweeping every other reviewable's notifications read. + # - param absent (visiting the /review index) → mark every + # /review-anchored mod_note row, matching the original behaviour + # since the staff member is viewing all reviewables at once. def mark_review_notifications_seen guardian.ensure_can_manage_mod_messages! - marked = - ::Notification - .where( - user_id: current_user.id, - notification_type: ::Notification.types[:custom], - read: false, + scope = + ::Notification.where( + user_id: current_user.id, + notification_type: ::Notification.types[:custom], + read: false, + ).where("data LIKE ?", "%\"mod_note\":true%") + + reviewable_id = params[:reviewable_id].to_s + if reviewable_id =~ /\A\d+\z/ + scope = + scope.where( + "data LIKE ? OR data LIKE ?", + "%\"url\":\"/review/#{reviewable_id}\"%", + "%\"url\":\"/review/#{reviewable_id}/%", ) - .where("data LIKE ?", "%\"mod_note\":true%") - .where("data LIKE ?", "%\"url\":\"/review%") - .update_all(read: true) + else + scope = scope.where("data LIKE ?", "%\"url\":\"/review%") + end + + marked = scope.update_all(read: true) current_user.publish_notifications_state if marked > 0 @@ -405,10 +423,19 @@ def notes_feed .limit(50) .pluck(:topic_id) + # `Topic.where(id: topic_ids)` doesn't preserve the topic_ids order + # we just computed (Postgres returns rows in id order, not the order + # of the IN-list), so index by id and re-iterate in topic_ids order. + # Critical when several notes share an iso8601 second — the combined + # recency sort below ties on identical timestamps, and Ruby's stable + # sort falls back to insertion order; without this, the panel + # surfaces oldest-first instead of newest-first. + topics_by_id = Topic.where(id: topic_ids).index_by(&:id) topic_notes = - Topic - .where(id: topic_ids) - .map do |topic| + topic_ids + .map do |id| + topic = topics_by_id[id] + next unless topic note = topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD].to_s next if note.blank? replies = topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD] @@ -427,8 +454,6 @@ def notes_feed } end .compact - .sort_by { |n| n[:activity_at].to_s } - .reverse # Non-topic-anchored event notifications: rows whose mod_note_kind # is NOT "note" or "reply" (those are already covered by the @@ -469,7 +494,21 @@ def notes_feed } end - render json: { notes: topic_notes + events } + # Merge both sources into a single recency-sorted list. Rows missing + # both timestamps fall to the bottom (sort tuple [1, 0]) regardless of + # how malformed the data is — rescue keeps a corrupt timestamp from + # 500-ing the feed. + combined = + (topic_notes + events).sort_by do |n| + ts = n[:activity_at].presence || n[:created_at].presence + begin + ts.present? ? [0, -Time.zone.parse(ts.to_s).to_i] : [1, 0] + rescue ArgumentError, TypeError + [1, 0] + end + end + + render json: { notes: combined } end # Marks the staff user's moderator-note feed as read. diff --git a/assets/javascripts/discourse/components/mod-notes-panel.gjs b/assets/javascripts/discourse/components/mod-notes-panel.gjs index 886428f..d3cc90c 100644 --- a/assets/javascripts/discourse/components/mod-notes-panel.gjs +++ b/assets/javascripts/discourse/components/mod-notes-panel.gjs @@ -55,6 +55,17 @@ export default class ModNotesPanel extends Component { @tracked notes = []; @tracked loading = true; + // Deep-link from the panel's "View more" link to the standard + // notifications page with our ?type=mod_notes filter pre-applied (see + // notifications-type-filter.js + the NotificationsController patch). + get viewMoreUrl() { + const username = this.currentUser?.username; + if (!username) { + return null; + } + return `/u/${username}/notifications?type=mod_notes`; + } + constructor() { super(...arguments); this.load(); @@ -114,6 +125,11 @@ export default class ModNotesPanel extends Component { {{/each}} + {{#if this.viewMoreUrl}} + + {{i18n "discourse_mod_categories.notes_tab.view_more"}} + + {{/if}} {{else}}
{{i18n "discourse_mod_categories.notes_tab.empty"}} diff --git a/assets/javascripts/discourse/components/notifications-type-filter.gjs b/assets/javascripts/discourse/components/notifications-type-filter.gjs new file mode 100644 index 0000000..2a3f364 --- /dev/null +++ b/assets/javascripts/discourse/components/notifications-type-filter.gjs @@ -0,0 +1,107 @@ +import Component from "@glimmer/component"; +import { hash } from "@ember/helper"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { i18n } from "discourse-i18n"; +import ComboBox from "select-kit/components/combo-box"; + +// Some plugin-defined notification types don't have a +// `notifications.titles.X` translation, in which case i18n() returns the +// bracketed placeholder "[en.notifications.titles.X]" — ugly inside a +// dropdown. discourse-i18n's default export is the `i18n` FUNCTION (not +// an I18n object with `.lookup`), so the cleanest probe is to call i18n +// and check for the bracket marker on the returned string. On a miss, +// fall back to a humanized version of the type name +// ("chat_group_mention" → "Chat group mention") so the row reads cleanly. +function nameForType(type) { + const value = i18n(`notifications.titles.${type}`); + if (value && !value.startsWith("[")) { + return value; + } + return type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +// Second filter dropdown rendered next to Discourse's built-in +// All / Read / Unread filter on the user notifications page. Options come +// from `site.notification_types` so we automatically stay in sync as +// Discourse (or any plugin) adds new types. The `mod_notes` pseudo-type +// is staff-only and is gated server-side too — see the NotificationsController +// patch in sub_plugins/mod_categories.rb. +const ALL = "all"; +const MOD_NOTES = "mod_notes"; + +export default class NotificationsTypeFilter extends Component { + @service router; + @service site; + @service currentUser; + + get options() { + const items = [ + { + id: ALL, + name: i18n("discourse_mod_categories.notification_type_filter.all"), + }, + ]; + + const types = this.site?.notification_types || {}; + Object.keys(types) + .sort() + .forEach((name) => { + items.push({ id: name, name: nameForType(name) }); + }); + + if (this.currentUser?.staff) { + items.push({ + id: MOD_NOTES, + name: i18n( + "discourse_mod_categories.notification_type_filter.mod_notes" + ), + }); + } + + return items; + } + + get selectedValue() { + // Read straight from window.location since `type` isn't a declared + // controller queryParam (see the long note in the initializer about + // why class-field queryParams can't be extended via api.modifyClass). + if (typeof window === "undefined") { + return ALL; + } + return new URLSearchParams(window.location.search).get("type") || ALL; + } + + @action + onChange(value) { + // Same reason — transitionTo({queryParams: {type: ...}}) silently + // drops `type` because Ember doesn't know about it. Mutate the URL + // directly and let the route's refreshModel logic re-fire via the + // URL change. Using router.transitionTo with the path+search string + // keeps the transition inside Ember (no full page reload). + const url = new URL(window.location.href); + if (value === ALL) { + url.searchParams.delete("type"); + } else { + url.searchParams.set("type", value); + } + this.router.transitionTo(url.pathname + url.search); + } + + +} 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/javascripts/discourse/initializers/mod-review-notifications-clear.js b/assets/javascripts/discourse/initializers/mod-review-notifications-clear.js index f72f057..a39c77c 100644 --- a/assets/javascripts/discourse/initializers/mod-review-notifications-clear.js +++ b/assets/javascripts/discourse/initializers/mod-review-notifications-clear.js @@ -1,19 +1,23 @@ import { withPluginApi } from "discourse/lib/plugin-api"; import { ajax } from "discourse/lib/ajax"; -const REVIEW_URL_RE = /^\/review(\/\d+)?(\?.*)?$/; +const REVIEW_URL_RE = /^\/review(?:\/(\d+))?(?:\?.*)?$/; // Marks the current user's mod_note notifications whose URL points at // /review/... as read whenever they navigate to /review or /review/:id. // // Without this, a staff member who lands on the review queue via a // bookmark, a direct URL paste, or a link from outside the bell drop- -// down would see the related flag_note / post_rejected notifications -// stay unread in the shield-tab and bell badge — only the bell-click -// path and the shield-tab-open path mark them read otherwise. +// down would see the related flag_note / post_rejected / post_approved +// notifications stay unread in the shield-tab and bell badge — only the +// bell-click path and the shield-tab-open path mark them read otherwise. // -// Backend filter pins this to mod_note rows whose `data.url` starts -// with /review, so we don't touch unrelated notifications. +// When the URL targets a specific reviewable (/review/123), the id is +// forwarded so the backend can scope mark-as-read to that ONE row. +// Without that scoping, clicking a single notification swept every +// other reviewable's notifications read too — the exact bug reported +// after the staff-streams rollout. Hitting /review (the index) keeps +// the broad sweep since the staff member is viewing everything at once. export default { name: "discourse-mod-review-notifications-clear", @@ -28,11 +32,15 @@ export default { if (typeof url !== "string") { return; } - if (!REVIEW_URL_RE.test(url)) { + const match = url.match(REVIEW_URL_RE); + if (!match) { return; } + const reviewableId = match[1]; + const data = reviewableId ? { reviewable_id: reviewableId } : {}; ajax("/discourse-mod-categories/review/notifications/seen", { type: "POST", + data, }).catch(() => { // Silent — marking-read on review-page open is best-effort. // A failure here just means the notification stays unread, diff --git a/assets/javascripts/discourse/initializers/notifications-type-filter.js b/assets/javascripts/discourse/initializers/notifications-type-filter.js new file mode 100644 index 0000000..d152d13 --- /dev/null +++ b/assets/javascripts/discourse/initializers/notifications-type-filter.js @@ -0,0 +1,69 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import NotificationsTypeFilter from "../components/notifications-type-filter"; + +// Adds a second filter dropdown to /u/{username}/notifications, sitting +// next to Discourse's existing All / Read / Unread filter. The dropdown +// sets ?type=, the route picks it up as a queryParam, and the +// server's NotificationsController patch (sub_plugins/mod_categories.rb) +// scopes the result set accordingly. Staff-only `mod_notes` is gated on +// both sides — the dropdown hides it for non-staff and the controller +// drops the filter silently if a non-staff user passes it via the URL. +export default { + name: "discourse-mod-notifications-type-filter", + + initialize() { + withPluginApi("1.8.0", (api) => { + // Renders directly inside `
`, + // right after Discourse's built-in NotificationsFilter — so the two + // dropdowns naturally sit side-by-side without any float / position + // gymnastics. Outlet confirmed against + // discourse/discourse:frontend/discourse/app/templates/user/notifications-index.gjs. + api.renderInOutlet( + "user-notifications-after-filter", + NotificationsTypeFilter + ); + + // The userNotifications.index route inherits both controllerName and + // model() from its parent `user-notifications` route, so that's where + // model() is actually defined — overriding the `.index` route does + // nothing because it has no model() of its own. See discourse/ + // discourse: frontend/discourse/app/routes/user-notifications.js. + // + // We deliberately do NOT try to declare `type` as a controller + // queryParam: Discourse's controller uses `queryParams = ["filter"]`, + // a class FIELD, and Ember's classic reopen() (which api.modifyClass + // uses under the hood) only patches prototype METHODS — it can't + // override class-field initializers, so `type` would be silently + // stripped from params before reaching model(). Instead we read + // `type` directly from window.location.search inside model(); the + // URL is the source of truth in either path (initial visit OR the + // dropdown's router.transitionTo, which updates the location). + // + // The server-side NotificationsController patch in + // sub_plugins/mod_categories.rb translates ?type=... into the + // existing filter_by_types mechanism or, for `mod_notes`, a custom + // scoped index. + api.modifyClass("route:user-notifications", { + pluginId: "discourse-mod-categories-notifications-filter", + + model(params) { + const username = this.modelFor("user").get("username"); + if ( + this.get("currentUser.username") !== username && + !this.get("currentUser.admin") + ) { + return; + } + const args = { username, filter: params.filter, limit: 60 }; + const urlType = new URLSearchParams(window.location.search).get( + "type" + ); + if (urlType && urlType !== "all") { + args.type = urlType; + } + return this.store.find("notification", args); + }, + }); + }); + }, +}; diff --git a/assets/stylesheets/mod-notes-panel.scss b/assets/stylesheets/mod-notes-panel.scss new file mode 100644 index 0000000..5ee1d61 --- /dev/null +++ b/assets/stylesheets/mod-notes-panel.scss @@ -0,0 +1,31 @@ +// Staff user-menu "Moderator notes" panel — keep the list scrollable so a +// large backlog doesn't blow out the user-menu height, and give the +// "View more" deep-link a sticky footer so it stays visible while the +// list scrolls. +.mod-notes-panel { + display: flex; + flex-direction: column; + + .mod-notes-list { + max-height: 60vh; + overflow-y: auto; + margin: 0; + } + + .mod-notes-view-more { + display: block; + text-align: center; + padding: 0.65em 1em; + border-top: 1px solid var(--primary-low); + color: var(--tertiary); + text-decoration: none; + font-size: var(--font-down-1); + background: var(--secondary); + + &:hover, + &:focus { + background: var(--primary-very-low); + text-decoration: underline; + } + } +} diff --git a/assets/stylesheets/notifications-type-filter.scss b/assets/stylesheets/notifications-type-filter.scss new file mode 100644 index 0000000..bb907c1 --- /dev/null +++ b/assets/stylesheets/notifications-type-filter.scss @@ -0,0 +1,41 @@ +// The new Type dropdown renders via the `user-notifications-after-filter` +// plugin outlet, which lives inside `.user-notifications-filter` right +// after Discourse's built-in All/Read/Unread dropdown. So a flex layout +// on the parent (which Discourse already has via NotificationsFilter) +// puts the two dropdowns side-by-side — we just need spacing + label +// styling that visually pairs with the existing filter. +.user-notifications-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1em; +} + +.notifications-type-filter { + display: inline-flex; + align-items: center; + gap: 0.5em; + + .filter-text { + color: var(--primary-medium); + font-weight: bold; + font-size: var(--font-down-1); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .notifications-type-filter-select.select-kit { + summary.select-kit-header { + border: 1px solid transparent; + background: transparent; + padding: 0.4em 0.65em; + + &:hover, + &:focus, + &[aria-expanded="true"] { + border-color: var(--primary-low); + background: var(--primary-very-low); + } + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index fa292db..0d55844 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -83,6 +83,11 @@ en: loading: Loading… empty: No moderator notes yet. on_target: 'on %{target}' + view_more: View all moderator notifications + notification_type_filter: + label: Type + all: All types + mod_notes: Moderator notes note_notification: '%{username} added a moderator note' note_notification_title: Moderator note note_reply_notification: '%{username} replied to a moderator note' diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index a2dcc2f..4080e3c 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -135,7 +135,15 @@ en: mod_notify_staff_on_post_actions: Notify staff on post actions mod_notify_staff_on_post_actions_description: When on, every other staff member gets a bell notification + live alert when a moderator - deletes a post, approves a queued post, or rejects a queued post. + deletes a post or rejects a queued post. Queued-post APPROVALS have + their own opt-in setting below (default off) since approvals are + routine and noisy. + mod_notify_staff_on_post_approved: Notify staff on queued-post approvals + mod_notify_staff_on_post_approved_description: When on, every other + staff member gets a bell notification + live alert when a moderator + approves a queued post. Off by default — approvals happen often and + most staff don't need a notification for each one. Independent from + the "post actions" setting above. mod_notify_staff_on_user_notes: Notify staff on user notes mod_notify_staff_on_user_notes_description: When on, every other staff member gets a bell notification + live alert when a moderator adds diff --git a/config/settings.yml b/config/settings.yml index ef6bc2d..995689e 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -154,6 +154,9 @@ jtech_mod: mod_notify_staff_on_post_actions: default: true client: false + mod_notify_staff_on_post_approved: + default: false + client: false mod_notify_staff_on_user_notes: default: true client: false diff --git a/lib/discourse_mod_categories/staff_notifier.rb b/lib/discourse_mod_categories/staff_notifier.rb index 1c67949..e4b9d22 100644 --- a/lib/discourse_mod_categories/staff_notifier.rb +++ b/lib/discourse_mod_categories/staff_notifier.rb @@ -129,7 +129,6 @@ def self.publish_alert( target_username: ) return if staff_user.suspended? - return unless staff_user.allow_live_notifications? i18n_args = { username: username } i18n_args[:topic] = topic_title if topic_title.present? @@ -146,11 +145,32 @@ def self.publish_alert( } payload[:topic_title] = topic_title if topic_title.present? - ::MessageBus.publish( - "/notification-alert/#{staff_user.id}", - payload, - user_ids: [staff_user.id], - ) + # In-tab live alert (only fires for users with a live connection). + if staff_user.allow_live_notifications? + ::MessageBus.publish( + "/notification-alert/#{staff_user.id}", + payload, + user_ids: [staff_user.id], + ) + end + + # Web Push (enqueued — fires even when the tab is closed/backgrounded, + # which is the whole reason a Firefox mod was missing these). Reuses + # core PostAlerter.push_notification so we inherit its full gate + # stack: do-not-disturb, plugin push_notification_filters, the + # subscription-existence check, the push_notification_time_window + # delay, and the :push_notification DiscourseEvent. Without this + # call the staff-event notifications only deliver in-tab via the + # MessageBus path above and never reach a closed browser. + if defined?(::PostAlerter) + begin + ::PostAlerter.push_notification(staff_user, payload) + rescue StandardError => e + ::Rails.logger.warn( + "[jtech-tools] staff_notifier push enqueue failed: #{e.class}: #{e.message}", + ) + end + end end # True when an identical-target mod_note notification was already 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/feature_screenshots_spec.rb b/spec/system/feature_screenshots_spec.rb index fd43c5b..198574e 100644 --- a/spec/system/feature_screenshots_spec.rb +++ b/spec/system/feature_screenshots_spec.rb @@ -638,4 +638,126 @@ def seed_panel_with_viewers(topic, viewers) sleep 0.3 shot("22_non_staff_composer_no_whisper_button") end + + # ────────────────────────────────────────────────────────────────────── + # Notifications-page Type filter (added by initializers/ + # notifications-type-filter.js) + the mod-notes panel's new "View more" + # link. Three captures: staff sees the dropdown with the "Moderator + # notes" option, regular user sees the dropdown WITHOUT it, and staff + # applying ?type=mod_notes lands on a list scoped to mod-note rows. + # ────────────────────────────────────────────────────────────────────── + + # Seeds a couple of mod-note custom notifications on the recipient, plus + # one ordinary "mentioned" notification so the ?type=mod_notes capture + # has something to filter OUT — the screenshot is only meaningful if the + # unfiltered view has noise to remove. + def seed_notifications_for(user, mod_note_count: 2) + topic = Fabricate(:topic, category: category, title: "Notifications filter seed topic") + Fabricate(:post, topic: topic, user: author, raw: "OP for the notifications seed.") + + mod_note_count.times do |i| + fab_mod_note_notification( + user: user, + topic: topic, + kind: "note", + excerpt: "Mod-note seed ##{i + 1}.", + ) + end + + Notification.create!( + notification_type: Notification.types[:mentioned], + user_id: user.id, + topic_id: topic.id, + post_number: 1, + high_priority: false, + data: { + topic_title: topic.title, + display_username: author.username, + original_post_id: topic.first_post.id, + original_post_type: 1, + original_username: author.username, + revision_number: nil, + }.to_json, + ) + end + + it "23. captures the staff notifications page with the Type filter (mod_notes visible)" do + seed_notifications_for(admin, mod_note_count: 2) + + sign_in(admin) + visit("/u/#{admin.username}/notifications") + expect(page).to have_css(".user-notifications-filter", wait: 15) + expect(page).to have_css(".notifications-type-filter", wait: 10) + # The dropdown must contain a "Moderator notes" option for staff. We + # open it before screenshotting so the option list is visible. + find(".notifications-type-filter .select-kit-header").click + expect(page).to have_css( + ".notifications-type-filter .select-kit-row[data-value='mod_notes']", + wait: 5, + ) + sleep 0.3 + shot("23_notifications_page_type_filter_staff") + end + + it "24. captures the regular-user notifications page (Type filter has NO mod_notes)" do + seed_notifications_for(stranger, mod_note_count: 0) + + sign_in(stranger) + visit("/u/#{stranger.username}/notifications") + expect(page).to have_css(".user-notifications-filter", wait: 15) + expect(page).to have_css(".notifications-type-filter", wait: 10) + find(".notifications-type-filter .select-kit-header").click + # The dropdown must NOT contain the mod_notes row for a non-staff + # user — the staff-only option is gated on currentUser.staff. + expect(page).to have_no_css( + ".notifications-type-filter .select-kit-row[data-value='mod_notes']", + wait: 5, + ) + sleep 0.3 + shot("24_notifications_page_type_filter_regular_user") + end + + it "25. captures the staff notifications page filtered to ?type=mod_notes" do + seed_notifications_for(admin, mod_note_count: 3) + + sign_in(admin) + visit("/u/#{admin.username}/notifications?type=mod_notes") + expect(page).to have_css(".user-notifications-filter", wait: 15) + # The MenuItem `
  • ` uses just the className from the notification + # model — `.notification.custom`, no .item prefix (verified against + # discourse/discourse:frontend/discourse/app/components/user-menu/ + # menu-item.gjs). Only the mod-note custom rows should render; the + # seeded "mentioned" row gets filtered out by the + # NotificationsController patch's data-LIKE scope. + expect(page).to have_css(".notification.custom", minimum: 3, wait: 10) + expect(page).to have_no_css(".notification.mentioned") + sleep 0.3 + shot("25_notifications_page_filtered_to_mod_notes") + end + + it "26. captures the staff mod-notes panel with the new View more footer link" do + 3.times do |i| + seed_topic_with_note( + title: "View-more demo triage topic #{i + 1}", + note: "Triage note #{i + 1} — needs follow-up.", + ) + end + + sign_in(admin) + visit("/") + expect(page).to have_css(".d-header", wait: 15) + find(".header-dropdown-toggle.current-user button", match: :first).click + expect(page).to have_css("#user-menu-button-discourse-mod-notes", wait: 15) + find("#user-menu-button-discourse-mod-notes").click + expect(page).to have_css(".mod-notes-panel .mod-notes-item", minimum: 3, wait: 15) + # The footer link is the deep-link into the notifications page with + # ?type=mod_notes pre-applied — wires #1 and #3 of this change set + # together. + expect(page).to have_css( + ".mod-notes-panel a.mod-notes-view-more[href*='type=mod_notes']", + wait: 5, + ) + sleep 0.3 + shot("26_mod_notes_panel_with_view_more_link") + end end 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 e7d2898..bf13b13 100644 --- a/sub_plugins/mod_categories.rb +++ b/sub_plugins/mod_categories.rb @@ -9,6 +9,8 @@ register_asset "stylesheets/topic-footer-message.scss" register_asset "stylesheets/whisper.scss" +register_asset "stylesheets/notifications-type-filter.scss" +register_asset "stylesheets/mod-notes-panel.scss" register_svg_icon "list-check" register_svg_icon "shield-halved" register_svg_icon "user-plus" @@ -53,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 } @@ -366,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 @@ -1077,12 +1109,24 @@ class Engine < ::Rails::Engine # version doesn't reliably invoke after_update callbacks. on(:reviewable_transitioned_to) do |status, reviewable| next unless SiteSetting.mod_categories_enabled - next unless SiteSetting.mod_notify_staff_on_post_actions next if reviewable.blank? # Only queued-post reviewables — flag/user reviewables transition # through this event too but have their own notification chain. next unless reviewable.type == "ReviewableQueuedPost" + # `:approved` and `:rejected` are gated on separate settings. + # Approvals are routine and noisy, so they have their own opt-in + # toggle (default off) — see config/settings.yml. Rejections and + # post deletes stay grouped under mod_notify_staff_on_post_actions. + case status + when :approved + next unless SiteSetting.mod_notify_staff_on_post_approved + when :rejected + next unless SiteSetting.mod_notify_staff_on_post_actions + else + next + end + kind, message_key, title_key, alert_key = case status when :approved @@ -1231,4 +1275,91 @@ class Engine < ::Rails::Engine end end end + + # 4. Second filter dropdown on /u/{username}/notifications. The frontend + # initializer (assets/javascripts/discourse/initializers/notifications-type-filter.js) + # sends ?type=. Two cases: + # - "mod_notes": needs a JSON-column LIKE filter, so we render the list + # ourselves. Gated to staff — non-staff silently get the unfiltered + # index so the URL can't leak staff-only data. + # - Any built-in Discourse notification type name: translate into the + # existing filter_by_types param so Discourse's index handler does + # the rest, keeping us forward-compatible with its query logic. + reloadable_patch do + module ::DiscourseModCategories + module NotificationsControllerTypeFilter + def index + requested_type = params[:type].to_s.strip + return super if requested_type.blank? || requested_type == "all" + + if requested_type == "mod_notes" + unless guardian.is_staff? + params.delete(:type) + return super + end + return render_mod_notes_index + end + + if ::Notification.types.key?(requested_type.to_sym) + params[:filter_by_types] = requested_type + end + params.delete(:type) + super + end + + private + + # Mirrors the response shape of NotificationsController#index's + # paginated branch (see discourse/discourse:app/controllers/ + # notifications_controller.rb) exactly — the Ember store.find + # adapter expects all four keys and the user-notifications page's + # load-more behaviour reads `load_more_notifications`. Returning + # only `{ notifications, total_rows_notifications }` was leaving + # the list empty in the JS layer. + def render_mod_notes_index + user = fetch_user_from_params + limit = 60 + offset = params[:offset].to_i + + scope = + ::Notification + .where(user_id: user.id) + .visible + .where(notification_type: ::Notification.types[:custom]) + .where("data LIKE ?", "%\"mod_note\":true%") + .includes(:topic) + .order(created_at: :desc) + + scope = scope.where(read: true) if params[:filter] == "read" + scope = scope.where(read: false) if params[:filter] == "unread" + + total = scope.dup.count + notifications = scope.offset(offset).limit(limit) + notifications = + ::Notification.filter_inaccessible_topic_notifications( + current_user.guardian, + notifications, + ) + notifications = ::Notification.filter_disabled_badge_notifications(notifications) + notifications = ::Notification.populate_acting_user(notifications) + + render_json_dump( + notifications: serialize_data(notifications, ::NotificationSerializer), + total_rows_notifications: total, + seen_notification_id: user.seen_notification_id, + load_more_notifications: + notifications_path( + username: user.username, + offset: offset + limit, + limit: limit, + filter: params[:filter], + type: "mod_notes", + ), + ) + end + end + end + + ::NotificationsController.prepend(::DiscourseModCategories::NotificationsControllerTypeFilter) + end end