diff --git a/.github/workflows/feature-screenshots.yml b/.github/workflows/feature-screenshots.yml
new file mode 100644
index 0000000..1f2f591
--- /dev/null
+++ b/.github/workflows/feature-screenshots.yml
@@ -0,0 +1,135 @@
+name: Feature Screenshots
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+
+# This workflow is a focused mirror of the `system_tests` job from
+# `discourse/.github/.github/workflows/discourse-plugin.yml` (the reusable
+# workflow used by `discourse-plugin.yml`), trimmed to run only the visual
+# feature-screenshot spec and to ALWAYS upload `tmp/capybara/feature_screenshots/*.png`
+# as a build artifact (not just on failure, the way the reusable workflow does it).
+
+jobs:
+ screenshots:
+ name: feature_screenshots
+ runs-on: ubuntu-latest
+ container: discourse/discourse_test:slim-browsers
+ timeout-minutes: 30
+
+ env:
+ RUBY_GLOBAL_METHOD_CACHE_SIZE: 131072
+ RAILS_ENV: test
+ PGUSER: discourse
+ PGPASSWORD: discourse
+ PLUGIN_NAME: ${{ github.event.repository.name }}
+ CHEAP_SOURCE_MAPS: "1"
+ MINIO_RUNNER_LOG_LEVEL: DEBUG
+ MINIO_RUNNER_INSTALL_DIR: /home/discourse/.minio_runner
+ USES_PARALLEL_DATABASES: "false"
+ PARALLEL_TEST_PROCESSORS: 1
+ LOAD_PLUGINS: 1
+ CAPYBARA_DEFAULT_MAX_WAIT_TIME: 10
+
+ steps:
+ - name: Set working directory owner
+ run: chown root:root .
+
+ - name: Checkout Discourse
+ uses: actions/checkout@v6
+ with:
+ repository: discourse/discourse
+ fetch-depth: 1
+ ref: latest
+
+ - name: Install plugin
+ uses: actions/checkout@v6
+ with:
+ path: plugins/${{ env.PLUGIN_NAME }}
+ fetch-depth: 1
+
+ - name: Setup Git
+ run: |
+ git config --global user.email "ci@ci.invalid"
+ git config --global user.name "Discourse CI"
+
+ - name: Start redis
+ run: redis-server /etc/redis/redis.conf &
+
+ - name: Start Postgres
+ run: |
+ chown -R postgres /var/run/postgresql
+ sudo -E -u postgres script/start_test_db.rb
+ sudo -u postgres psql -c "CREATE ROLE $PGUSER LOGIN SUPERUSER PASSWORD '$PGPASSWORD';"
+
+ - name: Container envs
+ id: container-envs
+ run: |
+ echo "ruby_version=$RUBY_VERSION" >> $GITHUB_OUTPUT
+ echo "debian_release=$DEBIAN_RELEASE" >> $GITHUB_OUTPUT
+
+ - name: Bundler cache
+ uses: actions/cache@v5
+ with:
+ path: vendor/bundle
+ key: ${{ runner.os }}-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-gem-${{ hashFiles('**/Gemfile.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-gem-
+
+ - name: Setup gems
+ run: |
+ bundle config --local path vendor/bundle
+ bundle config --local deployment true
+ bundle config --local without development
+ bundle install --jobs 4
+ bundle clean
+
+ - name: Install JS Dependencies
+ run: if [ -f yarn.lock ]; then yarn install --frozen-lockfile; else pnpm install --frozen-lockfile; fi
+
+ - name: Install playwright
+ run: |
+ if pnpm playwright -V; then
+ pnpm playwright install --with-deps --no-shell chromium
+ fi
+
+ - name: Create and migrate database
+ run: |
+ bin/rake db:create
+ bin/rake db:migrate
+
+ - name: Ember Build
+ env:
+ EMBER_ENV: development
+ run: |
+ if [ -f script/assemble_ember_build.rb ]; then
+ export DISCOURSE_DOWNLOAD_PRE_BUILT_ASSETS=$(bin/rails runner 'puts(Discourse.has_needed_version?(Discourse::VERSION::STRING, "2026.3.0-latest") ? 1 : 0)')
+ script/assemble_ember_build.rb
+ else
+ bin/ember-cli --build
+ fi
+
+ - name: Add hosts to /etc/hosts (Chrome can reach minio)
+ run: |
+ echo "127.0.0.1 minio.local" | sudo tee -a /etc/hosts
+ echo "127.0.0.1 discoursetest.minio.local" | sudo tee -a /etc/hosts
+
+ - name: Run feature-screenshot spec
+ run: |
+ bundle exec rspec \
+ --format documentation \
+ plugins/${{ env.PLUGIN_NAME }}/spec/system/feature_screenshots_spec.rb
+ continue-on-error: true
+
+ - name: Upload feature screenshots (always)
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: feature-screenshots
+ path: |
+ tmp/capybara/feature_screenshots/*.png
+ tmp/capybara/*.png
+ if-no-files-found: warn
diff --git a/app/controllers/discourse_mod_categories/messages_controller.rb b/app/controllers/discourse_mod_categories/messages_controller.rb
index 34120b8..0c51787 100644
--- a/app/controllers/discourse_mod_categories/messages_controller.rb
+++ b/app/controllers/discourse_mod_categories/messages_controller.rb
@@ -271,21 +271,37 @@ def notes_feed_seen
.update_all(read: true)
# Push the recalculated bell counts to every open tab so they refresh
- # in lockstep with the shield tab being opened.
+ # in lockstep with the shield tab being opened. This also drops the
+ # in-dropdown shield-tab pip, which now derives from unread Notification
+ # rows (the same source the bell uses), so it stays in lockstep with
+ # the bell badge without a dedicated MessageBus channel.
current_user.publish_notifications_state if marked > 0
- # Reset any other browser tabs/devices the staff member has open so
- # their header pip + title prefix clears in lockstep, not on the next
- # page load.
- MessageBus.publish(
- "/mod-note-unread-count/#{current_user.id}",
- { reset: true },
- user_ids: [current_user.id],
- )
-
render json: success_json
end
+ # Resolves a badge id to the current set of usernames who hold it.
+ # Used by the PM composer "Add badge group" button to splice badge
+ # holders into the standard `target_recipients` field — the PM is then
+ # sent through the normal PostCreator path with no further plugin code.
+ # Self is excluded (no point messaging yourself); the list is deduped.
+ def badge_members
+ guardian.ensure_can_send_private_messages!
+ badge = Badge.find_by(id: params[:badge_id])
+ raise Discourse::NotFound unless badge
+
+ usernames =
+ User
+ .joins(:user_badges)
+ .where(user_badges: { badge_id: badge.id })
+ .where(active: true)
+ .where.not(id: current_user.id)
+ .distinct
+ .pluck(:username)
+
+ render json: { usernames: usernames, badge: { id: badge.id, name: badge.display_name } }
+ end
+
# Adds a user to a topic's cumulative whisper conversation. From then on
# that user sees every whisper in the topic (both Guardian#can_see_post?
# and the topic-stream SQL filter grant visibility to participants).
@@ -387,22 +403,13 @@ def notify_staff_of_note(topic)
)
publish_note_alert(staff_user, topic, note, note_url)
- publish_unread_count_bump(staff_user)
+ # The standard /notifications poll picks up the new unread row so
+ # both the bell dot and the in-dropdown shield-tab pip refresh
+ # together. No separate /mod-note-unread-count channel is needed.
+ staff_user.publish_notifications_state
end
end
- # Publishes a small "+1" payload on a dedicated MessageBus channel the
- # header pip / title-prefix subscriber listens on. A separate channel
- # (independent of `/notification-alert/`) keeps the client-side reactivity
- # focused on the moderator-notes counter rather than the bell badge.
- def publish_unread_count_bump(staff_user)
- MessageBus.publish(
- "/mod-note-unread-count/#{staff_user.id}",
- { delta: 1 },
- user_ids: [staff_user.id],
- )
- end
-
# Fires the small live notification pop-up for one staff member. The
# payload mirrors `PostAlerter.create_notification_alert`, but carries an
# explicit `translated_title` so the pop-up text clearly names a
diff --git a/assets/javascripts/discourse/components/mod-note-header-pip.gjs b/assets/javascripts/discourse/components/mod-note-header-pip.gjs
deleted file mode 100644
index 99caeb0..0000000
--- a/assets/javascripts/discourse/components/mod-note-header-pip.gjs
+++ /dev/null
@@ -1,178 +0,0 @@
-import Component from "@glimmer/component";
-import { getOwner } from "@ember/owner";
-import { action } from "@ember/object";
-import { service } from "@ember/service";
-import didInsert from "@ember/render-modifiers/modifiers/did-insert";
-import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
-
-// Avatar-overlay indicator of the moderator-notes shield-tab's own unread
-// count. Injects a small badge directly onto the current-user avatar in
-// the page header, mirroring Discourse's native reviewables badge.
-//
-// The component itself renders an invisible placeholder; on insert it
-// finds the avatar element and appends a `.mod-note-avatar-pip` span to
-// it. A `MutationObserver` on `document.body` re-attaches if Discourse
-// re-renders the header. The count is held in sync via:
-// 1. A classic Ember property observer on the current user.
-// 2. A MessageBus subscription on `/mod-note-unread-count/{user_id}`.
-//
-// `pointer-events: none` on the badge lets clicks pass through to the
-// avatar — the user menu opens normally and exposes the shield tab.
-export default class ModNoteHeaderPip extends Component {
- @service currentUser;
-
- #onUserChange;
- #unsubscribe;
- #observer;
- #badge;
- #unreadCount = 0;
-
- get #avatarSelectors() {
- return [
- ".header-dropdown-toggle.current-user button",
- ".header-dropdown-toggle.current-user",
- ".header-dropdown-toggle__current-user button",
- ".header-dropdown-toggle__current-user",
- ];
- }
-
- #findAvatar() {
- for (const sel of this.#avatarSelectors) {
- const el = document.querySelector(sel);
- if (el) {
- return el;
- }
- }
- return null;
- }
-
- #ensureBadge() {
- const avatar = this.#findAvatar();
- if (!avatar) {
- return null;
- }
-
- // If the existing badge is still in the same avatar, reuse it.
- if (this.#badge && this.#badge.parentNode === avatar) {
- return this.#badge;
- }
-
- // Clean up any stale badge inside any avatar (e.g. after re-render).
- document
- .querySelectorAll(".mod-note-avatar-pip")
- .forEach((n) => n.remove());
-
- const span = document.createElement("span");
- span.className = "mod-note-avatar-pip";
- span.setAttribute("aria-hidden", "true");
-
- // Ensure the avatar can host an absolutely-positioned child.
- const cs = window.getComputedStyle(avatar);
- if (cs.position === "static") {
- avatar.style.position = "relative";
- }
-
- avatar.appendChild(span);
- this.#badge = span;
- return span;
- }
-
- #renderCount(n) {
- this.#unreadCount = Math.max(0, n | 0);
- const badge = this.#ensureBadge();
- if (!badge) {
- return;
- }
- if (this.#unreadCount > 0) {
- const label = this.#unreadCount > 9 ? "9+" : String(this.#unreadCount);
- badge.setAttribute("data-count", label);
- badge.classList.add("visible");
- } else {
- badge.removeAttribute("data-count");
- badge.classList.remove("visible");
- }
- }
-
- @action
- attach() {
- if (!this.currentUser?.staff) {
- return;
- }
-
- const initial = this.currentUser.mod_note_unread_count || 0;
- this.#renderCount(initial);
-
- this.#onUserChange = () => {
- this.#renderCount(this.currentUser?.mod_note_unread_count || 0);
- };
- if (typeof this.currentUser.addObserver === "function") {
- this.currentUser.addObserver("mod_note_unread_count", this.#onUserChange);
- }
-
- const messageBus = getOwner(this)?.lookup?.("service:message-bus");
- if (messageBus && typeof messageBus.subscribe === "function") {
- const channel = `/mod-note-unread-count/${this.currentUser.id}`;
- const handler = (payload) => {
- if (!payload) {
- return;
- }
- if (payload.reset) {
- this.currentUser?.set?.("mod_note_unread_count", 0);
- this.#renderCount(0);
- return;
- }
- if (typeof payload.delta === "number") {
- const next = Math.max(0, this.#unreadCount + payload.delta);
- this.currentUser?.set?.("mod_note_unread_count", next);
- this.#renderCount(next);
- }
- };
- messageBus.subscribe(channel, handler);
- this.#unsubscribe = () => {
- if (typeof messageBus.unsubscribe === "function") {
- messageBus.unsubscribe(channel, handler);
- }
- };
- }
-
- // Re-attach the badge if Discourse re-renders the header avatar.
- this.#observer = new MutationObserver(() => {
- const avatar = this.#findAvatar();
- if (!avatar) {
- return;
- }
- if (!this.#badge || this.#badge.parentNode !== avatar) {
- this.#renderCount(this.#unreadCount);
- }
- });
- this.#observer.observe(document.body, {
- childList: true,
- subtree: true,
- });
- }
-
- @action
- detach() {
- if (
- this.#onUserChange &&
- typeof this.currentUser?.removeObserver === "function"
- ) {
- this.currentUser.removeObserver(
- "mod_note_unread_count",
- this.#onUserChange
- );
- }
- this.#unsubscribe?.();
- this.#observer?.disconnect();
- this.#badge?.remove();
- this.#badge = null;
- }
-
-
-
-
-}
diff --git a/assets/javascripts/discourse/components/mod-pm-badge-picker.gjs b/assets/javascripts/discourse/components/mod-pm-badge-picker.gjs
new file mode 100644
index 0000000..ec233da
--- /dev/null
+++ b/assets/javascripts/discourse/components/mod-pm-badge-picker.gjs
@@ -0,0 +1,107 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { hash } from "@ember/helper";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import DModal from "discourse/components/d-modal";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import ComboBox from "select-kit/components/combo-box";
+import { i18n } from "discourse-i18n";
+
+// Picks a single badge, fetches the current holders' usernames, and
+// splices them into the PM composer's targetRecipients string (deduped,
+// comma-joined per Discourse convention). The PM is then sent through the
+// normal PostCreator path with that union as recipients — badge-grant
+// changes after send do NOT propagate, by design (a PM is a fixed-recipient
+// conversation).
+export default class ModPmBadgePicker extends Component {
+ @service store;
+
+ @tracked badgeChoices = [];
+ @tracked selectedBadgeId = null;
+ @tracked saving = false;
+
+ constructor() {
+ super(...arguments);
+ this.#loadBadges();
+ }
+
+ async #loadBadges() {
+ try {
+ const list = await this.store.findAll("badge");
+ this.badgeChoices = (list?.content || list || [])
+ .filter((b) => b?.enabled !== false)
+ .map((b) => ({ id: b.id, name: b.display_name || b.name }));
+ } catch (_e) {
+ this.badgeChoices = [];
+ }
+ }
+
+ @action
+ updateBadge(id) {
+ this.selectedBadgeId = id ? Number(id) : null;
+ }
+
+ @action
+ async confirm() {
+ const composer = this.args.model?.composer;
+ if (!composer || !this.selectedBadgeId) {
+ this.args.closeModal();
+ return;
+ }
+
+ this.saving = true;
+ try {
+ const data = await ajax(
+ `/discourse-mod-categories/badge-members/${this.selectedBadgeId}.json`
+ );
+ const newUsernames = Array.isArray(data?.usernames) ? data.usernames : [];
+
+ const current = (composer.targetRecipients || "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const merged = [...new Set([...current, ...newUsernames])];
+ composer.set("targetRecipients", merged.join(","));
+
+ this.args.closeModal();
+ } catch (e) {
+ popupAjaxError(e);
+ } finally {
+ this.saving = false;
+ }
+ }
+
+
+
+ <:body>
+ {{i18n "discourse_mod_categories.pm_badge.modal_instructions"}}
+
+
+ <:footer>
+
+
+
+
+}
diff --git a/assets/javascripts/discourse/components/mod-whisper-target-modal.gjs b/assets/javascripts/discourse/components/mod-whisper-target-modal.gjs
index 8cb710e..f31f8d9 100644
--- a/assets/javascripts/discourse/components/mod-whisper-target-modal.gjs
+++ b/assets/javascripts/discourse/components/mod-whisper-target-modal.gjs
@@ -2,22 +2,34 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
+import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import EmailGroupUserChooser from "discourse/select-kit/components/email-group-user-chooser";
+import MultiSelect from "select-kit/components/multi-select";
import { i18n } from "discourse-i18n";
// Staff-facing modal (opened from the composer toolbar eye button) for
-// picking the users AND groups a whisper reply should be visible to. Writes
-// the chosen ids/usernames/group ids/group names/objects onto the composer
-// model. The chooser returns a flat array mixing usernames and group names;
-// `confirm` resolves each entry to either a user id or a group id.
+// picking the users, groups, AND badges a whisper reply should be visible
+// to. Writes the chosen ids/usernames/group ids/group names/badge ids/
+// badge names onto the composer model. The user+group chooser returns a
+// flat array mixing usernames and group names; `confirm` resolves each
+// entry to either a user id or a group id. Badge selection is independent.
export default class ModWhisperTargetModal extends Component {
+ @service store;
+
@tracked selection = this.#initialSelection();
+ @tracked selectedBadgeIds = this.#initialBadgeIds();
+ @tracked badgeChoices = [];
@tracked saving = false;
+ constructor() {
+ super(...arguments);
+ this.#loadBadges();
+ }
+
#initialSelection() {
const composer = this.args.model?.composer;
const usernames = Array.isArray(composer?.modWhisperTargetUsernames)
@@ -29,11 +41,35 @@ export default class ModWhisperTargetModal extends Component {
return [...usernames, ...groupNames];
}
+ #initialBadgeIds() {
+ const composer = this.args.model?.composer;
+ const ids = Array.isArray(composer?.modWhisperTargetBadgeIds)
+ ? composer.modWhisperTargetBadgeIds
+ : [];
+ return ids.map((n) => Number(n)).filter((n) => Number.isInteger(n));
+ }
+
+ async #loadBadges() {
+ try {
+ const list = await this.store.findAll("badge");
+ this.badgeChoices = (list?.content || list || [])
+ .filter((b) => b?.enabled !== false)
+ .map((b) => ({ id: b.id, name: b.display_name || b.name }));
+ } catch (_e) {
+ this.badgeChoices = [];
+ }
+ }
+
@action
updateSelection(names) {
this.selection = names;
}
+ @action
+ updateBadgeSelection(ids) {
+ this.selectedBadgeIds = (ids || []).map((n) => Number(n));
+ }
+
@action
async confirm() {
const composer = this.args.model?.composer;
@@ -42,7 +78,10 @@ export default class ModWhisperTargetModal extends Component {
return;
}
- if (!this.selection.length) {
+ const badgeIds = this.selectedBadgeIds.slice();
+ const badges = this.badgeChoices.filter((b) => badgeIds.includes(b.id));
+
+ if (!this.selection.length && !badgeIds.length) {
// An empty selection still ARMS a whisper — a staff-only whisper-back.
composer.set("modWhisperArmed", true);
composer.set("modWhisperTargetUserIds", []);
@@ -51,6 +90,23 @@ export default class ModWhisperTargetModal extends Component {
composer.set("modWhisperTargetGroupIds", []);
composer.set("modWhisperTargetGroupNames", []);
composer.set("modWhisperTargetGroups", []);
+ composer.set("modWhisperTargetBadgeIds", []);
+ composer.set("modWhisperTargetBadges", []);
+ this.args.closeModal();
+ return;
+ }
+
+ if (!this.selection.length && badgeIds.length) {
+ // Badge-only audience — no user or group lookups needed.
+ composer.set("modWhisperArmed", true);
+ composer.set("modWhisperTargetUserIds", []);
+ composer.set("modWhisperTargetUsernames", []);
+ composer.set("modWhisperTargets", []);
+ composer.set("modWhisperTargetGroupIds", []);
+ composer.set("modWhisperTargetGroupNames", []);
+ composer.set("modWhisperTargetGroups", []);
+ composer.set("modWhisperTargetBadgeIds", badgeIds);
+ composer.set("modWhisperTargetBadges", badges);
this.args.closeModal();
return;
}
@@ -119,6 +175,9 @@ export default class ModWhisperTargetModal extends Component {
groups.map((g) => ({ id: g.id, name: g.name }))
);
+ composer.set("modWhisperTargetBadgeIds", badgeIds);
+ composer.set("modWhisperTargetBadges", badges);
+
this.args.closeModal();
} catch (e) {
popupAjaxError(e);
@@ -138,6 +197,8 @@ export default class ModWhisperTargetModal extends Component {
composer.set("modWhisperTargetGroupIds", null);
composer.set("modWhisperTargetGroupNames", null);
composer.set("modWhisperTargetGroups", null);
+ composer.set("modWhisperTargetBadgeIds", null);
+ composer.set("modWhisperTargetBadges", null);
}
this.args.closeModal();
}
@@ -161,6 +222,25 @@ export default class ModWhisperTargetModal extends Component {
filterPlaceholder="discourse_mod_categories.whisper.search_placeholder"
}}
/>
+
+ {{#if this.badgeChoices.length}}
+
+ {{i18n "discourse_mod_categories.whisper.modal_badge_instructions"}}
+
+
+ {{/if}}
<:footer>
0`, regardless of
-// whether the user menu is open. The `before-header-panel-outlet` outlet
-// sits inside the header just before the user-menu panel, so the pip lines
-// up alongside the existing notification icons.
-
diff --git a/assets/javascripts/discourse/connectors/composer-fields/mod-pm-badge-group.gjs b/assets/javascripts/discourse/connectors/composer-fields/mod-pm-badge-group.gjs
new file mode 100644
index 0000000..48100e6
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/composer-fields/mod-pm-badge-group.gjs
@@ -0,0 +1,47 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import ModPmBadgePicker from "../../components/mod-pm-badge-picker";
+
+// "Add badge group" button rendered inside the composer-fields outlet
+// whenever the composer is in private-message mode. Opens a modal that
+// resolves a chosen badge to its current holders' usernames and splices
+// them into the standard target_recipients field. From that point the PM
+// is sent through Discourse's normal PostCreator path with no further
+// plugin code — the audience is the snapshot of holders at send time.
+export default class ModPmBadgeGroup extends Component {
+ @service modal;
+
+ get composer() {
+ return this.args.outletArgs?.model;
+ }
+
+ get show() {
+ const composer = this.composer;
+ if (!composer) {
+ return false;
+ }
+ return !!composer.privateMessage;
+ }
+
+ @action
+ open() {
+ const composer = this.composer;
+ if (!composer) {
+ return;
+ }
+ this.modal.show(ModPmBadgePicker, { model: { composer } });
+ }
+
+
+ {{#if this.show}}
+
+ {{/if}}
+
+}
diff --git a/assets/javascripts/discourse/connectors/composer-fields/mod-whisper-armed-pill.gjs b/assets/javascripts/discourse/connectors/composer-fields/mod-whisper-armed-pill.gjs
index 1a5d86d..b2434e6 100644
--- a/assets/javascripts/discourse/connectors/composer-fields/mod-whisper-armed-pill.gjs
+++ b/assets/javascripts/discourse/connectors/composer-fields/mod-whisper-armed-pill.gjs
@@ -31,7 +31,11 @@ export default class ModWhisperArmedPill extends Component {
}
get staffOnly() {
- return this.usernames.length === 0 && this.groupNames.length === 0;
+ return (
+ this.usernames.length === 0 &&
+ this.groupNames.length === 0 &&
+ this.badges.length === 0
+ );
}
get usernames() {
@@ -51,6 +55,18 @@ export default class ModWhisperArmedPill extends Component {
return this.usernames.length > 0 || groupIndex > 0;
}
+ get badges() {
+ const composer = this.composer;
+ return composer ? get(composer, "modWhisperTargetBadges") || [] : [];
+ }
+
+ @action
+ needsBadgeSep(badgeIndex) {
+ return (
+ this.usernames.length > 0 || this.groupNames.length > 0 || badgeIndex > 0
+ );
+ }
+
@action
clearArmed() {
const composer = this.composer;
@@ -64,6 +80,8 @@ export default class ModWhisperArmedPill extends Component {
composer.set("modWhisperTargetGroupIds", null);
composer.set("modWhisperTargetGroupNames", null);
composer.set("modWhisperTargetGroups", null);
+ composer.set("modWhisperTargetBadgeIds", null);
+ composer.set("modWhisperTargetBadges", null);
}
@@ -91,6 +109,13 @@ export default class ModWhisperArmedPill extends Component {
class="mod-whisper-armed-pill__group"
>{{name}}
{{/each}}
+ {{#each this.badges as |badge index|}}
+ {{#if (this.needsBadgeSep index)}}, {{/if}}{{badge.name}}
+ {{/each}}
{{/if}}
title");
-
- // Reapply the prefix to the current `document.title` based on `lastCount`.
- // Guarded so the MutationObserver below doesn't recurse: if the title
- // is already in its desired state we don't touch it.
- let applying = false;
- const reapply = () => {
- if (applying) {
- return;
- }
- const current = document.title;
- const next = applyUnreadPrefix(stripUnreadPrefix(current), lastCount);
- if (next !== current) {
- applying = true;
- try {
- document.title = next;
- } finally {
- applying = false;
- }
- }
- };
-
- // Whenever something (Discourse's document-title service, a route
- // transition, etc.) rewrites the node, reassert the prefix.
- if (titleEl && typeof MutationObserver !== "undefined") {
- const observer = new MutationObserver(reapply);
- observer.observe(titleEl, {
- childList: true,
- characterData: true,
- subtree: true,
- });
- }
-
- const recompute = () => {
- lastCount = currentUser.mod_note_unread_count || 0;
- reapply();
- };
-
- // Apply once at boot so an initial unread count prefixes the title
- // without waiting for the next route transition.
- recompute();
-
- // Reactive bridge: a property observer on the User model picks up
- // every mutation — the panel's `set("mod_note_unread_count", 0)`
- // after `notes-feed/seen`, the header pip's MessageBus handler, etc.
- if (typeof currentUser.addObserver === "function") {
- currentUser.addObserver("mod_note_unread_count", recompute);
- }
-
- // Independent MessageBus subscription so the title prefix updates
- // even if the pip isn't currently mounted (e.g. count was 0 at boot).
- if (messageBus && typeof messageBus.subscribe === "function") {
- messageBus.subscribe(
- `/mod-note-unread-count/${currentUser.id}`,
- (payload) => {
- if (!payload) {
- return;
- }
- if (payload.reset) {
- currentUser.set?.("mod_note_unread_count", 0);
- recompute();
- return;
- }
- if (typeof payload.delta === "number") {
- const next =
- (currentUser.mod_note_unread_count || 0) + payload.delta;
- currentUser.set?.("mod_note_unread_count", Math.max(0, next));
- recompute();
- }
- }
- );
- }
-
- withPluginApi("1.0", () => {
- // Reserved for future hooks (e.g. additional reactive bindings on the
- // header service when its API stabilizes). The initializer itself
- // doesn't need a plugin-api capture today.
- });
- },
-};
diff --git a/assets/javascripts/discourse/initializers/mod-whisper.js b/assets/javascripts/discourse/initializers/mod-whisper.js
index 9a38500..63ff28f 100644
--- a/assets/javascripts/discourse/initializers/mod-whisper.js
+++ b/assets/javascripts/discourse/initializers/mod-whisper.js
@@ -61,6 +61,8 @@ export default {
model.set("modWhisperTargetGroupIds", []);
model.set("modWhisperTargetGroupNames", []);
model.set("modWhisperTargetGroups", []);
+ model.set("modWhisperTargetBadgeIds", []);
+ model.set("modWhisperTargetBadges", []);
}
// Non-participant: no-op.
},
@@ -77,6 +79,11 @@ export default {
"modWhisperTargetGroupIds"
);
+ api.serializeOnCreate(
+ "mod_whisper_target_badge_ids",
+ "modWhisperTargetBadgeIds"
+ );
+
// A boolean armed flag survives form-encoding even when the target id
// array is empty (a staff-only whisper, or a non-staff whisper-back).
// It is the server's single signal that a whisper is intended.
@@ -93,6 +100,8 @@ export default {
"mod_whisper_targets",
"mod_whisper_target_group_ids",
"mod_whisper_target_groups",
+ "mod_whisper_target_badge_ids",
+ "mod_whisper_target_badges",
"mod_whisper_is_staff_only",
"mod_whisper_author_is_staff"
);
@@ -103,6 +112,8 @@ export default {
"mod_whisper_targets",
"mod_whisper_target_group_ids",
"mod_whisper_target_groups",
+ "mod_whisper_target_badge_ids",
+ "mod_whisper_target_badges",
"mod_whisper_is_staff_only",
"mod_whisper_author_is_staff"
);
@@ -121,7 +132,11 @@ export default {
const targetGroups = Array.isArray(post.mod_whisper_target_groups)
? post.mod_whisper_target_groups
: [];
- const staffOnly = !targets.length && !targetGroups.length;
+ const targetBadges = Array.isArray(post.mod_whisper_target_badges)
+ ? post.mod_whisper_target_badges
+ : [];
+ const staffOnly =
+ !targets.length && !targetGroups.length && !targetBadges.length;
// Mark the cooked element itself — a marker on the post
// does not survive Glimmer post-stream reconciliation. SCSS tints
@@ -198,6 +213,15 @@ export default {
link.textContent = g.name;
banner.appendChild(link);
});
+
+ targetBadges.forEach((b) => {
+ addSep();
+ const link = document.createElement("a");
+ link.className = "mod-whisper-banner__badge";
+ link.href = `/badges/${b.id}`;
+ link.textContent = b.name;
+ banner.appendChild(link);
+ });
}
cookedEl.insertBefore(banner, cookedEl.firstChild);
@@ -221,11 +245,14 @@ export default {
}
if (currentUser.staff) {
- // Carry forward the original whisper's group targets so a staff
- // reply stays visible to the same group audience.
+ // Carry forward the original whisper's group AND badge targets so
+ // a staff reply stays visible to the same audience.
const replyGroups = Array.isArray(post.mod_whisper_target_groups)
? post.mod_whisper_target_groups
: [];
+ const replyBadges = Array.isArray(post.mod_whisper_target_badges)
+ ? post.mod_whisper_target_badges
+ : [];
model.set("modWhisperArmed", true);
model.set(
"modWhisperTargetGroupIds",
@@ -236,6 +263,11 @@ export default {
replyGroups.map((g) => g.name)
);
model.set("modWhisperTargetGroups", replyGroups);
+ model.set(
+ "modWhisperTargetBadgeIds",
+ replyBadges.map((b) => b.id)
+ );
+ model.set("modWhisperTargetBadges", replyBadges);
const replyAudience = computeReplyAudience(post, currentUser.id);
if (replyAudience.length) {
@@ -262,6 +294,8 @@ export default {
model.set("modWhisperTargetGroupIds", []);
model.set("modWhisperTargetGroupNames", []);
model.set("modWhisperTargetGroups", []);
+ model.set("modWhisperTargetBadgeIds", []);
+ model.set("modWhisperTargetBadges", []);
}
});
});
diff --git a/assets/javascripts/discourse/lib/mod-note-unread-title.js b/assets/javascripts/discourse/lib/mod-note-unread-title.js
deleted file mode 100644
index 51a929f..0000000
--- a/assets/javascripts/discourse/lib/mod-note-unread-title.js
+++ /dev/null
@@ -1,25 +0,0 @@
-// Pure helpers for the moderator-notes browser-tab title prefix.
-//
-// `applyUnreadPrefix(title, count)` mirrors how the bell prefixes the
-// document title with `(N)` when there are unread items. It is intentionally
-// idempotent: any existing `(N)` prefix is stripped before a new one is
-// applied, so repeated calls (or a count that ticks back down to zero) leave
-// the title in the right shape.
-
-const PREFIX_RE = /^\(\d+\)\s+/;
-
-// Returns the title with an existing `(N)` prefix removed, if any.
-export function stripUnreadPrefix(title) {
- return (title || "").replace(PREFIX_RE, "");
-}
-
-// Returns `(count) title` when count > 0, else the bare title. The input
-// title is first stripped of any prefix so repeated calls do not stack.
-export function applyUnreadPrefix(title, count) {
- const base = stripUnreadPrefix(title);
- const n = Math.max(0, parseInt(count, 10) || 0);
- if (n <= 0) {
- return base;
- }
- return `(${n}) ${base}`;
-}
diff --git a/assets/stylesheets/mod-note-header-pip.scss b/assets/stylesheets/mod-note-header-pip.scss
deleted file mode 100644
index 182a02a..0000000
--- a/assets/stylesheets/mod-note-header-pip.scss
+++ /dev/null
@@ -1,34 +0,0 @@
-// Small overlay badge injected onto the current-user avatar in the page
-// header. Mirrors Discourse's native unread-reviewables / notifications
-// badge: a coloured circle in the corner of the avatar with a count
-// inside. Hidden unless `.visible` is present (count > 0).
-.mod-note-avatar-pip {
- position: absolute;
- top: -4px;
- right: -4px;
- min-width: 16px;
- height: 16px;
- padding: 0 4px;
- box-sizing: border-box;
- border-radius: 999px;
- background: var(--tertiary);
- border: 2px solid var(--header_background, var(--secondary));
- color: var(--secondary, #fff);
- font-size: 10px;
- font-weight: 700;
- line-height: 12px;
- text-align: center;
- pointer-events: none;
- z-index: 2;
- display: none;
-
- &.visible {
- display: inline-block;
- }
-
- &::before {
- content: attr(data-count);
- display: inline-block;
- line-height: 12px;
- }
-}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 0f32961..b2687b0 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -148,10 +148,14 @@ en:
jump_to_original: Go to the original post
whisper:
toolbar_title: Whisper this reply
- modal_title: Whisper to users or groups
+ modal_title: Whisper to users, groups, or badges
modal_instructions: Choose up to 10 users or groups who should see this reply.
Staff always see whispers.
+ modal_badge_instructions: Optionally also include everyone who currently holds one of these badges.
+ Membership is checked when the post is viewed, so future grants and
+ revokes update the audience automatically.
search_placeholder: Search for a user or group…
+ badge_search_placeholder: Search for a badge…
confirm: Whisper
clear: Clear
armed_pill_prefix: Whispering to
@@ -171,6 +175,15 @@ en:
added_toast:
one: Added %{count} user to the whisper conversation
other: Added %{count} users to the whisper conversation
+ pm_badge:
+ button: Add badge group
+ modal_title: Add badge holders as recipients
+ modal_instructions: Pick a badge. Every user who currently holds it
+ will be added as a recipient. The list is captured at send time,
+ so future badge changes don't affect this conversation.
+ search_placeholder: Search for a badge…
+ none: Choose a badge…
+ confirm: Add recipients
dumbcourse:
sidebar_section_title: Dumbcourse
sidebar_link_text: Dumbcourse
diff --git a/config/routes.rb b/config/routes.rb
index 8c1aa6f..ed74c15 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -32,6 +32,7 @@
delete "/topic/:topic_id/note-reply" => "messages#delete_note_reply"
delete "/topic/:topic_id/note" => "messages#delete_note"
post "/topic/:topic_id/whisper-participant" => "messages#add_whisper_participant"
+ get "/badge-members/:badge_id" => "messages#badge_members"
get "/notes-feed" => "messages#notes_feed"
post "/notes-feed/seen" => "messages#notes_feed_seen"
get "/checklist" => "checklist#show"
diff --git a/lib/discourse_mod_categories/guardian_extensions.rb b/lib/discourse_mod_categories/guardian_extensions.rb
index 46b1d76..cb6171c 100644
--- a/lib/discourse_mod_categories/guardian_extensions.rb
+++ b/lib/discourse_mod_categories/guardian_extensions.rb
@@ -57,6 +57,12 @@ def can_see_post?(post)
if target_group_ids.any? && ::GroupUser.exists?(group_id: target_group_ids, user_id: @user.id)
return super
end
+ # Holders of any explicit target badge see it (membership evaluated
+ # lazily — a later grant brings the user into the audience).
+ target_badge_ids = mod_whisper_target_badge_ids(post)
+ if target_badge_ids.any? && ::UserBadge.exists?(badge_id: target_badge_ids, user_id: @user.id)
+ return super
+ end
# Cumulative topic whisper participants see it.
return super if mod_whisper_participant_ids(post.topic).include?(@user.id)
@@ -90,6 +96,13 @@ def mod_whisper_target_group_ids(post)
.reject { |id| id <= 0 }
end
+ def mod_whisper_target_badge_ids(post)
+ raw = post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD]
+ Array(raw)
+ .map { |id| id.is_a?(Numeric) || id.is_a?(String) ? id.to_i : 0 }
+ .reject { |id| id <= 0 }
+ end
+
def mod_whisper_participant_ids(topic)
return [] unless topic
diff --git a/lib/discourse_mod_categories/whisper_query_filter.rb b/lib/discourse_mod_categories/whisper_query_filter.rb
index 48b17e0..47664e7 100644
--- a/lib/discourse_mod_categories/whisper_query_filter.rb
+++ b/lib/discourse_mod_categories/whisper_query_filter.rb
@@ -30,6 +30,7 @@ def apply(scope, user)
if user
participant_field = DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD
groups_field = DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD
+ badges_field = DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD
where_sql = <<~SQL
mw_pcf.id IS NULL
OR posts.user_id = :uid
@@ -55,6 +56,18 @@ def apply(scope, user)
AND mw_gcf.value <> ''
AND mw_gcf.value <> '[]'
)
+ OR EXISTS (
+ SELECT 1
+ FROM post_custom_fields mw_bcf
+ JOIN user_badges mw_ub
+ ON mw_ub.user_id = :uid
+ AND mw_bcf.value::jsonb @> to_jsonb(mw_ub.badge_id)
+ WHERE mw_bcf.post_id = posts.id
+ AND mw_bcf.name = '#{badges_field}'
+ AND mw_bcf.value IS NOT NULL
+ AND mw_bcf.value <> ''
+ AND mw_bcf.value <> '[]'
+ )
SQL
scope.joins(join_sql).where(where_sql, uid: user.id, uid_json: user.id.to_json)
diff --git a/spec/requests/badge_members_endpoint_spec.rb b/spec/requests/badge_members_endpoint_spec.rb
new file mode 100644
index 0000000..578fa46
--- /dev/null
+++ b/spec/requests/badge_members_endpoint_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Verifies the /discourse-mod-categories/badge-members/:badge_id endpoint
+# used by the PM composer "Add badge group" button to resolve badge → current
+# holders' usernames.
+RSpec.describe "Badge members endpoint" do
+ fab!(:moderator)
+ fab!(:user)
+ fab!(:other_holder, :user)
+ fab!(:non_holder, :user)
+ fab!(:badge) { Fabricate(:badge, name: "PMBadge") }
+
+ before do
+ SiteSetting.mod_categories_enabled = true
+ Group.refresh_automatic_groups!
+
+ BadgeGranter.grant(badge, user)
+ BadgeGranter.grant(badge, other_holder)
+ end
+
+ it "returns the current holders' usernames" do
+ sign_in(moderator)
+ get "/discourse-mod-categories/badge-members/#{badge.id}.json"
+
+ expect(response.status).to eq(200)
+ body = response.parsed_body
+ expect(body["badge"]["id"]).to eq(badge.id)
+ expect(body["badge"]["name"]).to eq(badge.display_name)
+ expect(body["usernames"]).to match_array([user.username, other_holder.username])
+ expect(body["usernames"]).not_to include(non_holder.username)
+ end
+
+ it "excludes the requesting user from the returned list" do
+ BadgeGranter.grant(badge, moderator)
+ sign_in(moderator)
+ get "/discourse-mod-categories/badge-members/#{badge.id}.json"
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["usernames"]).not_to include(moderator.username)
+ end
+
+ it "404s when the badge does not exist" do
+ sign_in(moderator)
+ get "/discourse-mod-categories/badge-members/9999999.json"
+ expect(response.status).to eq(404)
+ end
+
+ it "requires authentication" do
+ get "/discourse-mod-categories/badge-members/#{badge.id}.json"
+ expect(response.status).to eq(403)
+ end
+end
diff --git a/spec/requests/mod_note_header_indicators_spec.rb b/spec/requests/mod_note_header_indicators_spec.rb
deleted file mode 100644
index 4b8a1e7..0000000
--- a/spec/requests/mod_note_header_indicators_spec.rb
+++ /dev/null
@@ -1,149 +0,0 @@
-# frozen_string_literal: true
-
-require "rails_helper"
-
-# Coverage for the data backing the moderator-notes header pip + browser-tab
-# title prefix:
-# - `mod_note_unread_count` reflects the right number across states.
-# - Setting a note (and replying) publishes a `+1` bump on the dedicated
-# `/mod-note-unread-count/{user_id}` MessageBus channel.
-# - Marking the feed as seen publishes a `reset` on the same channel and
-# drives the serializer count back to zero.
-RSpec.describe "Moderator-note header indicators" do
- fab!(:admin)
- fab!(:moderator)
- fab!(:other_moderator, :moderator)
- fab!(:user)
- fab!(:category)
- fab!(:topic) { Fabricate(:topic, category: category) }
- fab!(:first_post) { Fabricate(:post, topic: topic) }
-
- before { SiteSetting.mod_categories_enabled = true }
-
- describe "current-user serializer: mod_note_unread_count" do
- it "reports zero when no topic has note activity" do
- sign_in(moderator)
-
- get "/session/current.json"
-
- expect(response.status).to eq(200)
- expect(response.parsed_body["current_user"]["mod_note_unread_count"]).to eq(0)
- end
-
- it "reports the right count for unseen note activity" do
- topic.custom_fields["mod_topic_private_note_activity_at"] = Time.zone.now.iso8601
- topic.save_custom_fields(true)
-
- sign_in(moderator)
-
- get "/session/current.json"
-
- expect(response.parsed_body["current_user"]["mod_note_unread_count"]).to be >= 1
- end
-
- it "drops back to zero after the staff member marks the feed seen" do
- topic.custom_fields["mod_topic_private_note_activity_at"] = 2.days.ago.iso8601
- topic.save_custom_fields(true)
-
- sign_in(moderator)
- post "/discourse-mod-categories/notes-feed/seen.json"
- expect(response.status).to eq(200)
-
- get "/session/current.json"
- expect(response.parsed_body["current_user"]["mod_note_unread_count"]).to eq(0)
- end
-
- it "is always zero for a non-staff user" do
- topic.custom_fields["mod_topic_private_note_activity_at"] = Time.zone.now.iso8601
- topic.save_custom_fields(true)
-
- sign_in(user)
-
- get "/session/current.json"
- expect(response.parsed_body["current_user"]["mod_note_unread_count"]).to eq(0)
- end
- end
-
- describe "MessageBus: /mod-note-unread-count" do
- def set_note(raw = "Heads up, staff.")
- put "/discourse-mod-categories/topic/#{topic.id}.json", params: { private_note: raw }
- end
-
- it "publishes a +1 bump to every other staff member on a new note" do
- sign_in(moderator)
-
- messages =
- MessageBus
- .track_publish { set_note }
- .select { |m| m.channel.start_with?("/mod-note-unread-count/") }
-
- channels = messages.map(&:channel)
- expect(channels).to include("/mod-note-unread-count/#{admin.id}")
- expect(channels).to include("/mod-note-unread-count/#{other_moderator.id}")
- expect(channels).not_to include("/mod-note-unread-count/#{moderator.id}")
-
- payload = messages.first.data
- expect(payload[:delta]).to eq(1)
- end
-
- it "publishes a +1 bump on a note reply too" do
- topic.custom_fields["mod_topic_private_note"] = "Initial note."
- topic.save_custom_fields(true)
- sign_in(moderator)
-
- messages =
- MessageBus
- .track_publish do
- post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json",
- params: {
- raw: "Following up.",
- }
- end
- .select { |m| m.channel.start_with?("/mod-note-unread-count/") }
-
- channels = messages.map(&:channel)
- expect(channels).to include("/mod-note-unread-count/#{admin.id}")
- expect(channels).to include("/mod-note-unread-count/#{other_moderator.id}")
- end
-
- it "publishes a reset when the staff member marks the feed as seen" do
- sign_in(moderator)
-
- messages =
- MessageBus
- .track_publish { post "/discourse-mod-categories/notes-feed/seen.json" }
- .select { |m| m.channel.start_with?("/mod-note-unread-count/") }
-
- expect(messages.map(&:channel)).to eq(["/mod-note-unread-count/#{moderator.id}"])
- expect(messages.first.data[:reset]).to eq(true)
- end
-
- it "marks the staff member's mod-note Notification rows as read" do
- # Seed two unread mod-note notifications and one unrelated custom
- # notification — only the mod-note rows should flip to read.
- mod_note_rows =
- 2.times.map do
- Notification.create!(
- user_id: moderator.id,
- notification_type: Notification.types[:custom],
- read: false,
- data: { mod_note: true, message: "x" }.to_json,
- )
- end
- unrelated =
- Notification.create!(
- user_id: moderator.id,
- notification_type: Notification.types[:custom],
- read: false,
- data: { message: "not a mod note" }.to_json,
- )
-
- sign_in(moderator)
- post "/discourse-mod-categories/notes-feed/seen.json"
- expect(response.status).to eq(200)
-
- mod_note_rows.each { |n| expect(n.reload.read).to eq(true) }
- expect(unrelated.reload.read).to eq(false)
- end
- end
-end
diff --git a/spec/requests/mod_note_unread_count_spec.rb b/spec/requests/mod_note_unread_count_spec.rb
new file mode 100644
index 0000000..55ed100
--- /dev/null
+++ b/spec/requests/mod_note_unread_count_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Verifies that mod_note_unread_count derives from the same unread
+# Notification rows that drive Discourse's standard bell dot — so reading a
+# mod-note from the bell decrements the in-dropdown shield-tab count, and
+# opening the shield tab (which marks the rows read) decrements the bell.
+RSpec.describe "Mod-note unread count merge" do
+ fab!(:admin)
+ fab!(:moderator)
+ fab!(:user)
+
+ before do
+ SiteSetting.mod_categories_enabled = true
+ Group.refresh_automatic_groups!
+ end
+
+ def create_mod_note_notification(staff_user)
+ ::Notification.create!(
+ notification_type: ::Notification.types[:custom],
+ user_id: staff_user.id,
+ high_priority: true,
+ data: {
+ topic_title: "Some topic",
+ display_username: "modX",
+ mod_note: true,
+ url: "/t/1",
+ message: "discourse_mod_categories.note_notification",
+ title: "discourse_mod_categories.note_notification_title",
+ }.to_json,
+ )
+ end
+
+ def current_user_payload(actor)
+ sign_in(actor)
+ get "/session/current.json"
+ expect(response.status).to eq(200)
+ response.parsed_body["current_user"]
+ end
+
+ it "returns 0 for non-staff users" do
+ create_mod_note_notification(admin) # unrelated unread on admin
+ expect(current_user_payload(user)["mod_note_unread_count"]).to eq(0)
+ end
+
+ it "counts unread mod-note notifications for staff" do
+ create_mod_note_notification(moderator)
+ create_mod_note_notification(moderator)
+ expect(current_user_payload(moderator)["mod_note_unread_count"]).to eq(2)
+ end
+
+ it "decrements the shield-tab count when a single mod-note notification is marked read from the bell" do
+ n1 = create_mod_note_notification(moderator)
+ create_mod_note_notification(moderator)
+ expect(current_user_payload(moderator)["mod_note_unread_count"]).to eq(2)
+
+ n1.update!(read: true)
+
+ expect(current_user_payload(moderator)["mod_note_unread_count"]).to eq(1)
+ end
+
+ it "ignores non-mod-note custom notifications (whispers)" do
+ # A whisper notification is also notification_type: :custom but carries
+ # no `mod_note` marker — must not bump the shield-tab count.
+ ::Notification.create!(
+ notification_type: ::Notification.types[:custom],
+ user_id: moderator.id,
+ data: {
+ topic_title: "T",
+ display_username: "u",
+ original_post_id: 1,
+ original_post_type: 1,
+ }.to_json,
+ )
+ expect(current_user_payload(moderator)["mod_note_unread_count"]).to eq(0)
+ end
+
+ it "drops to 0 once all mod-note notifications are read" do
+ create_mod_note_notification(moderator)
+ ::Notification.where(
+ user_id: moderator.id,
+ notification_type: ::Notification.types[:custom],
+ ).update_all(read: true)
+
+ expect(current_user_payload(moderator)["mod_note_unread_count"]).to eq(0)
+ end
+end
diff --git a/spec/requests/mod_serialization_spec.rb b/spec/requests/mod_serialization_spec.rb
index fb7af1d..9a170db 100644
--- a/spec/requests/mod_serialization_spec.rb
+++ b/spec/requests/mod_serialization_spec.rb
@@ -111,9 +111,21 @@
end
describe "moderator-note unread count" do
+ # The count is derived from unread Notification rows tagged with
+ # `mod_note: true` in their data — the same rows that drive the avatar
+ # bell dot — so reading a mod-note from either the bell or the shield
+ # tab decrements both counts together.
+ def make_mod_note_notification(user, read: false)
+ ::Notification.create!(
+ notification_type: ::Notification.types[:custom],
+ user_id: user.id,
+ read: read,
+ data: { mod_note: true, topic_title: "x", display_username: "y" }.to_json,
+ )
+ end
+
it "exposes an unread count to staff via the current user" do
- topic.custom_fields["mod_topic_private_note_activity_at"] = Time.zone.now.iso8601
- topic.save_custom_fields(true)
+ make_mod_note_notification(moderator)
sign_in(moderator)
get "/session/current.json"
@@ -123,11 +135,8 @@
expect(count).to be >= 1
end
- it "reports zero once the staff member has seen the feed" do
- topic.custom_fields["mod_topic_private_note_activity_at"] = 2.days.ago.iso8601
- topic.save_custom_fields(true)
- moderator.custom_fields["mod_notes_seen_at"] = Time.zone.now.iso8601
- moderator.save_custom_fields(true)
+ it "reports zero once every mod-note notification is read" do
+ make_mod_note_notification(moderator, read: true)
sign_in(moderator)
get "/session/current.json"
diff --git a/spec/requests/whisper_badge_targeting_spec.rb b/spec/requests/whisper_badge_targeting_spec.rb
new file mode 100644
index 0000000..6d11127
--- /dev/null
+++ b/spec/requests/whisper_badge_targeting_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Verifies badge-target whisper visibility:
+# * A user holding a target badge sees the whisper (Guardian + topic stream).
+# * A user without the badge does not.
+# * Granting the badge later restores visibility (lazy membership).
+RSpec.describe "Whisper badge targeting" do
+ fab!(:admin)
+ fab!(:moderator)
+ fab!(:author, :user)
+ fab!(:badge) { Fabricate(:badge, name: "WhisperBadge") }
+ fab!(:badge_holder, :user)
+ fab!(:stranger, :user)
+ fab!(:topic)
+ fab!(:op) { Fabricate(:post, topic: topic, user: author) }
+ fab!(:whisper_post) { Fabricate(:post, topic: topic, user: moderator) }
+
+ let(:targets_field) { DiscourseModCategories::POST_WHISPER_TARGETS_FIELD }
+ let(:groups_field) { DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD }
+ let(:badges_field) { DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD }
+
+ before do
+ SiteSetting.mod_categories_enabled = true
+ SiteSetting.mod_whisper_enabled = true
+ SiteSetting.auto_silence_fast_typers_on_first_post = false
+ Group.refresh_automatic_groups!
+
+ BadgeGranter.grant(badge, badge_holder)
+
+ whisper_post.custom_fields[targets_field] = []
+ whisper_post.custom_fields[groups_field] = []
+ whisper_post.custom_fields[badges_field] = [badge.id]
+ whisper_post.save_custom_fields(true)
+ end
+
+ def stream_post_ids
+ get "/t/#{topic.id}.json"
+ expect(response.status).to eq(200)
+ response.parsed_body["post_stream"]["posts"].map { |p| p["id"] }
+ end
+
+ describe "Guardian" do
+ it "permits a badge holder to see the whisper" do
+ expect(Guardian.new(badge_holder).can_see_post?(whisper_post.reload)).to eq(true)
+ end
+
+ it "denies a non-holder" do
+ expect(Guardian.new(stranger).can_see_post?(whisper_post.reload)).to eq(false)
+ end
+
+ it "permits staff regardless of badge membership" do
+ expect(Guardian.new(admin).can_see_post?(whisper_post.reload)).to eq(true)
+ expect(Guardian.new(moderator).can_see_post?(whisper_post.reload)).to eq(true)
+ end
+ end
+
+ describe "WhisperQueryFilter" do
+ def filter(user)
+ DiscourseModCategories::WhisperQueryFilter.apply(
+ Post.where(id: whisper_post.id),
+ user,
+ ).exists?
+ end
+
+ it "shows the whisper to a badge holder" do
+ expect(filter(badge_holder)).to eq(true)
+ end
+
+ it "hides the whisper from a non-holder" do
+ expect(filter(stranger)).to eq(false)
+ end
+
+ it "shows the whisper to staff" do
+ expect(filter(admin)).to eq(true)
+ end
+
+ it "matches Guardian decision across personas" do
+ [nil, author, badge_holder, stranger, admin, moderator].each do |user|
+ guardian_visible = Guardian.new(user).can_see_post?(whisper_post.reload)
+ sql_visible = filter(user)
+ expect(sql_visible).to eq(guardian_visible),
+ "QueryFilter (#{sql_visible}) disagrees with Guardian " \
+ "(#{guardian_visible}) for user #{user&.username || "anonymous"}"
+ end
+ end
+ end
+
+ describe "topic stream rendering" do
+ it "includes the whisper for a badge holder" do
+ sign_in(badge_holder)
+ expect(stream_post_ids).to include(whisper_post.id)
+ end
+
+ it "excludes the whisper from a non-holder" do
+ sign_in(stranger)
+ expect(stream_post_ids).not_to include(whisper_post.id)
+ end
+
+ it "serializes the target badge on the post" do
+ sign_in(badge_holder)
+ get "/t/#{topic.id}.json"
+ post_json =
+ response.parsed_body["post_stream"]["posts"].find { |p| p["id"] == whisper_post.id }
+ expect(post_json["mod_whisper_target_badge_ids"]).to eq([badge.id])
+ expect(post_json["mod_whisper_target_badges"]).to eq(
+ [{ "id" => badge.id, "name" => badge.display_name }],
+ )
+ expect(post_json["mod_whisper_is_staff_only"]).to eq(false)
+ end
+ end
+
+ describe "lazy membership" do
+ it "grants visibility when the badge is granted later" do
+ expect(Guardian.new(stranger).can_see_post?(whisper_post.reload)).to eq(false)
+ BadgeGranter.grant(badge, stranger)
+ expect(Guardian.new(stranger).can_see_post?(whisper_post.reload)).to eq(true)
+ end
+
+ it "removes visibility when the badge is revoked later" do
+ expect(Guardian.new(badge_holder).can_see_post?(whisper_post.reload)).to eq(true)
+ UserBadge.where(user_id: badge_holder.id, badge_id: badge.id).destroy_all
+ expect(Guardian.new(badge_holder).can_see_post?(whisper_post.reload)).to eq(false)
+ end
+ end
+end
diff --git a/spec/requests/whisper_unread_badge_spec.rb b/spec/requests/whisper_unread_badge_spec.rb
new file mode 100644
index 0000000..9d289ec
--- /dev/null
+++ b/spec/requests/whisper_unread_badge_spec.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Verifies the audience-aware unread-badge behavior for whisper posts:
+#
+# * Topic#highest_post_number is rolled back after a whisper is created so a
+# non-audience viewer's topic-list badge is not bumped.
+# * The :listable_topic serializer adds the bump back for audience members
+# (staff, explicit user targets, group targets, cumulative participants).
+RSpec.describe "Whisper unread badge" do
+ fab!(:admin)
+ fab!(:moderator)
+ fab!(:author, :user)
+ fab!(:target, :user)
+ fab!(:participant, :user)
+ fab!(:stranger, :user)
+ fab!(:group_member, :user)
+ fab!(:whisper_group) { Fabricate(:group, name: "whisper_squad") }
+ fab!(:topic)
+ fab!(:op) { Fabricate(:post, topic: topic, user: author) }
+ fab!(:regular_reply) { Fabricate(:post, topic: topic, user: author) }
+ fab!(:whisper_post) { Fabricate(:post, topic: topic, user: moderator) }
+
+ let(:targets_field) { DiscourseModCategories::POST_WHISPER_TARGETS_FIELD }
+ let(:groups_field) { DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD }
+ let(:participants_field) { DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD }
+
+ before do
+ SiteSetting.mod_categories_enabled = true
+ SiteSetting.mod_whisper_enabled = true
+ SiteSetting.auto_silence_fast_typers_on_first_post = false
+ Group.refresh_automatic_groups!
+
+ # Mark the post a whisper to target + participant. Triggers the same
+ # rollback the post_created handler would, since the spec writes the
+ # custom field directly (no PostCreator path).
+ whisper_post.custom_fields[targets_field] = [target.id]
+ whisper_post.save_custom_fields(true)
+
+ topic.custom_fields[participants_field] = [target.id, participant.id]
+ topic.save_custom_fields(true)
+ end
+
+ describe "DiscourseModCategories.whisper_audience_max_post_number" do
+ it "returns the whisper's post_number for an explicit user target" do
+ expect(DiscourseModCategories.whisper_audience_max_post_number(topic, target)).to eq(
+ whisper_post.post_number,
+ )
+ end
+
+ it "returns the whisper's post_number for a cumulative topic participant" do
+ expect(DiscourseModCategories.whisper_audience_max_post_number(topic, participant)).to eq(
+ whisper_post.post_number,
+ )
+ end
+
+ it "returns the whisper's post_number for staff" do
+ expect(DiscourseModCategories.whisper_audience_max_post_number(topic, admin)).to eq(
+ whisper_post.post_number,
+ )
+ expect(DiscourseModCategories.whisper_audience_max_post_number(topic, moderator)).to eq(
+ whisper_post.post_number,
+ )
+ end
+
+ it "returns the highest non-whisper post_number for a non-audience user" do
+ expect(DiscourseModCategories.whisper_audience_max_post_number(topic, stranger)).to eq(
+ regular_reply.post_number,
+ )
+ end
+
+ it "returns the highest non-whisper post_number for anonymous viewers" do
+ expect(DiscourseModCategories.whisper_audience_max_post_number(topic, nil)).to eq(
+ regular_reply.post_number,
+ )
+ end
+
+ it "honors group-target audience" do
+ whisper_group.add(group_member)
+ whisper_post.custom_fields[targets_field] = []
+ whisper_post.custom_fields[groups_field] = [whisper_group.id]
+ whisper_post.save_custom_fields(true)
+
+ expect(DiscourseModCategories.whisper_audience_max_post_number(topic, group_member)).to eq(
+ whisper_post.post_number,
+ )
+ end
+ end
+
+ describe "topic-list highest_post_number serialization" do
+ # The listable_topic serializer override returns the per-user audience-aware
+ # max so the (highest - last_read) topic-list math is audience-aware.
+ it "reports the whisper as the highest post number for an audience member" do
+ sign_in(target)
+ get "/latest.json"
+ topic_json = response.parsed_body["topic_list"]["topics"].find { |t| t["id"] == topic.id }
+ expect(topic_json["highest_post_number"]).to eq(whisper_post.post_number)
+ end
+
+ it "reports the prior non-whisper post as the highest for a non-audience user" do
+ sign_in(stranger)
+ get "/latest.json"
+ topic_json = response.parsed_body["topic_list"]["topics"].find { |t| t["id"] == topic.id }
+ expect(topic_json["highest_post_number"]).to eq(regular_reply.post_number)
+ end
+
+ it "reports the whisper as the highest for staff" do
+ sign_in(admin)
+ get "/latest.json"
+ topic_json = response.parsed_body["topic_list"]["topics"].find { |t| t["id"] == topic.id }
+ expect(topic_json["highest_post_number"]).to eq(whisper_post.post_number)
+ end
+ end
+
+ describe "post_created rollback" do
+ before { SiteSetting.min_post_length = 5 }
+
+ it "rolls Topic#highest_post_number back to the last non-whisper post when a whisper is created" do
+ sign_in(moderator)
+ # The topic already has op(1), regular_reply(2), whisper_post(3).
+ # Create a NEW whisper via the real PostCreator path so the on(:post_created)
+ # rollback runs.
+ post "/posts.json",
+ params: {
+ :topic_id => topic.id,
+ :raw => "Yet another whisper body long enough to be valid.",
+ DiscourseModCategories::POST_WHISPER_ARMED_PARAM => true,
+ DiscourseModCategories::POST_WHISPER_TARGETS_FIELD => [target.id],
+ }
+ expect(response.status).to eq(200)
+
+ topic.reload
+ expect(topic.highest_post_number).to eq(regular_reply.post_number)
+ end
+ end
+end
diff --git a/spec/system/feature_screenshots_spec.rb b/spec/system/feature_screenshots_spec.rb
new file mode 100644
index 0000000..6a34ab1
--- /dev/null
+++ b/spec/system/feature_screenshots_spec.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Visual captures of each behavior added by the
+# "Audience-aware whisper unread + merge mod-note bell + badge targeting"
+# change set, so a reviewer can eyeball them from CI without spinning up a
+# local Discourse. PNGs are written into `tmp/capybara/feature_screenshots/`
+# and are picked up by the `feature-screenshots.yml` workflow's
+# `actions/upload-artifact@v6` step (`if: always()` — uploaded regardless
+# of pass/fail).
+RSpec.describe "Feature screenshots" do
+ fab!(:admin) { Fabricate(:admin, username: "screen_admin") }
+ fab!(:moderator) { Fabricate(:moderator, username: "screen_mod") }
+ fab!(:author, :user) { Fabricate(:user, username: "screen_author") }
+ fab!(:audience_user, :user) { Fabricate(:user, username: "screen_audience") }
+ fab!(:stranger, :user) { Fabricate(:user, username: "screen_stranger") }
+ fab!(:badge_holder, :user) { Fabricate(:user, username: "screen_badge_holder") }
+ fab!(:badge) { Fabricate(:badge, name: "ScreenshotBadge") }
+ fab!(:category)
+
+ let(:targets_field) { DiscourseModCategories::POST_WHISPER_TARGETS_FIELD }
+ let(:participants_field) { DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD }
+
+ before do
+ SiteSetting.mod_categories_enabled = true
+ SiteSetting.mod_whisper_enabled = true
+ SiteSetting.min_post_length = 5
+ SiteSetting.body_min_entropy = 1
+ SiteSetting.auto_silence_fast_typers_on_first_post = false
+ Group.refresh_automatic_groups!
+ SiteSetting.approve_unless_allowed_groups = Group::AUTO_GROUPS[:trust_level_0].to_s
+
+ BadgeGranter.grant(badge, badge_holder)
+
+ FileUtils.mkdir_p(File.join(Rails.root, "tmp/capybara/feature_screenshots"))
+ end
+
+ def shot(name)
+ begin
+ Timeout.timeout(8) do
+ sleep 0.1 until page.evaluate_script("Array.from(document.images).every((i) => i.complete)")
+ end
+ rescue Timeout::Error
+ # Capture anyway rather than failing on a slow image.
+ end
+ # Absolute path so the file lands where the workflow expects, regardless
+ # of Capybara.save_path. Relative paths are interpreted relative to
+ # Capybara.save_path (`tmp/capybara/`), which would produce
+ # `tmp/capybara/tmp/capybara/feature_screenshots/...`.
+ path = File.join(Rails.root, "tmp/capybara/feature_screenshots/#{name}.png")
+ page.save_screenshot(path)
+ end
+
+ def topic_with_whisper(audience_ids: [audience_user.id])
+ topic = Fabricate(:topic, category: category, title: "Audience-aware whisper demo")
+ Fabricate(:post, topic: topic, user: author, raw: "OP body for the visual capture.")
+ Fabricate(:post, topic: topic, user: author, raw: "Public reply visible to everyone.")
+ whisper = Fabricate(:post, topic: topic, user: moderator, raw: "Mod-only whisper body.")
+ whisper.custom_fields[targets_field] = audience_ids
+ whisper.save_custom_fields(true)
+ topic.custom_fields[participants_field] = audience_ids
+ topic.save_custom_fields(true)
+ # Mirror the on(:post_created) rollback so the visual matches what
+ # production sees after a whisper is posted.
+ non_whisper_max =
+ Post
+ .where(topic_id: topic.id, deleted_at: nil)
+ .where.not(id: PostCustomField.where(name: targets_field).select(:post_id))
+ .maximum(:post_number)
+ Topic.where(id: topic.id).update_all(highest_post_number: non_whisper_max) if non_whisper_max
+ topic.reload
+ end
+
+ it "1. captures the topic list with no unread bump for a non-audience viewer" do
+ topic_with_whisper
+ sign_in(stranger)
+ visit("/latest")
+ expect(page).to have_css(".topic-list", wait: 15)
+ shot("01_non_audience_no_badge")
+ end
+
+ it "2. captures the topic list WITH the unread bump for an audience viewer" do
+ topic_with_whisper(audience_ids: [audience_user.id])
+ sign_in(audience_user)
+ visit("/latest")
+ expect(page).to have_css(".topic-list", wait: 15)
+ shot("02_audience_sees_badge")
+ end
+
+ it "3. captures the standard bell with a mod-note notification (no separate header pip)" do
+ Notification.create!(
+ notification_type: Notification.types[:custom],
+ user_id: moderator.id,
+ high_priority: true,
+ data: {
+ topic_title: "Heads up, staff",
+ display_username: admin.username,
+ mod_note: true,
+ url: "/",
+ message: "discourse_mod_categories.note_notification",
+ title: "discourse_mod_categories.note_notification_title",
+ }.to_json,
+ )
+
+ sign_in(moderator)
+ visit("/")
+ expect(page).to have_css(".d-header", wait: 15)
+ shot("03_bell_header_no_separate_pip")
+
+ begin
+ find(".header-dropdown-toggle.current-user button", match: :first).click
+ rescue StandardError
+ nil
+ end
+ sleep 0.5
+ shot("04_user_menu_with_mod_note_in_bell")
+ end
+
+ it "4. captures the whisper composer toolbar modal with the badge picker" do
+ topic = Fabricate(:topic, category: category, title: "Whisper composer demo")
+ Fabricate(:post, topic: topic, user: author, raw: "OP for whisper composer demo.")
+
+ sign_in(moderator)
+ visit("/t/#{topic.slug}/#{topic.id}")
+ expect(page).to have_css(".topic-post", wait: 15)
+
+ find("#topic-footer-buttons .create", match: :first).click
+ expect(page).to have_css(".d-editor-input", wait: 15)
+
+ # The whisper toolbar button — clicking it as staff opens the target modal.
+ find(
+ ".d-editor-button-bar button.mod-whisper-target, " \
+ ".d-editor-button-bar button[title='" \
+ "#{I18n.t("js.discourse_mod_categories.whisper.toolbar_title")}']",
+ match: :first,
+ ).click
+
+ expect(page).to have_css(".mod-whisper-target-modal", wait: 15)
+ # The badge picker appears when at least one enabled badge exists; the
+ # fab!(:badge) at the top of the spec ensures that.
+ shot("05_whisper_modal_with_badge_picker")
+ end
+
+ it "5. captures the PM composer with the 'Add badge group' button" do
+ sign_in(moderator)
+ visit("/")
+ expect(page).to have_css(".d-header", wait: 15)
+
+ # Open a new PM via the URL fragment that opens the composer.
+ visit("/new-message?username=#{audience_user.username}")
+ expect(page).to have_css(".composer-fields", wait: 15)
+ sleep 0.5
+ shot("06_pm_composer_add_badge_group_button")
+ end
+end
diff --git a/spec/system/mod_note_avatar_badge_visuals_spec.rb b/spec/system/mod_note_avatar_badge_visuals_spec.rb
deleted file mode 100644
index d498660..0000000
--- a/spec/system/mod_note_avatar_badge_visuals_spec.rb
+++ /dev/null
@@ -1,158 +0,0 @@
-# frozen_string_literal: true
-
-require "rails_helper"
-
-# Close-up visual captures of the moderator-notes avatar pip across the
-# different unread-count states. The badge lives on the current-user
-# avatar in the header; these specs crop the screenshot to the `.d-header`
-# bar so the badge is centred with enough context to read it.
-#
-# This file deliberately only screenshots — the behavioural coverage lives
-# in `spec/system/mod_note_header_indicators_spec.rb`. We just need PNGs
-# the reviewer can eyeball before merging.
-RSpec.describe "Moderator-note avatar badge visuals" do
- fab!(:moderator)
- fab!(:category)
-
- before { SiteSetting.mod_categories_enabled = true }
-
- # Crop to the header bar if the driver supports element-level screenshots;
- # otherwise fall back to a full-page screenshot so the spec still produces
- # an artefact in CI.
- def avatar_shot(name)
- begin
- Timeout.timeout(5) do
- sleep 0.1 until page.evaluate_script("Array.from(document.images).every((i) => i.complete)")
- end
- rescue Timeout::Error
- # Slow image — capture anyway.
- end
-
- path = "tmp/capybara/#{name}.png"
- begin
- el = find(".d-header", wait: 5)
- el.native.save_screenshot(path)
- rescue StandardError
- page.save_screenshot(path)
- end
- end
-
- # Build N topics, each with an unseen note activity timestamp. The
- # serializer counts `TopicCustomField` rows whose `value > seen_at`, so
- # this is the most reliable way to force `mod_note_unread_count` to N.
- def seed_unread_notes(count, base_time: Time.zone.now)
- count.times do |i|
- topic = Fabricate(:topic, category: category, title: "Mod note thread #{i + 1}")
- Fabricate(:post, topic: topic, raw: "Body for thread #{i + 1}.")
- topic.custom_fields["mod_topic_private_note"] = "Note #{i + 1}."
- topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id
- topic.custom_fields["mod_topic_private_note_activity_at"] = (base_time + i.seconds).iso8601
- topic.save_custom_fields(true)
- end
- end
-
- # Force seen_at to "now+future" so any existing notes don't bleed in.
- def reset_seen_to_future
- moderator.custom_fields[DiscourseModCategories::USER_NOTES_SEEN_FIELD] = 1.hour.from_now.iso8601
- moderator.save_custom_fields(true)
- end
-
- # Wait until the pip reports the expected count via its data-count
- # attribute. The pip renders its number via a CSS `::before` pseudo, so
- # textContent is empty; `data-count` is the source of truth.
- def wait_for_pip_count(expected_label)
- Timeout.timeout(15) do
- loop do
- count =
- page
- .evaluate_script("document.querySelector('.mod-note-avatar-pip')?.dataset.count || ''")
- .to_s
- .strip
- break if count == expected_label
- sleep 0.2
- end
- end
- end
-
- def wait_for_pip_absent
- Timeout.timeout(15) do
- loop do
- visible = page.evaluate_script("!!document.querySelector('.mod-note-avatar-pip.visible')")
- break unless visible
- sleep 0.2
- end
- end
- end
-
- it "captures the avatar with no unread notes" do
- reset_seen_to_future
- sign_in(moderator)
-
- visit("/")
- expect(page).to have_css(".d-header .header-dropdown-toggle.current-user", wait: 10)
- wait_for_pip_absent
- avatar_shot("193_avatar_badge_no_unread")
- end
-
- it "captures the avatar with exactly 1 unread note" do
- seed_unread_notes(1)
- sign_in(moderator)
-
- visit("/")
- expect(page).to have_css(".mod-note-avatar-pip.visible", wait: 10)
- wait_for_pip_count("1")
- avatar_shot("194_avatar_badge_one_unread")
- end
-
- it "captures the avatar with 5 unread notes" do
- seed_unread_notes(5)
- sign_in(moderator)
-
- visit("/")
- expect(page).to have_css(".mod-note-avatar-pip.visible", wait: 10)
- wait_for_pip_count("5")
- avatar_shot("195_avatar_badge_five_unread")
- end
-
- it "captures the avatar with 12 unread notes (overflow renders as 9+)" do
- seed_unread_notes(12)
- sign_in(moderator)
-
- visit("/")
- expect(page).to have_css(".mod-note-avatar-pip.visible", wait: 10)
- wait_for_pip_count("9+")
- avatar_shot("196_avatar_badge_nine_plus_overflow")
- end
-
- it "captures the avatar after the shield tab clears the badge" do
- seed_unread_notes(3)
- sign_in(moderator)
-
- visit("/")
- expect(page).to have_css(".mod-note-avatar-pip.visible", wait: 10)
- wait_for_pip_count("3")
-
- # Open the user menu, click the shield tab, wait for the seen-ack to
- # fire and the badge to clear, then close the menu and screenshot.
- find(".header-dropdown-toggle.current-user").click
- expect(page).to have_css("#user-menu-button-discourse-mod-notes", wait: 10)
- find("#user-menu-button-discourse-mod-notes").click
- expect(page).to have_css(".mod-notes-panel", wait: 10)
-
- Timeout.timeout(10) do
- loop do
- count =
- page
- .evaluate_script("document.querySelector('.mod-note-avatar-pip')?.dataset.count || ''")
- .to_s
- .strip
- break if count.empty? || count == "0"
- sleep 0.2
- end
- end
-
- find("body").send_keys(:escape)
- wait_for_pip_absent
- avatar_shot("197_avatar_badge_after_seen")
- end
-end
diff --git a/spec/system/mod_note_header_indicators_spec.rb b/spec/system/mod_note_header_indicators_spec.rb
deleted file mode 100644
index 49ff75c..0000000
--- a/spec/system/mod_note_header_indicators_spec.rb
+++ /dev/null
@@ -1,124 +0,0 @@
-# frozen_string_literal: true
-
-require "rails_helper"
-
-# End-to-end coverage for the header-level moderator-notes indicators.
-#
-# Two staff-only signals:
-# 1. A small badge overlaid on the current-user avatar in the header
-# carrying the unread count, visible whenever the user menu is
-# *closed*.
-# 2. A `(N)` prefix on `document.title`, mirroring the bell's behaviour.
-#
-# Both reset to "nothing" once the staff member opens the shield tab —
-# `POST /notes-feed/seen` clears `mod_notes_seen_at`, and the panel
-# component zeroes `currentUser.mod_note_unread_count` locally.
-RSpec.describe "Moderator-note header indicators" do
- fab!(:admin)
- fab!(:moderator)
- fab!(:user)
- fab!(:category)
- fab!(:topic) { Fabricate(:topic, category: category, title: "Share your app build here") }
- fab!(:first_post) { Fabricate(:post, topic: topic, raw: "Drop your app uploads in this thread.") }
-
- before do
- SiteSetting.mod_categories_enabled = true
- topic.custom_fields["mod_topic_private_note"] = "Please review this thread."
- topic.custom_fields["mod_topic_private_note_user_id"] = admin.id
- topic.custom_fields["mod_topic_private_note_activity_at"] = Time.zone.now.iso8601
- topic.save_custom_fields(true)
- end
-
- def shot(name)
- begin
- Timeout.timeout(8) do
- sleep 0.1 until page.evaluate_script("Array.from(document.images).every((i) => i.complete)")
- end
- rescue Timeout::Error
- # Capture anyway rather than failing the spec over a slow image.
- end
- page.save_screenshot("#{name}.png")
- end
-
- it "renders the avatar pip with the unread count for a staff user" do
- sign_in(moderator)
-
- visit("/")
- expect(page).to have_css(".mod-note-avatar-pip.visible", wait: 10)
- count =
- page.evaluate_script("document.querySelector('.mod-note-avatar-pip')?.dataset.count || ''")
- expect(count).to match(/\d/)
- shot("190_mod_note_header_pip_visible")
- end
-
- it "prefixes the document title with (N) when there are unread notes" do
- sign_in(moderator)
-
- visit("/")
- expect(page).to have_css(".mod-note-avatar-pip.visible", wait: 10)
-
- # Discourse's `document-title` service rewrites `` on every
- # route transition; our MutationObserver re-applies the `(N)` prefix
- # after each rewrite. Visit a topic so a stable, non-empty bare
- # title is in flight, then poll for the prefix.
- visit("/t/#{topic.slug}/#{topic.id}")
- expect(page).to have_css("#topic-title", wait: 10)
-
- title =
- Timeout.timeout(15) do
- loop do
- t = page.evaluate_script("document.title")
- break t if t =~ /^\(\d+\)\s/
- sleep 0.2
- end
- end
-
- expect(title).to match(/^\(\d+\)\s/)
- shot("191_mod_note_browser_title_prefix")
- end
-
- it "clears the pip and the title prefix after the shield tab is opened" do
- sign_in(moderator)
-
- visit("/")
- expect(page).to have_css(".mod-note-avatar-pip.visible", wait: 10)
-
- # Opening the shield tab marks the feed as seen and resets the count.
- find(".header-dropdown-toggle.current-user").click
- expect(page).to have_css("#user-menu-button-discourse-mod-notes", wait: 10)
- find("#user-menu-button-discourse-mod-notes").click
- expect(page).to have_css(".mod-notes-panel", wait: 10)
-
- # Wait for the panel's `notes-feed/seen` POST to complete so the
- # currentUser count is actually zeroed before we check the badge.
- # The badge renders its number via a CSS `::before` pseudo, so we
- # read `dataset.count` instead of textContent.
- Timeout.timeout(10) do
- loop do
- count =
- page
- .evaluate_script("document.querySelector('.mod-note-avatar-pip')?.dataset.count || ''")
- .to_s
- .strip
- break if count.empty? || count == "0"
- sleep 0.2
- end
- end
-
- # Close the user menu so we can verify the badge is no longer visible.
- find("body").send_keys(:escape)
- expect(page).to have_no_css(".mod-note-avatar-pip.visible", wait: 10)
-
- # Title prefix is back to the bare title.
- expect(page.evaluate_script("document.title")).not_to match(/^\(\d+\)\s/)
- shot("192_mod_note_header_indicators_cleared_after_seen")
- end
-
- it "is never rendered for a regular user" do
- sign_in(user)
-
- visit("/")
- expect(page).to have_css("#site-logo, .d-header", wait: 10)
- expect(page).to have_no_css(".mod-note-avatar-pip.visible")
- end
-end
diff --git a/sub_plugins/mod_categories.rb b/sub_plugins/mod_categories.rb
index 57b7d2b..39eea89 100644
--- a/sub_plugins/mod_categories.rb
+++ b/sub_plugins/mod_categories.rb
@@ -8,12 +8,12 @@
register_asset "stylesheets/topic-footer-message.scss"
register_asset "stylesheets/whisper.scss"
-register_asset "stylesheets/mod-note-header-pip.scss"
register_svg_icon "list-check"
register_svg_icon "shield-halved"
register_svg_icon "user-plus"
register_svg_icon "pencil"
register_svg_icon "trash-can"
+register_svg_icon "certificate"
module ::DiscourseModCategories
# Custom-field keys for the moderator-set messages.
@@ -234,6 +234,12 @@ def self.targeted_checklists
# the whisper. User targets and group targets are independent — a whisper
# may carry either, both, or neither (an all-empty staff whisper).
POST_WHISPER_TARGET_GROUPS_FIELD = "mod_whisper_target_group_ids"
+ # A whisper may target the holders of one or more badges; this per-post
+ # field holds the chosen badge ids (json int array). Membership is
+ # evaluated lazily at query time, so a user who later earns the badge
+ # gains visibility and a user who loses it loses visibility — same shape
+ # as group targets.
+ POST_WHISPER_TARGET_BADGES_FIELD = "mod_whisper_target_badge_ids"
TOPIC_WHISPER_PARTICIPANTS_FIELD = "mod_whisper_participant_ids"
MAX_WHISPER_TARGETS = 10
# Explicit boolean armed flag sent by the composer. A boolean survives
@@ -241,6 +247,19 @@ def self.targeted_checklists
# target count — is the single source of truth for "this post is a whisper".
POST_WHISPER_ARMED_PARAM = "mod_whisper"
+ # Highest post_number in the topic that the given user can actually see —
+ # i.e. excluding whispers whose audience does not include them. Used as the
+ # per-user serialized `highest_post_number` so the topic-list unread badge
+ # is audience-aware: non-audience viewers see no badge bump from whispers,
+ # while audience members (staff, explicit user/group targets, topic whisper
+ # participants) see the whisper post count toward unread.
+ def self.whisper_audience_max_post_number(topic, user)
+ return nil unless topic
+ scope = ::Post.where(topic_id: topic.id, deleted_at: nil)
+ scope = WhisperQueryFilter.apply(scope, user)
+ scope.maximum(:post_number)
+ end
+
class Engine < ::Rails::Engine
engine_name "discourse_mod_categories"
isolate_namespace DiscourseModCategories
@@ -250,6 +269,25 @@ class Engine < ::Rails::Engine
after_initialize do
reloadable_patch { ::Guardian.prepend(DiscourseModCategories::GuardianExtensions) }
+ # Keep the shield-tab pip in sync when a mod-note notification is marked
+ # read from the standard bell dropdown. The reverse direction (opening the
+ # shield tab → marking the bell rows read) is already wired in
+ # MessagesController#notes_feed_seen via publish_notifications_state. This
+ # hook gives a single-row bell mark-read the same effect: republishing the
+ # bell count tells the user-state poll that the unread total dropped, and
+ # the next /session/current.json (or current-user serializer refresh) picks
+ # up the recomputed mod_note_unread_count.
+ reloadable_patch do
+ ::Notification.after_update_commit do
+ next unless saved_change_to_read?
+ next unless read
+ next unless notification_type == ::Notification.types[:custom]
+ next if data.to_s.exclude?('"mod_note":true')
+ user = ::User.find_by(id: user_id)
+ user&.publish_notifications_state
+ end
+ end
+
# Per-topic and per-category storage for the moderator-set messages.
register_topic_custom_field_type(DiscourseModCategories::TOPIC_FOOTER_FIELD, :string)
register_topic_custom_field_type(DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD, :string)
@@ -395,19 +433,16 @@ class Engine < ::Rails::Engine
end
end
- # Unread moderator-note count, for the staff member's user-menu tab.
+ # Unread moderator-note count, for the staff member's user-menu tab. Derived
+ # from the same unread Notification rows that drive the standard avatar
+ # bell dot, so reading a mod-note from the bell decrements this count and
+ # opening the shield tab (which marks the rows read) decrements the bell.
add_to_serializer(:current_user, :mod_note_unread_count) do
next 0 unless object.staff?
- seen_at =
- Array(object.custom_fields[DiscourseModCategories::USER_NOTES_SEEN_FIELD])
- .compact
- .max
- .presence || "1970-01-01T00:00:00Z"
-
- TopicCustomField
- .where(name: DiscourseModCategories::TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD)
- .where("value > ?", seen_at)
+ ::Notification
+ .where(user_id: object.id, notification_type: ::Notification.types[:custom], read: false)
+ .where("data LIKE ?", "%\"mod_note\":true%")
.count
end
@@ -470,9 +505,11 @@ class Engine < ::Rails::Engine
register_post_custom_field_type(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD, :json)
register_post_custom_field_type(DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD, :json)
+ register_post_custom_field_type(DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD, :json)
register_topic_custom_field_type(DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD, :json)
add_permitted_post_create_param(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD, :array)
add_permitted_post_create_param(DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD, :array)
+ add_permitted_post_create_param(DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD, :array)
# Permitted as a scalar (:string) — `add_permitted_post_create_param` only
# special-cases :array/:hash, and an unrecognized type would drop the param
# entirely. The value arrives as the string "true"/"false" and is cast with
@@ -519,22 +556,39 @@ class Engine < ::Rails::Engine
requested_ids = normalize_ids.call(opts[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD])
requested_group_ids =
normalize_ids.call(opts[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD])
+ requested_badge_ids =
+ normalize_ids.call(opts[DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD])
author = post.user
topic = post.topic
next unless author && topic
if author.staff?
- # Staff whisper: keep only ids that map to real users / real groups. An
- # EMPTY user AND group list is valid and means a staff-only whisper.
+ # Staff whisper: keep only ids that map to real users / real groups /
+ # real badges. An EMPTY user AND group AND badge list is valid and
+ # means a staff-only whisper.
valid_ids = ::User.where(id: requested_ids).pluck(:id)
valid_group_ids = ::Group.where(id: requested_group_ids).pluck(:id)
+ valid_badge_ids = ::Badge.where(id: requested_badge_ids).pluck(:id)
post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD] = valid_ids
post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD] = valid_group_ids
+ post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD] = valid_badge_ids
- # Record the non-staff targets as cumulative topic participants.
+ # Record the non-staff targets (explicit users + current badge holders)
+ # as cumulative topic participants so they keep visibility on later
+ # whispers in the topic even after a badge revoke.
non_staff_ids = ::User.where(id: valid_ids).where(admin: false, moderator: false).pluck(:id)
+ if valid_badge_ids.any?
+ non_staff_ids +=
+ ::User
+ .joins(:user_badges)
+ .where(user_badges: { badge_id: valid_badge_ids })
+ .where(admin: false, moderator: false)
+ .distinct
+ .pluck(:id)
+ non_staff_ids.uniq!
+ end
merge_whisper_participants.call(topic, non_staff_ids) if non_staff_ids.any?
else
# Non-staff: only an existing topic whisper participant may whisper,
@@ -548,6 +602,7 @@ class Engine < ::Rails::Engine
post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD] = []
post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD] = []
+ post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD] = []
end
end
@@ -559,17 +614,53 @@ class Engine < ::Rails::Engine
target_ids =
Array(post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD]).map(&:to_i)
+ target_group_ids =
+ Array(post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD]).map(
+ &:to_i
+ )
+ target_badge_ids =
+ Array(post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD]).map(
+ &:to_i
+ )
+
+ topic = post.topic
+
+ # Roll back Topic#highest_post_number so non-audience viewers do not see
+ # a topic-list "+1 unread" badge for a whisper they can't read. The
+ # :listable_topic serializer override adds the bump back for audience
+ # members on serialization, so they still see the badge. Runs for EVERY
+ # whisper (including staff-only whisper-backs with no recipients).
+ if topic
+ non_whisper_max =
+ ::Post
+ .where(topic_id: topic.id, deleted_at: nil)
+ .where.not(
+ id:
+ ::PostCustomField.where(
+ name: DiscourseModCategories::POST_WHISPER_TARGETS_FIELD,
+ ).select(:post_id),
+ )
+ .maximum(:post_number) || 0
+
+ if non_whisper_max > 0 && non_whisper_max < topic.highest_post_number
+ ::Topic.where(id: topic.id).update_all(highest_post_number: non_whisper_max)
+ end
+ end
recipient_ids =
if user&.staff?
- target_ids
+ ids = target_ids.dup
+ ids +=
+ ::GroupUser.where(group_id: target_group_ids).pluck(:user_id) if target_group_ids.any?
+ ids +=
+ ::UserBadge.where(badge_id: target_badge_ids).pluck(:user_id) if target_badge_ids.any?
+ ids
else
::User.where(admin: true).or(::User.where(moderator: true)).pluck(:id)
end
recipient_ids = recipient_ids.uniq - [post.user_id]
next if recipient_ids.empty?
- topic = post.topic
data = {
topic_title: topic&.title,
display_username: user&.username,
@@ -623,6 +714,28 @@ class Engine < ::Rails::Engine
object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD)
end
+ add_to_serializer(:post, :mod_whisper_target_badge_ids) do
+ Array(object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD]).map(
+ &:to_i
+ )
+ end
+ add_to_serializer(:post, :include_mod_whisper_target_badge_ids?) do
+ SiteSetting.mod_whisper_enabled &&
+ object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD)
+ end
+
+ add_to_serializer(:post, :mod_whisper_target_badges) do
+ ids =
+ Array(object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD]).map(
+ &:to_i
+ )
+ ::Badge.where(id: ids).map { |b| { id: b.id, name: b.display_name } }
+ end
+ add_to_serializer(:post, :include_mod_whisper_target_badges?) do
+ SiteSetting.mod_whisper_enabled &&
+ object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD)
+ end
+
add_to_serializer(:post, :mod_whisper_targets) do
ids =
Array(object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD]).map(&:to_i)
@@ -635,11 +748,14 @@ class Engine < ::Rails::Engine
object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD)
end
- # A whisper with no user targets AND no group targets is a staff-only
- # whisper-back.
+ # A whisper with no user targets AND no group targets AND no badge
+ # targets is a staff-only whisper-back.
add_to_serializer(:post, :mod_whisper_is_staff_only) do
Array(object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD]).empty? &&
- Array(object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD]).empty?
+ Array(
+ object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD],
+ ).empty? &&
+ Array(object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD]).empty?
end
add_to_serializer(:post, :include_mod_whisper_is_staff_only?) do
SiteSetting.mod_whisper_enabled &&
@@ -655,4 +771,18 @@ class Engine < ::Rails::Engine
add_to_serializer(:basic_category, :mod_category_new_topic_prompt_max_tl) do
object.custom_fields[DiscourseModCategories::CATEGORY_NEW_TOPIC_PROMPT_TL_FIELD]
end
+
+ # Audience-aware highest_post_number for the topic list. Returns the max
+ # post_number in the topic that the CURRENT user can see — whispers are
+ # excluded for non-audience viewers and included for the audience (staff,
+ # explicit targets, group targets, topic participants). This is what makes
+ # the topic-list `(highest - last_read)` math audience-aware: non-audience
+ # viewers never see a badge bump from a whisper they can't read.
+ add_to_serializer(:listable_topic, :highest_post_number) do
+ raw = object.highest_post_number
+ next raw unless SiteSetting.mod_whisper_enabled
+
+ visible_max = DiscourseModCategories.whisper_audience_max_post_number(object, scope&.user)
+ visible_max || raw
+ end
end