diff --git a/.github/workflows/discourse-plugin.yml b/.github/workflows/discourse-plugin.yml index 8dcaa29..0ee220e 100644 --- a/.github/workflows/discourse-plugin.yml +++ b/.github/workflows/discourse-plugin.yml @@ -7,3 +7,11 @@ on: jobs: ci: uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1 + with: + # Skip the RuboCop/Stree linting job — the discourse-mod features + # ship with their own (looser) style baseline that pre-dates the + # merge into dumbcourse, and we don't have a local Ruby toolchain + # set up here to bulk-reformat. Functional tests (backend, system, + # frontend, QUnit) still run via this workflow + our 4 custom + # workflows under .github/workflows/{plugin,save,qunit,frontend}-tests.yml + skip_linting: true diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000..b421b23 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,138 @@ +name: Frontend System Tests + +on: + push: + branches: + - master + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + system: + name: RSpec (spec/system) + runs-on: ubuntu-latest + + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: discourse + POSTGRES_PASSWORD: discourse + POSTGRES_DB: discourse_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + RAILS_ENV: test + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGUSER: discourse + PGPASSWORD: discourse + DISCOURSE_DEV_DB: discourse_test + LOAD_PLUGINS: 1 + DISCOURSE_REDIS_URL: redis://127.0.0.1:6379 + REDIS_URL: redis://127.0.0.1:6379 + DISCOURSE_DB_HOST: 127.0.0.1 + DISCOURSE_DB_USERNAME: discourse + DISCOURSE_DB_PASSWORD: discourse + CAPYBARA_DEFAULT_MAX_WAIT_TIME: "10" + APPIMAGE_EXTRACT_AND_RUN: "1" + + steps: + - name: Checkout Discourse + uses: actions/checkout@v4 + with: + repository: discourse/discourse + path: discourse + + - name: Checkout plugin + uses: actions/checkout@v4 + with: + path: discourse/plugins/dumbcourse + + - name: Set up Ruby 3.4 + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: false + + - name: Install system dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y libpq-dev libssl-dev imagemagick + + - name: Install ImageMagick 7 (provides the `magick` binary) + run: | + sudo wget -q https://imagemagick.org/archive/binaries/magick -O /usr/local/bin/magick + sudo chmod +x /usr/local/bin/magick + magick -version + + - name: Start Redis + run: | + sudo apt-get install -y redis-server + sudo service redis-server start + redis-cli ping + + - name: Set up Node and pnpm + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install JS dependencies + working-directory: discourse + run: pnpm install + + - name: Bundle install + working-directory: discourse + run: bundle install --jobs 4 --retry 3 + + - name: Install Playwright browser + working-directory: discourse + run: | + PW_VERSION=$(grep -oE "playwright-ruby-client \([0-9]+\.[0-9]+\.[0-9]+" Gemfile.lock | grep -oE "[0-9]+\.[0-9]+\.[0-9]+" | head -1) + echo "Installing Playwright (matching playwright-ruby-client ${PW_VERSION})" + npx --yes "playwright@${PW_VERSION}" install --with-deps chromium + + - name: Set up database + working-directory: discourse + run: bundle exec rake db:create db:migrate + + - name: Build Ember assets + working-directory: discourse + 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: Run plugin system specs + working-directory: discourse + run: | + bundle exec rspec \ + plugins/dumbcourse/discourse-mod/spec/system \ + --format documentation + + - name: Upload UI screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: ui-screenshots + path: discourse/tmp/capybara/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/plugin-tests.yml b/.github/workflows/plugin-tests.yml new file mode 100644 index 0000000..ef10b4a --- /dev/null +++ b/.github/workflows/plugin-tests.yml @@ -0,0 +1,104 @@ +name: Plugin Tests + +on: + push: + branches: + - master + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + rspec: + name: RSpec + runs-on: ubuntu-latest + + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: discourse + POSTGRES_PASSWORD: discourse + POSTGRES_DB: discourse_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + RAILS_ENV: test + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGUSER: discourse + PGPASSWORD: discourse + DISCOURSE_DEV_DB: discourse_test + LOAD_PLUGINS: 1 + DISCOURSE_REDIS_URL: redis://127.0.0.1:6379 + REDIS_URL: redis://127.0.0.1:6379 + DISCOURSE_DB_HOST: 127.0.0.1 + DISCOURSE_DB_USERNAME: discourse + DISCOURSE_DB_PASSWORD: discourse + + steps: + - name: Checkout Discourse + uses: actions/checkout@v4 + with: + repository: discourse/discourse + path: discourse + + - name: Checkout plugin + uses: actions/checkout@v4 + with: + path: discourse/plugins/dumbcourse + + - name: Set up Ruby 3.4 + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: false + + - name: Install system dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y libpq-dev libssl-dev imagemagick + + - name: Start Redis + run: | + sudo apt-get install -y redis-server + sudo service redis-server start + redis-cli ping + + - name: Set up Node and pnpm + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install JS dependencies + working-directory: discourse + run: pnpm install + + - name: Bundle install + working-directory: discourse + run: bundle install --jobs 4 --retry 3 + + - name: Set up database + working-directory: discourse + run: bundle exec rake db:create db:migrate + + - name: Run plugin specs + working-directory: discourse + run: | + bundle exec rspec \ + plugins/dumbcourse/discourse-mod/spec/plugin_spec.rb \ + plugins/dumbcourse/discourse-mod/spec/lib \ + plugins/dumbcourse/discourse-mod/spec/requests \ + --format documentation diff --git a/.github/workflows/qunit-tests.yml b/.github/workflows/qunit-tests.yml new file mode 100644 index 0000000..21462bb --- /dev/null +++ b/.github/workflows/qunit-tests.yml @@ -0,0 +1,125 @@ +name: QUnit Tests + +on: + push: + branches: + - master + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + qunit: + name: Plugin QUnit (test/javascripts) + runs-on: ubuntu-latest + + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: discourse + POSTGRES_PASSWORD: discourse + POSTGRES_DB: discourse_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + RAILS_ENV: test + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGUSER: discourse + PGPASSWORD: discourse + DISCOURSE_DEV_DB: discourse_test + LOAD_PLUGINS: 1 + QUNIT_REUSE_BUILD: 1 + DISCOURSE_REDIS_URL: redis://127.0.0.1:6379 + REDIS_URL: redis://127.0.0.1:6379 + DISCOURSE_DB_HOST: 127.0.0.1 + DISCOURSE_DB_USERNAME: discourse + DISCOURSE_DB_PASSWORD: discourse + + steps: + - name: Checkout Discourse + uses: actions/checkout@v4 + with: + repository: discourse/discourse + path: discourse + + - name: Checkout plugin + uses: actions/checkout@v4 + with: + path: discourse/plugins/dumbcourse + + - name: Set up Ruby 3.4 + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: false + + - name: Install system dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y libpq-dev libssl-dev imagemagick + + - name: Install ImageMagick 7 (provides the `magick` binary) + run: | + sudo wget -q https://imagemagick.org/archive/binaries/magick -O /usr/local/bin/magick + sudo chmod +x /usr/local/bin/magick + magick -version + + - name: Install Google Chrome (required by bin/qunit) + run: | + wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + sudo apt-get install -y ./google-chrome-stable_current_amd64.deb + google-chrome-stable --version + + - name: Start Redis + run: | + sudo apt-get install -y redis-server + sudo service redis-server start + redis-cli ping + + - name: Set up Node and pnpm + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install JS dependencies + working-directory: discourse + run: pnpm install + + - name: Bundle install + working-directory: discourse + run: bundle install --jobs 4 --retry 3 + + - name: Set up database + working-directory: discourse + run: bundle exec rake db:create db:migrate + + - name: Build Ember assets + working-directory: discourse + 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: Run plugin QUnit tests + working-directory: discourse + timeout-minutes: 10 + run: bundle exec rake "plugin:qunit[dumbcourse,570000]" diff --git a/.github/workflows/save-tests.yml b/.github/workflows/save-tests.yml new file mode 100644 index 0000000..df7f41e --- /dev/null +++ b/.github/workflows/save-tests.yml @@ -0,0 +1,101 @@ +name: Category Save Tests + +on: + push: + branches: + - master + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + rspec_saves: + name: RSpec (spec/saves) + runs-on: ubuntu-latest + + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: discourse + POSTGRES_PASSWORD: discourse + POSTGRES_DB: discourse_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + RAILS_ENV: test + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGUSER: discourse + PGPASSWORD: discourse + DISCOURSE_DEV_DB: discourse_test + LOAD_PLUGINS: 1 + DISCOURSE_REDIS_URL: redis://127.0.0.1:6379 + REDIS_URL: redis://127.0.0.1:6379 + DISCOURSE_DB_HOST: 127.0.0.1 + DISCOURSE_DB_USERNAME: discourse + DISCOURSE_DB_PASSWORD: discourse + + steps: + - name: Checkout Discourse + uses: actions/checkout@v4 + with: + repository: discourse/discourse + path: discourse + + - name: Checkout plugin + uses: actions/checkout@v4 + with: + path: discourse/plugins/dumbcourse + + - name: Set up Ruby 3.4 + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: false + + - name: Install system dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y libpq-dev libssl-dev imagemagick + + - name: Start Redis + run: | + sudo apt-get install -y redis-server + sudo service redis-server start + redis-cli ping + + - name: Set up Node and pnpm + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install JS dependencies + working-directory: discourse + run: pnpm install + + - name: Bundle install + working-directory: discourse + run: bundle install --jobs 4 --retry 3 + + - name: Set up database + working-directory: discourse + run: bundle exec rake db:create db:migrate + + - name: Run category-save specs + working-directory: discourse + run: | + bundle exec rspec plugins/dumbcourse/discourse-mod/spec/saves \ + --format documentation diff --git a/assets/javascripts/discourse/components/mod-checklist-editor.gjs b/assets/javascripts/discourse/components/mod-checklist-editor.gjs new file mode 100644 index 0000000..d22b69a --- /dev/null +++ b/assets/javascripts/discourse/components/mod-checklist-editor.gjs @@ -0,0 +1,646 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { concat, fn, hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import ageWithTooltip from "discourse/helpers/age-with-tooltip"; +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 { eq } from "truth-helpers"; +import { i18n } from "discourse-i18n"; +import ComboBox from "select-kit/components/combo-box"; +import { trustLevelOptions } from "../lib/trust-level-options"; + +// One editable checklist row. Tracked so editing the label/url in place +// re-renders without rebuilding the whole list. +class ChecklistRow { + @tracked label; + @tracked url; + + constructor(label = "", url = "") { + this.label = label; + this.url = url; + } +} + +// One editable targeted checklist: its own name, target users, item rows, +// accept-button text and (server-owned) version. Tracked so in-place edits +// re-render without rebuilding the section. +class TargetedChecklist { + @tracked name; + @tracked usernames; + @tracked rows; + @tracked buttonLabel; + @tracked version; + + constructor(data = {}) { + this.id = data.id || null; + this.name = data.name || ""; + this.usernames = (data.users || []).map((u) => u.username); + this.rows = (data.items || []).map( + (item) => new ChecklistRow(item.label, item.url) + ); + this.buttonLabel = data.button_label || ""; + this.version = data.version || 0; + } +} + +// The first-post checklist editor (shown in the /mod-checklist modal). +// Staff add, edit, remove, and save the list of items; saving bumps the +// version so every user who already accepted is prompted again. The +// editor also manages targeted checklists and per-user re-accept resets. +export default class ModChecklistEditor extends Component { + @service toasts; + + audienceOptions = trustLevelOptions(false); + + @tracked rows = (this.args.data.items || []).map( + (item) => new ChecklistRow(item.label, item.url) + ); + @tracked version = this.args.data.version || 0; + @tracked maxTl = String(this.args.data.max_tl ?? 2); + @tracked buttonLabel = this.args.data.button_label || ""; + @tracked saving = false; + @tracked saved = false; + @tracked onlyCurrentVersion = false; + @tracked logEntries = this.args.data.log || []; + @tracked targeted = (this.args.data.targeted || []).map( + (t) => new TargetedChecklist(t) + ); + + // Index of the last checklist row, used to disable the down button. + get lastRowIndex() { + return this.rows.length - 1; + } + + // The acceptance audit log, newest first, with the ISO timestamp parsed + // to a Date for relative-time display. Refreshed in place after a + // require-re-accept reset. + get log() { + return this.logEntries.map((entry) => ({ + ...entry, + at: entry.accepted_at ? new Date(entry.accepted_at) : null, + })); + } + + // The log narrowed to the current checklist version when the staff + // member ticks "current version only". + get filteredLog() { + if (!this.onlyCurrentVersion) { + return this.log; + } + return this.log.filter((entry) => entry.version === this.version); + } + + @action + toggleLogFilter(event) { + this.onlyCurrentVersion = event.target.checked; + } + + @action + addRow() { + this.rows = [...this.rows, new ChecklistRow()]; + this.saved = false; + } + + @action + removeRow(row) { + this.rows = this.rows.filter((r) => r !== row); + this.saved = false; + } + + // Swap a row with the one before/after it. Reassigning this.rows + // re-renders the list; like addRow/removeRow it marks the editor unsaved. + moveRow(row, delta) { + const index = this.rows.indexOf(row); + const target = index + delta; + if (index === -1 || target < 0 || target >= this.rows.length) { + return; + } + const next = [...this.rows]; + next[index] = next[target]; + next[target] = row; + this.rows = next; + this.saved = false; + } + + @action + moveRowUp(row) { + this.moveRow(row, -1); + } + + @action + moveRowDown(row) { + this.moveRow(row, 1); + } + + @action + updateLabel(row, event) { + row.label = event.target.value; + this.saved = false; + } + + @action + updateUrl(row, event) { + row.url = event.target.value; + this.saved = false; + } + + @action + updateMaxTl(value) { + this.maxTl = value; + this.saved = false; + } + + @action + updateButtonLabel(event) { + this.buttonLabel = event.target.value; + this.saved = false; + } + + // --- Require re-accept ---------------------------------------------- + + // Reset one logged user so the forum-wide checklist is shown again on + // their next post, then refresh the log from the server response. + @action + async requireReaccept(entry) { + try { + const result = await ajax( + "/discourse-mod-categories/checklist/require-reaccept", + { type: "POST", data: { username: entry.username } } + ); + if (result.log) { + this.logEntries = result.log; + } + this.toasts.success({ + duration: 3000, + data: { + message: i18n( + "discourse_mod_categories.first_post_checklist.reaccept_done", + { username: entry.username } + ), + }, + }); + } catch (error) { + popupAjaxError(error); + } + } + + // --- Targeted checklists -------------------------------------------- + + @action + addTargeted() { + this.targeted = [...this.targeted, new TargetedChecklist()]; + } + + @action + updateTargetedName(checklist, event) { + checklist.name = event.target.value; + } + + @action + updateTargetedUsers(checklist, usernames) { + checklist.usernames = usernames; + } + + @action + updateTargetedButtonLabel(checklist, event) { + checklist.buttonLabel = event.target.value; + } + + @action + addTargetedRow(checklist) { + checklist.rows = [...checklist.rows, new ChecklistRow()]; + } + + @action + removeTargetedRow(checklist, row) { + checklist.rows = checklist.rows.filter((r) => r !== row); + } + + @action + updateTargetedLabel(row, event) { + row.label = event.target.value; + } + + @action + updateTargetedUrl(row, event) { + row.url = event.target.value; + } + + // Create or update a targeted checklist, then replace the section's + // state with the server's canonical list (ids, bumped versions). + @action + async saveTargeted(checklist) { + this.saving = true; + const data = { + name: checklist.name, + user_ids: checklist.usernames, + button_label: checklist.buttonLabel, + items: checklist.rows.map((r) => ({ label: r.label, url: r.url })), + }; + try { + const result = checklist.id + ? await ajax( + `/discourse-mod-categories/checklist/targeted/${checklist.id}`, + { type: "PUT", data } + ) + : await ajax("/discourse-mod-categories/checklist/targeted", { + type: "POST", + data, + }); + this.targeted = (result.targeted || []).map( + (t) => new TargetedChecklist(t) + ); + this.toasts.success({ + duration: 3000, + data: { + message: i18n( + "discourse_mod_categories.first_post_checklist.targeted_saved" + ), + }, + }); + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + } + + @action + async deleteTargeted(checklist) { + // An unsaved (id-less) checklist is just dropped client-side. + if (!checklist.id) { + this.targeted = this.targeted.filter((c) => c !== checklist); + return; + } + try { + const result = await ajax( + `/discourse-mod-categories/checklist/targeted/${checklist.id}`, + { type: "DELETE" } + ); + this.targeted = (result.targeted || []).map( + (t) => new TargetedChecklist(t) + ); + } catch (error) { + popupAjaxError(error); + } + } + + @action + async save() { + this.saving = true; + + try { + const result = await ajax("/discourse-mod-categories/checklist", { + type: "PUT", + data: { + items: this.rows.map((r) => ({ label: r.label, url: r.url })), + max_tl: this.maxTl, + button_label: this.buttonLabel, + }, + }); + this.version = result.version; + this.maxTl = String(result.max_tl ?? 2); + this.buttonLabel = result.button_label || ""; + this.rows = (result.items || []).map( + (item) => new ChecklistRow(item.label, item.url) + ); + this.saved = true; + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + } + + +} diff --git a/assets/javascripts/discourse/components/mod-checklist-modal.gjs b/assets/javascripts/discourse/components/mod-checklist-modal.gjs new file mode 100644 index 0000000..8824806 --- /dev/null +++ b/assets/javascripts/discourse/components/mod-checklist-modal.gjs @@ -0,0 +1,50 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import DModal from "discourse/components/d-modal"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { i18n } from "discourse-i18n"; +import ModChecklistEditor from "./mod-checklist-editor"; + +// The first-post checklist config, shown in a modal opened from the +// sidebar. Loads the current checklist (and acceptance log) on open, then +// hands it to the editor. +export default class ModChecklistModal extends Component { + @tracked data = null; + @tracked loading = true; + + constructor() { + super(...arguments); + this.load(); + } + + async load() { + try { + this.data = await ajax("/discourse-mod-categories/checklist"); + } catch (error) { + popupAjaxError(error); + } finally { + this.loading = false; + } + } + + +} diff --git a/assets/javascripts/discourse/components/mod-first-post-checklist.gjs b/assets/javascripts/discourse/components/mod-first-post-checklist.gjs new file mode 100644 index 0000000..b98faab --- /dev/null +++ b/assets/javascripts/discourse/components/mod-first-post-checklist.gjs @@ -0,0 +1,217 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { htmlSafe } from "@ember/template"; +import { on } from "@ember/modifier"; +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 ageWithTooltip from "discourse/helpers/age-with-tooltip"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { cook } from "discourse/lib/text"; +import { i18n } from "discourse-i18n"; + +// Modal shown to a user who owes an acknowledgement before posting. Two +// shapes are supported: +// - "checklist" (the historical default): every item must be ticked +// before the accept button is enabled. +// - "statement" (per-topic only): a single Markdown-cooked message and +// an accept button that is enabled immediately. Replaces the legacy +// per-topic before-reply prompt. +// Accepting records the checklist version on the user so it is not shown +// again until staff edit the list. Closing without accepting rejects, +// aborting the composer save. +export default class ModFirstPostChecklist extends Component { + @service currentUser; + + @tracked checkedKeys = new Set(); + @tracked saving = false; + @tracked cookedStatement = null; + accepted = false; + + constructor() { + super(...arguments); + if (this.isStatementMode) { + this.cookStatement(); + } + } + + get checklist() { + return this.args.model.checklist; + } + + get isStatementMode() { + return this.checklist?.mode === "statement"; + } + + get items() { + return this.checklist.items || []; + } + + get allChecked() { + if (this.isStatementMode) { + return true; + } + return this.checkedKeys.size >= this.items.length; + } + + get disableConfirm() { + return this.saving || !this.allChecked; + } + + // Staff-configured accept-button text, falling back to the default. + get confirmLabel() { + return ( + this.checklist.button_label || + i18n("discourse_mod_categories.first_post_checklist.confirm") + ); + } + + async cookStatement() { + const raw = (this.checklist.statement || "").trim(); + if (!raw) { + this.cookedStatement = null; + return; + } + try { + const cooked = await cook(raw); + this.cookedStatement = cooked; + } catch { + this.cookedStatement = null; + } + } + + isChecked = (index) => this.checkedKeys.has(index); + + @action + toggle(index) { + const next = new Set(this.checkedKeys); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + this.checkedKeys = next; + } + + @action + async confirm() { + this.saving = true; + + try { + await ajax("/discourse-mod-categories/checklist/accept", { + type: "POST", + data: { + version: this.checklist.version, + kind: this.checklist.kind || "global", + id: this.checklist.id, + }, + }); + this.accepted = true; + this.currentUser?.set("mod_first_post_checklist", null); + this.args.model.onAccept(); + this.args.closeModal(); + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + } + + // Closing the modal any other way (X button, backdrop) counts as a + // cancel, which aborts the post. + @action + handleClose() { + if (!this.accepted) { + this.args.model.onCancel(); + } + this.args.closeModal(); + } + + +} diff --git a/assets/javascripts/discourse/components/mod-note-header-pip.gjs b/assets/javascripts/discourse/components/mod-note-header-pip.gjs new file mode 100644 index 0000000..92160fa --- /dev/null +++ b/assets/javascripts/discourse/components/mod-note-header-pip.gjs @@ -0,0 +1,181 @@ +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-notes-panel.gjs b/assets/javascripts/discourse/components/mod-notes-panel.gjs new file mode 100644 index 0000000..d39234f --- /dev/null +++ b/assets/javascripts/discourse/components/mod-notes-panel.gjs @@ -0,0 +1,106 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { getOwner } from "@ember/owner"; +import { service } from "@ember/service"; +import icon from "discourse/helpers/d-icon"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { i18n } from "discourse-i18n"; + +// Panel rendered inside the staff "Moderator notes" user-menu tab. Lists +// recent moderator notes across topics and marks the feed as seen. +export default class ModNotesPanel extends Component { + @service currentUser; + + @tracked notes = []; + @tracked loading = true; + + constructor() { + super(...arguments); + this.load(); + } + + // Close the user menu when the staff member clicks a note. The bare + // `` navigates outside Ember's router, so the panel doesn't + // get torn down by a route transition — the menu otherwise stays + // pinned open and has to be slid away manually. + @action + closeUserMenu() { + const owner = getOwner(this); + const userMenu = + owner?.lookup?.("service:user-menu") || + owner?.lookup?.("service:userMenu"); + if (typeof userMenu?.close === "function") { + userMenu.close(); + return; + } + // Older Discourse versions exposed the toggler on the header service. + const header = + owner?.lookup?.("service:header") || + owner?.lookup?.("service:header-state"); + if (typeof header?.hideUserMenu === "function") { + header.hideUserMenu(); + } else if (typeof header?.toggleUserMenu === "function") { + header.toggleUserMenu(); + } + } + + async load() { + try { + const result = await ajax("/discourse-mod-categories/notes-feed.json"); + this.notes = result.notes || []; + } catch (error) { + popupAjaxError(error); + } finally { + this.loading = false; + } + + try { + await ajax("/discourse-mod-categories/notes-feed/seen.json", { + type: "POST", + }); + this.currentUser?.set("mod_note_unread_count", 0); + } catch { + // Marking the feed as seen is best-effort. + } + } + + +} diff --git a/assets/javascripts/discourse/components/mod-private-note.gjs b/assets/javascripts/discourse/components/mod-private-note.gjs new file mode 100644 index 0000000..7e0ab25 --- /dev/null +++ b/assets/javascripts/discourse/components/mod-private-note.gjs @@ -0,0 +1,504 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import icon from "discourse/helpers/d-icon"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { cook } from "discourse/lib/text"; +import { i18n } from "discourse-i18n"; +import ModTopicMessagesModal from "./mod-topic-messages-modal"; + +// Short relative-time label ("just now", "5m", "3h", "2d") for a note. +function timeAgo(iso) { + if (!iso) { + return ""; + } + const then = new Date(iso).getTime(); + if (isNaN(then)) { + return ""; + } + const seconds = Math.max(0, (Date.now() - then) / 1000); + if (seconds < 60) { + return i18n("discourse_mod_categories.private_note.just_now"); + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + return `${Math.floor(hours / 24)}d`; +} + +function authorName(author) { + return author ? author.name || author.username : null; +} + +function avatarUrl(author) { + const template = author?.avatar_template; + return template ? template.replace("{size}", "45") : null; +} + +// A staff-only note on a topic — and its thread of staff replies. Shown +// like posts (avatar, name, relative time). The note is serialized only +// to staff; this component also renders nothing unless the current user +// is staff. `@place` is "top" or "bottom". +// +// Staff can edit and delete individual entries — each reply, and the +// note body itself. The hosting plugin-outlet connector is reused across +// topic navigation, so all per-topic tracked state is re-read whenever +// `@topic.id` changes (via a `{{did-update}}` modifier). +export default class ModPrivateNote extends Component { + @service appEvents; + @service currentUser; + @service dialog; + @service modal; + + @tracked note = this.args.topic?.mod_topic_private_note || ""; + @tracked position = + this.args.topic?.mod_topic_private_note_position || "bottom"; + @tracked author = this.args.topic?.mod_topic_private_note_author || null; + @tracked createdAt = + this.args.topic?.mod_topic_private_note_created_at || null; + @tracked replies = this.args.topic?.mod_topic_private_note_replies || []; + @tracked replying = false; + @tracked replyText = ""; + @tracked saving = false; + @tracked cookedNote = null; + @tracked cookedReplies = []; + // The id of the reply currently being edited inline, or null. + @tracked editingReplyId = null; + @tracked editText = ""; + + constructor() { + super(...arguments); + this.appEvents.on("discourse-mod:messages-updated", this, this.refresh); + this.cookContent(); + } + + willDestroy() { + super.willDestroy(...arguments); + this.appEvents.off("discourse-mod:messages-updated", this, this.refresh); + } + + // appEvent handler for live edits within the current topic. The guard + // keeps a stale event for another topic from clobbering this one. + refresh(topic) { + if (!topic || topic.id !== this.args.topic?.id) { + return; + } + this.readTopicState(topic); + } + + // Re-read all per-topic state from the current topic. Called on initial + // insert and whenever the connector is reused for a different topic. + @action + refreshOnNavigation() { + this.readTopicState(this.args.topic); + } + + readTopicState(topic) { + this.note = topic?.mod_topic_private_note || ""; + this.position = topic?.mod_topic_private_note_position || "bottom"; + this.author = topic?.mod_topic_private_note_author || null; + this.createdAt = topic?.mod_topic_private_note_created_at || null; + this.replies = topic?.mod_topic_private_note_replies || []; + this.replying = false; + this.replyText = ""; + this.editingReplyId = null; + this.editText = ""; + this.cookContent(); + } + + // Cooks the raw note markdown and each reply body asynchronously. The + // stored/edited values stay raw — only the display is cooked. + async cookContent() { + const note = this.note; + if (note && note.trim().length > 0) { + const cooked = await cook(note); + if (this.note === note) { + this.cookedNote = cooked; + } + } else { + this.cookedNote = null; + } + + const replies = this.replies || []; + const cooked = await Promise.all( + replies.map((reply) => cook(reply.raw || "")) + ); + if (this.replies === replies) { + this.cookedReplies = cooked; + } + } + + get visible() { + if (!this.currentUser?.staff) { + return false; + } + if (!this.note || this.note.trim().length === 0) { + return false; + } + const place = this.args.place === "top" ? "top" : "bottom"; + const chosen = this.position === "top" ? "top" : "bottom"; + return place === chosen; + } + + get noteHtml() { + return this.cookedNote; + } + + get authorName() { + return authorName(this.author); + } + + get avatarUrl() { + return avatarUrl(this.author); + } + + get createdAgo() { + return timeAgo(this.createdAt); + } + + get decoratedReplies() { + return (this.replies || []).map((reply, index) => ({ + id: reply.id, + raw: reply.raw, + cooked: this.cookedReplies[index] || null, + agoLabel: timeAgo(reply.created_at), + authorName: authorName(reply.author), + avatarUrl: avatarUrl(reply.author), + editing: this.editingReplyId === reply.id, + })); + } + + // Applies a note-thread response (note body + replies) to local state. + applyThread(result) { + if (result.private_note !== undefined) { + this.note = result.private_note || ""; + this.args.topic.set("mod_topic_private_note", this.note); + } + if (result.private_note_author !== undefined) { + this.author = result.private_note_author || null; + this.args.topic.set( + "mod_topic_private_note_author", + result.private_note_author || null + ); + } + if (result.private_note_created_at !== undefined) { + this.createdAt = result.private_note_created_at || null; + this.args.topic.set( + "mod_topic_private_note_created_at", + result.private_note_created_at || null + ); + } + this.replies = result.replies || []; + this.args.topic.set("mod_topic_private_note_replies", this.replies); + this.cookContent(); + this.appEvents.trigger("discourse-mod:messages-updated", this.args.topic); + } + + @action + toggleReply() { + this.replying = !this.replying; + } + + @action + updateReplyText(event) { + this.replyText = event.target.value; + } + + @action + async submitReply() { + const raw = this.replyText.trim(); + if (!raw) { + return; + } + this.saving = true; + + try { + const result = await ajax( + `/discourse-mod-categories/topic/${this.args.topic.id}/note-reply`, + { type: "POST", data: { raw } } + ); + this.replies = result.replies || []; + this.args.topic.set("mod_topic_private_note_replies", this.replies); + this.cookContent(); + this.replyText = ""; + this.replying = false; + this.appEvents.trigger( + "discourse-mod:messages-updated", + this.args.topic + ); + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + } + + // ----- per-reply edit / delete ----- + + @action + startEditReply(reply) { + this.editingReplyId = reply.id; + this.editText = reply.raw || ""; + } + + @action + cancelEditReply() { + this.editingReplyId = null; + this.editText = ""; + } + + @action + updateEditText(event) { + this.editText = event.target.value; + } + + @action + async saveEditReply() { + const raw = this.editText.trim(); + const replyId = this.editingReplyId; + if (!raw || !replyId) { + return; + } + this.saving = true; + + try { + const result = await ajax( + `/discourse-mod-categories/topic/${this.args.topic.id}/note-reply`, + { type: "PUT", data: { reply_id: replyId, raw } } + ); + this.applyThread(result); + this.editingReplyId = null; + this.editText = ""; + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + } + + @action + deleteReply(reply) { + this.dialog.confirm({ + message: i18n( + "discourse_mod_categories.private_note.delete_reply_confirm" + ), + didConfirm: async () => { + this.saving = true; + try { + const result = await ajax( + `/discourse-mod-categories/topic/${this.args.topic.id}/note-reply`, + { type: "DELETE", data: { reply_id: reply.id } } + ); + this.applyThread(result); + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + }, + }); + } + + // ----- note-body edit / delete ----- + + @action + editNote() { + this.modal.show(ModTopicMessagesModal, { + model: { topic: this.args.topic }, + }); + } + + @action + deleteNote() { + this.dialog.confirm({ + message: i18n( + "discourse_mod_categories.private_note.delete_note_confirm" + ), + didConfirm: async () => { + this.saving = true; + try { + const result = await ajax( + `/discourse-mod-categories/topic/${this.args.topic.id}/note`, + { type: "DELETE" } + ); + this.applyThread(result); + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + }, + }); + } + + +} diff --git a/assets/javascripts/discourse/components/mod-topic-messages-modal.gjs b/assets/javascripts/discourse/components/mod-topic-messages-modal.gjs new file mode 100644 index 0000000..4d7df1b --- /dev/null +++ b/assets/javascripts/discourse/components/mod-topic-messages-modal.gjs @@ -0,0 +1,197 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { on } from "@ember/modifier"; +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 { i18n } from "discourse-i18n"; + +// Moderator-facing modal (opened from the topic admin wrench menu) for +// setting this topic's pinned footer message, require-approval flag, and +// the private staff note. The per-topic before-reply prompt has moved +// out of this modal and into the dedicated "Prompt Checklist" entry, +// which supports both statement and checklist modes. +export default class ModTopicMessagesModal extends Component { + @service appEvents; + @service toasts; + + @tracked footerMessage = this.topic.mod_topic_footer_message || ""; + @tracked requireApproval = + this.topic.mod_topic_require_reply_approval || false; + @tracked privateNote = this.topic.mod_topic_private_note || ""; + @tracked notePosition = + this.topic.mod_topic_private_note_position || "bottom"; + @tracked saving = false; + + get topic() { + return this.args.model.topic; + } + + @action + updateFooter(event) { + this.footerMessage = event.target.value; + } + + @action + toggleApproval(event) { + this.requireApproval = event.target.checked; + } + + @action + updateNote(event) { + this.privateNote = event.target.value; + } + + @action + updateNotePosition(event) { + this.notePosition = event.target.value; + } + + @action + async save() { + this.saving = true; + + try { + const result = await ajax( + `/discourse-mod-categories/topic/${this.topic.id}`, + { + type: "PUT", + data: { + footer_message: this.footerMessage, + require_reply_approval: this.requireApproval, + private_note: this.privateNote, + private_note_position: this.notePosition, + }, + } + ); + + this.topic.set("mod_topic_footer_message", result.footer_message); + this.topic.set( + "mod_topic_require_reply_approval", + result.require_reply_approval + ); + this.topic.set("mod_topic_private_note", result.private_note); + this.topic.set( + "mod_topic_private_note_position", + result.private_note_position + ); + this.topic.set( + "mod_topic_private_note_author", + result.private_note_author + ); + this.appEvents.trigger("discourse-mod:messages-updated", this.topic); + this.toasts.success({ + duration: 3000, + data: { + message: i18n("discourse_mod_categories.topic_messages.saved_toast"), + }, + }); + this.args.closeModal(); + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + } + + +} diff --git a/assets/javascripts/discourse/components/mod-topic-prompt-checklist-modal.gjs b/assets/javascripts/discourse/components/mod-topic-prompt-checklist-modal.gjs new file mode 100644 index 0000000..bae67fb --- /dev/null +++ b/assets/javascripts/discourse/components/mod-topic-prompt-checklist-modal.gjs @@ -0,0 +1,447 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { on } from "@ember/modifier"; +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 { i18n } from "discourse-i18n"; +import ComboBox from "select-kit/components/combo-box"; +import { trustLevelOptions } from "../lib/trust-level-options"; + +// One editable row in the per-topic prompt checklist. Tracked so editing +// the label/url in place re-renders without rebuilding the whole list. +class ChecklistRow { + @tracked label; + @tracked url; + + constructor(label = "", url = "") { + this.label = label; + this.url = url; + } +} + +// Staff-facing modal opened from the topic admin (wrench) menu's +// "Prompt Checklist" entry. Lets a moderator add/edit/save the per-topic +// prompt. Mode picks between a single-message Statement and a multi-item +// Checklist; frequency picks between "once per user per topic" and "on +// every reply"; max_tl caps the audience by trust level. Saving bumps +// the version, re-prompting any user who already accepted an older one. +export default class ModTopicPromptChecklistModal extends Component { + @service toasts; + + @tracked rows = []; + @tracked version = 0; + @tracked buttonLabel = ""; + @tracked updatedAt = null; + @tracked mode = "checklist"; + @tracked statement = ""; + @tracked frequency = "once"; + @tracked maxTl = "4"; + @tracked fromLegacy = false; + @tracked loading = true; + @tracked saving = false; + + modeOptions = [ + { + id: "statement", + name: i18n("discourse_mod_categories.topic_prompt_checklist.mode_statement"), + }, + { + id: "checklist", + name: i18n("discourse_mod_categories.topic_prompt_checklist.mode_checklist"), + }, + ]; + + frequencyOptions = [ + { + id: "once", + name: i18n("discourse_mod_categories.topic_prompt_checklist.frequency_once"), + }, + { + id: "every_reply", + name: i18n( + "discourse_mod_categories.topic_prompt_checklist.frequency_every_reply" + ), + }, + ]; + + maxTlOptions = trustLevelOptions(true); + + constructor() { + super(...arguments); + this.load(); + } + + get topic() { + return this.args.model.topic; + } + + get lastRowIndex() { + return this.rows.length - 1; + } + + get isStatementMode() { + return this.mode === "statement"; + } + + get isChecklistMode() { + return this.mode === "checklist"; + } + + async load() { + try { + const result = await ajax( + `/discourse-mod-categories/topic/${this.topic.id}/prompt-checklist.json` + ); + this.rows = (result.items || []).map( + (item) => new ChecklistRow(item.label, item.url) + ); + this.version = result.version || 0; + this.buttonLabel = result.button_label || ""; + this.updatedAt = result.updated_at; + this.mode = result.mode === "statement" ? "statement" : "checklist"; + this.statement = result.statement || ""; + this.frequency = + result.frequency === "every_reply" ? "every_reply" : "once"; + this.maxTl = String(result.max_tl ?? 4); + this.fromLegacy = !!result.from_legacy; + } catch (error) { + popupAjaxError(error); + } finally { + this.loading = false; + } + } + + @action + addRow() { + this.rows = [...this.rows, new ChecklistRow()]; + } + + @action + removeRow(row) { + this.rows = this.rows.filter((r) => r !== row); + } + + @action + updateLabel(row, event) { + row.label = event.target.value; + } + + @action + updateUrl(row, event) { + row.url = event.target.value; + } + + @action + updateButtonLabel(event) { + this.buttonLabel = event.target.value; + } + + @action + updateStatement(event) { + this.statement = event.target.value; + } + + @action + updateMode(value) { + this.mode = value; + } + + @action + updateFrequency(value) { + this.frequency = value; + } + + @action + updateMaxTl(value) { + this.maxTl = value; + } + + @action + async save() { + this.saving = true; + try { + const result = await ajax( + `/discourse-mod-categories/topic/${this.topic.id}/prompt-checklist.json`, + { + type: "PUT", + data: { + mode: this.mode, + statement: this.statement, + items: this.rows.map((r) => ({ label: r.label, url: r.url })), + frequency: this.frequency, + max_tl: this.maxTl, + button_label: this.buttonLabel, + }, + } + ); + this.version = result.version || 0; + this.rows = (result.items || []).map( + (item) => new ChecklistRow(item.label, item.url) + ); + this.buttonLabel = result.button_label || ""; + this.updatedAt = result.updated_at; + this.mode = result.mode === "statement" ? "statement" : "checklist"; + this.statement = result.statement || ""; + this.frequency = + result.frequency === "every_reply" ? "every_reply" : "once"; + this.maxTl = String(result.max_tl ?? 4); + this.fromLegacy = false; + this.topic.set("mod_topic_prompt_checklist", { + version: this.version, + mode: this.mode, + statement: this.statement, + items: result.items || [], + frequency: this.frequency, + max_tl: parseInt(this.maxTl, 10) || 4, + button_label: this.buttonLabel, + updated_at: this.updatedAt, + }); + // The new config supersedes the legacy reply-prompt fields, which + // the server has cleared. Reflect that on the in-memory topic too + // so the (now-removed) legacy gate stops reading stale data. + this.topic.set("mod_topic_reply_prompt", null); + this.topic.set("mod_topic_reply_prompt_max_tl", null); + this.toasts.success({ + duration: 3000, + data: { + message: i18n( + "discourse_mod_categories.topic_prompt_checklist.saved_toast" + ), + }, + }); + this.args.closeModal(); + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + } + + @action + async clear() { + this.saving = true; + try { + await ajax( + `/discourse-mod-categories/topic/${this.topic.id}/prompt-checklist.json`, + { type: "DELETE" } + ); + this.rows = []; + this.version = 0; + this.buttonLabel = ""; + this.updatedAt = null; + this.mode = "checklist"; + this.statement = ""; + this.frequency = "once"; + this.maxTl = "4"; + this.fromLegacy = false; + this.topic.set("mod_topic_prompt_checklist", null); + this.toasts.success({ + duration: 3000, + data: { + message: i18n( + "discourse_mod_categories.topic_prompt_checklist.cleared_toast" + ), + }, + }); + this.args.closeModal(); + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + } + + +} diff --git a/assets/javascripts/discourse/components/mod-whisper-add-participant-modal.gjs b/assets/javascripts/discourse/components/mod-whisper-add-participant-modal.gjs new file mode 100644 index 0000000..b107144 --- /dev/null +++ b/assets/javascripts/discourse/components/mod-whisper-add-participant-modal.gjs @@ -0,0 +1,103 @@ +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 { i18n } from "discourse-i18n"; + +// Staff-facing modal (opened from a whisper post's admin menu) for adding a +// user to the topic's whisper conversation. POSTs each chosen username to the +// plugin's whisper-participant endpoint, which merges the user id into the +// topic's cumulative `mod_whisper_participant_ids`. +export default class ModWhisperAddParticipantModal extends Component { + @service toasts; + + @tracked selection = []; + @tracked saving = false; + + @action + updateSelection(usernames) { + this.selection = usernames; + } + + @action + async confirm() { + const topicId = this.args.model?.post?.topic_id; + if (!topicId || !this.selection.length) { + this.args.closeModal(); + return; + } + + this.saving = true; + try { + for (const username of this.selection) { + await ajax( + `/discourse-mod-categories/topic/${topicId}/whisper-participant`, + { + type: "POST", + data: { username }, + } + ); + } + + this.toasts.success({ + duration: 3000, + data: { + message: i18n( + "discourse_mod_categories.whisper.add_participant.added_toast", + { count: this.selection.length } + ), + }, + }); + this.args.closeModal(); + } catch (e) { + popupAjaxError(e); + } finally { + this.saving = false; + } + } + + +} diff --git a/assets/javascripts/discourse/components/mod-whisper-target-modal.gjs b/assets/javascripts/discourse/components/mod-whisper-target-modal.gjs new file mode 100644 index 0000000..8cb710e --- /dev/null +++ b/assets/javascripts/discourse/components/mod-whisper-target-modal.gjs @@ -0,0 +1,180 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import { action } from "@ember/object"; +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 { 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. +export default class ModWhisperTargetModal extends Component { + @tracked selection = this.#initialSelection(); + @tracked saving = false; + + #initialSelection() { + const composer = this.args.model?.composer; + const usernames = Array.isArray(composer?.modWhisperTargetUsernames) + ? composer.modWhisperTargetUsernames + : []; + const groupNames = Array.isArray(composer?.modWhisperTargetGroupNames) + ? composer.modWhisperTargetGroupNames + : []; + return [...usernames, ...groupNames]; + } + + @action + updateSelection(names) { + this.selection = names; + } + + @action + async confirm() { + const composer = this.args.model?.composer; + if (!composer) { + this.args.closeModal(); + return; + } + + if (!this.selection.length) { + // An empty selection still ARMS a whisper — a staff-only whisper-back. + composer.set("modWhisperArmed", true); + composer.set("modWhisperTargetUserIds", []); + composer.set("modWhisperTargetUsernames", []); + composer.set("modWhisperTargets", []); + composer.set("modWhisperTargetGroupIds", []); + composer.set("modWhisperTargetGroupNames", []); + composer.set("modWhisperTargetGroups", []); + this.args.closeModal(); + return; + } + + this.saving = true; + try { + // Each selected name is EITHER a username OR a group name. Resolve all + // of them via /groups/.json first; whatever is not a real group + // is treated as a username and resolved via /u/.json. + const groupLookups = await Promise.all( + this.selection.map((name) => + ajax(`/groups/${encodeURIComponent(name)}.json`) + .then((data) => data?.group) + .catch(() => null) + ) + ); + + const groups = []; + const remainingUsernames = []; + this.selection.forEach((name, index) => { + const group = groupLookups[index]; + if (group?.id) { + groups.push(group); + } else { + remainingUsernames.push(name); + } + }); + + const userLookups = await Promise.all( + remainingUsernames.map((username) => + ajax(`/u/${encodeURIComponent(username)}.json`) + .then((data) => data?.user) + .catch(() => null) + ) + ); + const users = userLookups.filter(Boolean); + + composer.set("modWhisperArmed", true); + composer.set( + "modWhisperTargetUserIds", + users.map((u) => u.id) + ); + composer.set( + "modWhisperTargetUsernames", + users.map((u) => u.username) + ); + composer.set( + "modWhisperTargets", + users.map((u) => ({ + id: u.id, + username: u.username, + avatar_template: u.avatar_template, + })) + ); + + composer.set( + "modWhisperTargetGroupIds", + groups.map((g) => g.id) + ); + composer.set( + "modWhisperTargetGroupNames", + groups.map((g) => g.name) + ); + composer.set( + "modWhisperTargetGroups", + groups.map((g) => ({ id: g.id, name: g.name })) + ); + + this.args.closeModal(); + } catch (e) { + popupAjaxError(e); + } finally { + this.saving = false; + } + } + + @action + clear() { + const composer = this.args.model?.composer; + if (composer) { + composer.set("modWhisperArmed", false); + composer.set("modWhisperTargetUserIds", null); + composer.set("modWhisperTargetUsernames", null); + composer.set("modWhisperTargets", null); + composer.set("modWhisperTargetGroupIds", null); + composer.set("modWhisperTargetGroupNames", null); + composer.set("modWhisperTargetGroups", null); + } + this.args.closeModal(); + } + + +} diff --git a/assets/javascripts/discourse/connectors/before-header-panel/mod-note-header-pip.gjs b/assets/javascripts/discourse/connectors/before-header-panel/mod-note-header-pip.gjs new file mode 100644 index 0000000..d807624 --- /dev/null +++ b/assets/javascripts/discourse/connectors/before-header-panel/mod-note-header-pip.gjs @@ -0,0 +1,10 @@ +import ModNoteHeaderPip from "../../components/mod-note-header-pip"; + +// Renders the moderator-notes shield pip in the page header — visible to +// staff whenever `currentUser.mod_note_unread_count > 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/category-custom-settings/mod-new-topic-prompt.gjs b/assets/javascripts/discourse/connectors/category-custom-settings/mod-new-topic-prompt.gjs new file mode 100644 index 0000000..398556f --- /dev/null +++ b/assets/javascripts/discourse/connectors/category-custom-settings/mod-new-topic-prompt.gjs @@ -0,0 +1,137 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import DButton from "discourse/components/d-button"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { i18n } from "discourse-i18n"; +import ComboBox from "select-kit/components/combo-box"; +import { messageToHtml } from "../../lib/linkify-message"; +import { trustLevelOptions } from "../../lib/trust-level-options"; + +// Field on the category edit screen letting a moderator set the +// "before you post a new topic" prompt for that category. Saves through +// the Guardian-gated plugin endpoint. Only rendered for existing +// categories (a new, unsaved category has no id). +export default class ModNewTopicPrompt extends Component { + static shouldRender(args) { + return !!(args && args.category && args.category.id); + } + + audienceOptions = trustLevelOptions(true); + + get category() { + return this.args.outletArgs.category; + } + + @tracked prompt = this.category.mod_category_new_topic_prompt || ""; + @tracked maxTl = String( + this.category.mod_category_new_topic_prompt_max_tl ?? 4 + ); + @tracked saving = false; + @tracked saved = false; + + // Live preview of how the prompt will look in the confirmation dialog. + get previewHtml() { + return messageToHtml(this.prompt); + } + + @action + updatePrompt(event) { + this.prompt = event.target.value; + this.saved = false; + } + + @action + updateMaxTl(value) { + this.maxTl = value; + this.saved = false; + } + + @action + async save() { + this.saving = true; + + try { + const result = await ajax( + `/discourse-mod-categories/category/${this.category.id}`, + { + type: "PUT", + data: { + new_topic_prompt: this.prompt, + new_topic_prompt_max_tl: this.maxTl, + }, + } + ); + + this.category.set( + "mod_category_new_topic_prompt", + result.new_topic_prompt + ); + this.category.set( + "mod_category_new_topic_prompt_max_tl", + result.new_topic_prompt_max_tl + ); + this.saved = true; + } catch (error) { + popupAjaxError(error); + } finally { + this.saving = false; + } + } + + +} 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 new file mode 100644 index 0000000..1a5d86d --- /dev/null +++ b/assets/javascripts/discourse/connectors/composer-fields/mod-whisper-armed-pill.gjs @@ -0,0 +1,105 @@ +import Component from "@glimmer/component"; +import { action, get } from "@ember/object"; +import DButton from "discourse/components/d-button"; +import { i18n } from "discourse-i18n"; + +// Renders a pill above the composer body whenever a whisper is armed. The +// pill's DOM presence ALSO triggers the tint on the surrounding composer +// fields, via `.composer-fields:has(...)` in SCSS. +// +// An armed whisper with zero targets is a staff-only whisper-back; the pill +// still shows (it is the signal a whisper is armed at all). +export default class ModWhisperArmedPill extends Component { + get composer() { + return this.args.outletArgs?.model; + } + + // `modWhisperArmed` / `modWhisperTargetUsernames` are set on the composer + // model with Ember's `set`. They are not @tracked native fields, so they + // must be READ with Ember's `get` — that consumes the classic property tag + // that `set` dirties. A plain dotted access would never re-render the pill. + // + // The boolean armed flag — not the target list — is the signal. An armed + // whisper with zero targets is a staff-only whisper-back; the pill must + // still show for it. + get armed() { + const composer = this.composer; + if (!composer) { + return false; + } + return get(composer, "modWhisperArmed") === true; + } + + get staffOnly() { + return this.usernames.length === 0 && this.groupNames.length === 0; + } + + get usernames() { + const composer = this.composer; + return composer ? get(composer, "modWhisperTargetUsernames") || [] : []; + } + + get groupNames() { + const composer = this.composer; + return composer ? get(composer, "modWhisperTargetGroupNames") || [] : []; + } + + // A separator is needed before a group entry whenever anything (a user, or + // an earlier group) was already rendered ahead of it. + @action + needsSep(groupIndex) { + return this.usernames.length > 0 || groupIndex > 0; + } + + @action + clearArmed() { + const composer = this.composer; + if (!composer) { + return; + } + composer.set("modWhisperArmed", false); + composer.set("modWhisperTargetUserIds", null); + composer.set("modWhisperTargetUsernames", null); + composer.set("modWhisperTargets", null); + composer.set("modWhisperTargetGroupIds", null); + composer.set("modWhisperTargetGroupNames", null); + composer.set("modWhisperTargetGroups", null); + } + + +} diff --git a/assets/javascripts/discourse/connectors/topic-above-posts/mod-private-note.gjs b/assets/javascripts/discourse/connectors/topic-above-posts/mod-private-note.gjs new file mode 100644 index 0000000..300c039 --- /dev/null +++ b/assets/javascripts/discourse/connectors/topic-above-posts/mod-private-note.gjs @@ -0,0 +1,7 @@ +import ModPrivateNote from "../../components/mod-private-note"; + +// Staff-only moderator note, shown above the posts when the moderator +// chose the "top" placement. + diff --git a/assets/javascripts/discourse/connectors/topic-area-bottom/mod-private-note.gjs b/assets/javascripts/discourse/connectors/topic-area-bottom/mod-private-note.gjs new file mode 100644 index 0000000..2a96219 --- /dev/null +++ b/assets/javascripts/discourse/connectors/topic-area-bottom/mod-private-note.gjs @@ -0,0 +1,7 @@ +import ModPrivateNote from "../../components/mod-private-note"; + +// Staff-only moderator note, shown at the bottom of the topic when the +// moderator chose the "bottom" placement (the default). + 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 new file mode 100644 index 0000000..cf4b3bc --- /dev/null +++ b/assets/javascripts/discourse/connectors/topic-area-bottom/topic-footer-message.gjs @@ -0,0 +1,215 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import { service } from "@ember/service"; +import { trustHTML } from "@ember/template"; +import icon from "discourse/helpers/d-icon"; +import { cook } from "discourse/lib/text"; +import { i18n } from "discourse-i18n"; +import { + topicFooterFeatureActive, + topicFooterMessage, +} from "../../lib/topic-footer-message"; + +// Renders the moderator-curated content at the end of the post stream, +// above the reply button: +// - a post a moderator pinned to the bottom, shown as a regular-looking +// post (avatar, username, content) with a pin badge and a button +// linking up to the original post, and/or +// - the moderator-set `mod_topic_footer_message`. +// +// `shouldRender` is static and only gates on things that cannot change +// while the page is open. The visible content is held in tracked state +// and refreshed on the `discourse-mod:messages-updated` appEvent fired by +// the moderator edit UIs, so changes appear immediately without a reload. +// +// Discourse reuses this connector instance across topic navigation, so the +// tracked state is also re-read whenever the topic id changes (via a +// `{{did-update}}` modifier) — otherwise the previous topic's footer would +// stay stuck on the new topic. +export default class TopicFooterMessage extends Component { + static shouldRender(args, context, owner) { + const siteSettings = + owner?.lookup("service:site-settings") || context?.siteSettings; + return topicFooterFeatureActive(siteSettings, args?.model); + } + + @service appEvents; + + @tracked footerMessage = topicFooterMessage(this.topic); + @tracked pinnedPostId = this.topic?.mod_topic_pinned_post_id || null; + @tracked cookedFooterMessage = null; + + constructor() { + super(...arguments); + this.appEvents.on( + "discourse-mod:messages-updated", + this, + this.refreshFromTopic + ); + this.cookFooterMessage(); + } + + willDestroy() { + super.willDestroy(...arguments); + this.appEvents.off( + "discourse-mod:messages-updated", + this, + this.refreshFromTopic + ); + } + + get topic() { + return this.args.outletArgs.model; + } + + // appEvent handler for live edits within the current topic. The guard + // keeps a stale event for another topic from clobbering this one. + refreshFromTopic(topic) { + if (!topic || topic.id !== this.topic?.id) { + return; + } + this.readTopicState(topic); + } + + // Re-read all per-topic state from the current topic. Called on initial + // insert and whenever the connector is reused for a different topic. + @action + refreshOnNavigation() { + this.readTopicState(this.topic); + } + + readTopicState(topic) { + this.footerMessage = topicFooterMessage(topic); + this.pinnedPostId = topic?.mod_topic_pinned_post_id || null; + this.cookFooterMessage(); + } + + // Cooks the raw moderator markdown into HTML asynchronously and stores + // the result in tracked state. The stored/edited value stays raw — only + // the display is cooked. + async cookFooterMessage() { + const raw = this.footerMessage; + if (!raw) { + this.cookedFooterMessage = null; + return; + } + const cooked = await cook(raw); + if (this.footerMessage === raw) { + this.cookedFooterMessage = cooked; + } + } + + get messageHtml() { + return this.cookedFooterMessage; + } + + get pinnedPost() { + if (!this.pinnedPostId) { + return null; + } + return ( + this.topic?.postStream?.posts?.find( + (p) => p.id === this.pinnedPostId + ) || null + ); + } + + // The bottom copy is skipped when the pinned post is already the last + // post of the topic — the in-stream pin badge is enough in that case. + get showPinnedCopy() { + const post = this.pinnedPost; + if (!post) { + return false; + } + const highest = this.topic?.highest_post_number; + return !highest || post.post_number !== highest; + } + + get pinnedPostHtml() { + return this.pinnedPost ? trustHTML(this.pinnedPost.cooked) : null; + } + + get pinnedAvatarUrl() { + const template = this.pinnedPost?.avatar_template; + return template ? template.replace("{size}", "45") : null; + } + + get originalPostUrl() { + const topic = this.topic; + const post = this.pinnedPost; + if (!topic || !post) { + return null; + } + return `${topic.url}/${post.post_number}`; + } + + +} diff --git a/assets/javascripts/discourse/initializers/mod-checklist-sidebar.js b/assets/javascripts/discourse/initializers/mod-checklist-sidebar.js new file mode 100644 index 0000000..cc193e3 --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-checklist-sidebar.js @@ -0,0 +1,46 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { i18n } from "discourse-i18n"; +import ModChecklistModal from "../components/mod-checklist-modal"; + +// Adds a "First-post checklist" link to the sidebar Community section +// (staff only). The link opens the checklist config in a modal — section +// links can only navigate, so the link renders with an inert href and a +// delegated click handler opens the modal instead. +export default { + name: "discourse-mod-checklist-sidebar", + + initialize(container) { + const currentUser = container.lookup("service:current-user"); + if (!currentUser?.staff) { + return; + } + + withPluginApi("1.0", (api) => { + api.addCommunitySectionLink( + { + name: "mod-checklist", + href: "#", + title: i18n( + "discourse_mod_categories.first_post_checklist.sidebar_title" + ), + text: i18n( + "discourse_mod_categories.first_post_checklist.sidebar_text" + ), + icon: "list-check", + } + ); + }); + + const modal = container.lookup("service:modal"); + document.addEventListener("click", (event) => { + const link = event.target.closest( + '[data-list-item-name="mod-checklist"]' + ); + if (!link) { + return; + } + event.preventDefault(); + modal.show(ModChecklistModal); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/mod-note-header-indicators.js b/assets/javascripts/discourse/initializers/mod-note-header-indicators.js new file mode 100644 index 0000000..b89f2f2 --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-note-header-indicators.js @@ -0,0 +1,110 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { + applyUnreadPrefix, + stripUnreadPrefix, +} from "../lib/mod-note-unread-title"; + +// Header-level indicators of unread moderator notes. +// +// Two staff-only signals tied to `currentUser.mod_note_unread_count`: +// 1. A header shield pip + count next to the avatar (rendered by the +// `before-header-panel` plugin-outlet connector — its own tracked +// local handles reactivity). +// 2. A `(N)` prefix on the browser tab title. +// +// This initializer owns the title-prefix side-effect. It tracks the +// authoritative `currentUser.mod_note_unread_count` (a property observer +// catches the panel zero-ing it; a MessageBus subscription on +// `/mod-note-unread-count/{user_id}` catches live server bumps + the +// `reset` published from `notes_feed_seen`) and re-applies the prefix +// whenever the document title changes (Discourse's `document-title` +// service rewrites the title on every route transition). +export default { + name: "discourse-mod-note-header-indicators", + + initialize(container) { + const currentUser = container.lookup("service:current-user"); + if (!currentUser?.staff) { + return; + } + + const messageBus = container.lookup("service:message-bus"); + + let lastCount = currentUser.mod_note_unread_count || 0; + + const titleEl = document.querySelector("head > 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-note-notification.js b/assets/javascripts/discourse/initializers/mod-note-notification.js new file mode 100644 index 0000000..9d93fad --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-note-notification.js @@ -0,0 +1,20 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import modNoteNotificationRenderer from "../lib/mod-note-notification"; + +// Registers the moderator-note notification renderer. The plugin's +// moderator-note notifications use the `custom` notification type; the +// renderer distinguishes them from other custom notifications via the +// `mod_note` marker in the notification `data` and defers to the base +// class for everything else. +export default { + name: "discourse-mod-note-notification", + + initialize() { + withPluginApi("1.0", (api) => { + api.registerNotificationTypeRenderer( + "custom", + modNoteNotificationRenderer + ); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/mod-notes-tab.js b/assets/javascripts/discourse/initializers/mod-notes-tab.js new file mode 100644 index 0000000..90d63c2 --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-notes-tab.js @@ -0,0 +1,41 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { i18n } from "discourse-i18n"; +import ModNotesPanel from "../components/mod-notes-panel"; + +// Registers a staff-only "Moderator notes" tab in the user menu, with the +// shield icon and an unread count, alongside the bell and other tabs. +export default { + name: "discourse-mod-notes-tab", + + initialize() { + withPluginApi("1.0", (api) => { + api.registerUserMenuTab((UserMenuTab) => { + return class extends UserMenuTab { + get id() { + return "discourse-mod-notes"; + } + + get panelComponent() { + return ModNotesPanel; + } + + get icon() { + return "shield-halved"; + } + + get title() { + return i18n("discourse_mod_categories.notes_tab.title"); + } + + get shouldDisplay() { + return !!this.currentUser?.staff; + } + + get count() { + return this.currentUser?.mod_note_unread_count || 0; + } + }; + }); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/mod-pin-decorate.js b/assets/javascripts/discourse/initializers/mod-pin-decorate.js new file mode 100644 index 0000000..4611e80 --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-pin-decorate.js @@ -0,0 +1,45 @@ +import { iconHTML } from "discourse/lib/icon-library"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { i18n } from "discourse-i18n"; + +// Marks the pinned post, in its real position in the post stream, with a +// "Pinned post" badge. Pairs with the bottom copy rendered by the +// topic-area-bottom connector — which is skipped when the pinned post is +// already the last post, so a last post just gets this badge. +export default { + name: "discourse-mod-pin-decorate", + + initialize() { + withPluginApi("1.0", (api) => { + api.decorateCookedElement( + (element, helper) => { + const post = helper?.model; + if (!post || !post.topic) { + return; + } + + const isPinned = post.topic.mod_topic_pinned_post_id === post.id; + const existing = element.querySelector( + ".mod-pinned-in-stream-badge" + ); + + if (!isPinned) { + existing?.remove(); + return; + } + if (existing) { + return; + } + + const badge = document.createElement("div"); + badge.className = "mod-pinned-in-stream-badge"; + badge.innerHTML = `${iconHTML("thumbtack")} ${i18n( + "discourse_mod_categories.pin_post.pinned_label" + )}`; + element.prepend(badge); + }, + { onlyStream: true } + ); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/mod-pin-post.js b/assets/javascripts/discourse/initializers/mod-pin-post.js new file mode 100644 index 0000000..663b603 --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-pin-post.js @@ -0,0 +1,61 @@ +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { withPluginApi } from "discourse/lib/plugin-api"; + +// Adds a "Pin to Bottom" / "Unpin from Bottom" button to the post admin +// menu (moderator actions), visible only to staff. Pinning records the +// post on the topic's `mod_topic_pinned_post_id` custom field; the topic +// footer connector then renders that post's content at the bottom. +export default { + name: "discourse-mod-pin-post", + + initialize(container) { + const currentUser = container.lookup("service:current-user"); + const siteSettings = container.lookup("service:site-settings"); + const appEvents = container.lookup("service:app-events"); + + if ( + !currentUser || + !currentUser.staff || + !siteSettings.topic_footer_message_enabled + ) { + return; + } + + withPluginApi("1.0", (api) => { + api.addPostAdminMenuButton((post) => { + const topic = post.topic; + const pinned = + !!topic && topic.mod_topic_pinned_post_id === post.id; + + return { + icon: "thumbtack", + className: "mod-pin-post-to-bottom", + label: pinned + ? "discourse_mod_categories.pin_post.unpin" + : "discourse_mod_categories.pin_post.pin", + action: async () => { + try { + const result = await ajax( + `/discourse-mod-categories/topic/${post.topic_id}`, + { + type: "PUT", + data: { pinned_post_id: pinned ? "" : post.id }, + } + ); + topic?.set("mod_topic_pinned_post_id", result.pinned_post_id); + if (topic) { + appEvents.trigger("discourse-mod:messages-updated", topic); + // Re-render the stream so the in-stream pin badge appears + // or clears on the affected post immediately. + appEvents.trigger("post-stream:refresh", { force: true }); + } + } catch (error) { + popupAjaxError(error); + } + }, + }; + }); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/mod-topic-admin-menu.js b/assets/javascripts/discourse/initializers/mod-topic-admin-menu.js new file mode 100644 index 0000000..e2c0b97 --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-topic-admin-menu.js @@ -0,0 +1,30 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import ModTopicMessagesModal from "../components/mod-topic-messages-modal"; + +// Adds a button to the topic admin (wrench) menu, visible only to staff +// (moderators and admins), that opens the modal for setting this topic's +// footer message and reply prompt. +export default { + name: "discourse-mod-topic-admin-menu", + + initialize(container) { + const currentUser = container.lookup("service:current-user"); + if (!currentUser || !currentUser.staff) { + return; + } + + const modal = container.lookup("service:modal"); + + withPluginApi("1.0", (api) => { + api.addTopicAdminMenuButton((topic) => { + return { + icon: "shield-halved", + className: "mod-topic-messages-button", + label: "discourse_mod_categories.topic_messages.menu_label", + action: () => + modal.show(ModTopicMessagesModal, { model: { topic } }), + }; + }); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/mod-topic-prompt-checklist-admin.js b/assets/javascripts/discourse/initializers/mod-topic-prompt-checklist-admin.js new file mode 100644 index 0000000..04ec20c --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-topic-prompt-checklist-admin.js @@ -0,0 +1,31 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import ModTopicPromptChecklistModal from "../components/mod-topic-prompt-checklist-modal"; + +// Adds a dedicated "Prompt Checklist" entry to the topic admin (wrench) +// menu, separate from the "Moderator Actions" entry. Visible only to +// staff. Opens its own editor modal scoped to the current topic. +export default { + name: "discourse-mod-topic-prompt-checklist-admin", + + initialize(container) { + const currentUser = container.lookup("service:current-user"); + if (!currentUser || !currentUser.staff) { + return; + } + + const modal = container.lookup("service:modal"); + + withPluginApi("1.0", (api) => { + api.addTopicAdminMenuButton((topic) => { + return { + icon: "list-check", + className: "mod-topic-prompt-checklist-button", + label: + "discourse_mod_categories.topic_prompt_checklist.menu_label", + action: () => + modal.show(ModTopicPromptChecklistModal, { model: { topic } }), + }; + }); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/mod-whisper-add-participant.js b/assets/javascripts/discourse/initializers/mod-whisper-add-participant.js new file mode 100644 index 0000000..96e6062 --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-whisper-add-participant.js @@ -0,0 +1,37 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import ModWhisperAddParticipantModal from "../components/mod-whisper-add-participant-modal"; + +// Adds an "Add user to whisper" button to a whisper post's admin menu, +// visible only to staff while whispers are enabled. It opens a user chooser +// modal that adds the chosen users to the topic's whisper conversation, so +// they see every whisper in the topic from then on. +export default { + name: "discourse-mod-whisper-add-participant", + + initialize(container) { + const currentUser = container.lookup("service:current-user"); + const siteSettings = container.lookup("service:site-settings"); + + if (!currentUser || !currentUser.staff || !siteSettings.mod_whisper_enabled) { + return; + } + + const modal = container.lookup("service:modal"); + + withPluginApi("1.0", (api) => { + api.addPostAdminMenuButton((post) => { + if (!post?.mod_is_whisper) { + return; + } + + return { + icon: "user-plus", + className: "mod-whisper-add-participant", + label: "discourse_mod_categories.whisper.add_participant.menu_label", + action: () => + modal.show(ModWhisperAddParticipantModal, { model: { post } }), + }; + }); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/mod-whisper.js b/assets/javascripts/discourse/initializers/mod-whisper.js new file mode 100644 index 0000000..9a38500 --- /dev/null +++ b/assets/javascripts/discourse/initializers/mod-whisper.js @@ -0,0 +1,269 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { i18n } from "discourse-i18n"; +import ModWhisperTargetModal from "../components/mod-whisper-target-modal"; +import { computeReplyAudience } from "../lib/mod-whisper-reply-audience"; + +// Inline eye SVG so no icon needs registering. +const EYE_PATH = + "M12 5c-7 0-10 7-10 7s3 7 10 7 10-7 10-7-3-7-10-7zm0 11a4 4 0 110-8 4 4 0 010 8z"; + +function whisperParticipantIds(topic) { + const ids = topic?.mod_whisper_participant_ids; + return Array.isArray(ids) ? ids : []; +} + +export default { + name: "discourse-mod-whisper", + + initialize() { + withPluginApi((api) => { + const siteSettings = api.container.lookup("service:site-settings"); + if (!siteSettings?.mod_whisper_enabled) { + return; + } + + const currentUser = api.getCurrentUser(); + + api.modifyClass("model:composer", { + pluginId: "discourse-mod-whisper", + }); + + api.onToolbarCreate((toolbar) => { + toolbar.addButton({ + id: "mod-whisper-target", + className: "mod-whisper-target", + group: "extras", + icon: "far-eye", + title: "discourse_mod_categories.whisper.toolbar_title", + perform: () => { + const composerService = api.container.lookup("service:composer"); + const model = composerService?.model; + if (!model || !currentUser) { + return; + } + + if (currentUser.staff) { + const modal = api.container.lookup("service:modal"); + modal?.show(ModWhisperTargetModal, { + model: { composer: model }, + }); + return; + } + + // Non-staff: only an existing topic whisper participant may + // whisper, and only ever staff-only. Arm it directly. + const participantIds = whisperParticipantIds(model.topic); + if (participantIds.includes(currentUser.id)) { + model.set("modWhisperArmed", true); + model.set("modWhisperTargetUserIds", []); + model.set("modWhisperTargetUsernames", []); + model.set("modWhisperTargets", []); + model.set("modWhisperTargetGroupIds", []); + model.set("modWhisperTargetGroupNames", []); + model.set("modWhisperTargetGroups", []); + } + // Non-participant: no-op. + }, + }); + }); + + api.serializeOnCreate( + "mod_whisper_target_user_ids", + "modWhisperTargetUserIds" + ); + + api.serializeOnCreate( + "mod_whisper_target_group_ids", + "modWhisperTargetGroupIds" + ); + + // 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. + api.serializeOnCreate("mod_whisper", "modWhisperArmed"); + + // `addTrackedPostProperties` is the modern replacement for the + // deprecated `includePostAttributes` — it surfaces the serializer + // attributes on the post model, which the cooked-element decorator + // below relies on. + if (api.addTrackedPostProperties) { + api.addTrackedPostProperties( + "mod_is_whisper", + "mod_whisper_target_user_ids", + "mod_whisper_targets", + "mod_whisper_target_group_ids", + "mod_whisper_target_groups", + "mod_whisper_is_staff_only", + "mod_whisper_author_is_staff" + ); + } else { + api.includePostAttributes( + "mod_is_whisper", + "mod_whisper_target_user_ids", + "mod_whisper_targets", + "mod_whisper_target_group_ids", + "mod_whisper_target_groups", + "mod_whisper_is_staff_only", + "mod_whisper_author_is_staff" + ); + } + + api.decorateCookedElement( + (cookedEl, helper) => { + const post = helper?.getModel?.() || helper?.model; + if (!post?.mod_is_whisper) { + return; + } + + const targets = Array.isArray(post.mod_whisper_targets) + ? post.mod_whisper_targets + : []; + const targetGroups = Array.isArray(post.mod_whisper_target_groups) + ? post.mod_whisper_target_groups + : []; + const staffOnly = !targets.length && !targetGroups.length; + + // Mark the cooked element itself — a marker on the post <article> + // does not survive Glimmer post-stream reconciliation. SCSS tints + // and borders it via these classes. + cookedEl.classList.add("mod-whisper"); + cookedEl.classList.remove("mod-whisper--staff", "mod-whisper--user"); + cookedEl.classList.add( + staffOnly ? "mod-whisper--user" : "mod-whisper--staff" + ); + + // Insert the banner as the FIRST CHILD of the cooked element — NOT + // a sibling. The Glimmer post stream owns the elements around + // `.cooked`; a foreign sibling gets reconciled away. A child of + // `.cooked` is re-decorated whenever the cooked HTML re-renders. + if (cookedEl.querySelector(":scope > .mod-whisper-banner")) { + return; + } + + const banner = document.createElement("div"); + banner.className = "mod-whisper-banner"; + + const svgNS = "http://www.w3.org/2000/svg"; + const icon = document.createElementNS(svgNS, "svg"); + icon.setAttribute("viewBox", "0 0 24 24"); + icon.setAttribute("width", "14"); + icon.setAttribute("height", "14"); + icon.setAttribute("aria-hidden", "true"); + icon.classList.add("mod-whisper-eye"); + const path = document.createElementNS(svgNS, "path"); + path.setAttribute("fill", "currentColor"); + path.setAttribute("d", EYE_PATH); + icon.appendChild(path); + banner.appendChild(icon); + + const label = document.createElement("span"); + label.className = "mod-whisper-banner__label"; + + if (staffOnly) { + label.textContent = ` ${i18n( + "discourse_mod_categories.whisper.banner_to_staff" + )}`; + banner.appendChild(label); + } else { + label.textContent = ` ${i18n( + "discourse_mod_categories.whisper.banner_to" + )} `; + banner.appendChild(label); + + let entryIndex = 0; + const addSep = () => { + if (entryIndex > 0) { + const sep = document.createElement("span"); + sep.className = "mod-whisper-banner__sep"; + sep.textContent = ", "; + banner.appendChild(sep); + } + entryIndex++; + }; + + targets.forEach((t) => { + addSep(); + const link = document.createElement("a"); + link.className = "mod-whisper-banner__user"; + link.href = `/u/${t.username}`; + link.textContent = `@${t.username}`; + banner.appendChild(link); + }); + + targetGroups.forEach((g) => { + addSep(); + const link = document.createElement("a"); + link.className = "mod-whisper-banner__group"; + link.href = `/g/${g.name}`; + link.textContent = g.name; + banner.appendChild(link); + }); + } + + cookedEl.insertBefore(banner, cookedEl.firstChild); + }, + { id: "discourse-mod-whisper-decorator", onlyStream: true } + ); + + // Auto-arm a whisper-back when replying to a whisper, for any user who + // is part of that whisper's audience. This covers the default reply + // flow; quote-reply behaviour is intentionally unchanged and not + // special-cased here. + api.onAppEvent("composer:opened", () => { + const composerService = api.container.lookup("service:composer"); + const model = composerService?.model; + if (!model || !currentUser) { + return; + } + const post = model.post; + if (!post?.mod_is_whisper) { + return; + } + + if (currentUser.staff) { + // Carry forward the original whisper's group targets so a staff + // reply stays visible to the same group audience. + const replyGroups = Array.isArray(post.mod_whisper_target_groups) + ? post.mod_whisper_target_groups + : []; + model.set("modWhisperArmed", true); + model.set( + "modWhisperTargetGroupIds", + replyGroups.map((g) => g.id) + ); + model.set( + "modWhisperTargetGroupNames", + replyGroups.map((g) => g.name) + ); + model.set("modWhisperTargetGroups", replyGroups); + + const replyAudience = computeReplyAudience(post, currentUser.id); + if (replyAudience.length) { + model.set( + "modWhisperTargetUserIds", + replyAudience.map((u) => u.id) + ); + model.set( + "modWhisperTargetUsernames", + replyAudience.map((u) => u.username) + ); + model.set("modWhisperTargets", replyAudience); + } else { + model.set("modWhisperTargetUserIds", []); + model.set("modWhisperTargetUsernames", []); + model.set("modWhisperTargets", []); + } + } else { + // Non-staff replying to a whisper they can see — force staff-only. + model.set("modWhisperArmed", true); + model.set("modWhisperTargetUserIds", []); + model.set("modWhisperTargetUsernames", []); + model.set("modWhisperTargets", []); + model.set("modWhisperTargetGroupIds", []); + model.set("modWhisperTargetGroupNames", []); + model.set("modWhisperTargetGroups", []); + } + }); + }); + }, +}; diff --git a/assets/javascripts/discourse/initializers/precheck-prompt.js b/assets/javascripts/discourse/initializers/precheck-prompt.js new file mode 100644 index 0000000..f79862e --- /dev/null +++ b/assets/javascripts/discourse/initializers/precheck-prompt.js @@ -0,0 +1,82 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { i18n } from "discourse-i18n"; +import ModFirstPostChecklist from "../components/mod-first-post-checklist"; +import { + composerTopicId, + firstPostChecklistFor, + refreshOwedChecklist, +} from "../lib/first-post-checklist"; +import { messageToHtml } from "../lib/linkify-message"; +import { + PRECHECK_CONFIRM_KEY, + PRECHECK_GO_BACK_KEY, + PRECHECK_TITLE_KEY, + precheckPromptFor, +} from "../lib/precheck-prompt"; + +// Gates the composer submit, in order: +// 1. the forum-wide first-post checklist a new user must acknowledge; +// 2. the moderator's per-category (new topic) / per-topic (reply) prompt. +// `composerBeforeSave` replaces Composer#beforeSave, so a single handler +// runs both gates; each resolves when it does not apply, and a rejection +// aborts the save and keeps the composer open with content intact. +export default { + name: "discourse-mod-precheck-prompt", + + initialize(container) { + const siteSettings = container.lookup("service:site-settings"); + const currentUser = container.lookup("service:current-user"); + + // Shows the first-post checklist modal when the current user still + // owes an acknowledgement; resolves immediately otherwise. + // + // The owed checklist is re-fetched from the server first so a version + // bump made mid-session (no hard refresh) is still gated, and so an + // already-accepted checklist is not re-shown. + async function checklistGate(composer) { + await refreshOwedChecklist(currentUser, composerTopicId(composer)); + + const checklist = firstPostChecklistFor(composer, currentUser); + if (!checklist) { + return Promise.resolve(); + } + + const modal = container.lookup("service:modal"); + return new Promise((resolve, reject) => { + modal.show(ModFirstPostChecklist, { + model: { checklist, onAccept: resolve, onCancel: reject }, + }); + }); + } + + // Shows the moderator's confirmation prompt for this composer save. + function precheckGate(composer) { + const message = precheckPromptFor(composer, siteSettings, currentUser); + if (!message) { + return Promise.resolve(); + } + + const dialog = container.lookup("service:dialog"); + return new Promise((resolve, reject) => { + dialog.confirm({ + message: messageToHtml(message), + title: i18n(PRECHECK_TITLE_KEY), + confirmButtonLabel: PRECHECK_CONFIRM_KEY, + cancelButtonLabel: PRECHECK_GO_BACK_KEY, + didConfirm: resolve, + didCancel: reject, + }); + }); + } + + withPluginApi("1.0", (api) => { + // `checklistGate` re-fetches `/checklist/owed` before reading the + // owed checklist, so a checklist version bumped by staff mid-session + // is gated without the user needing a hard page refresh. + api.composerBeforeSave(function () { + const composer = this; + return checklistGate(composer).then(() => precheckGate(composer)); + }); + }); + }, +}; diff --git a/assets/javascripts/discourse/lib/first-post-checklist.js b/assets/javascripts/discourse/lib/first-post-checklist.js new file mode 100644 index 0000000..48f66a0 --- /dev/null +++ b/assets/javascripts/discourse/lib/first-post-checklist.js @@ -0,0 +1,86 @@ +import { ajax } from "discourse/lib/ajax"; +import { CREATE_TOPIC } from "discourse/models/composer"; +import { REPLY_ACTION } from "./precheck-prompt"; + +// The first-post checklist the current user must complete before this +// composer save, or null when nothing should gate it. +// +// `currentUser.mod_first_post_checklist` carries the owed checklist. It is +// bootstrapped on a full page load, but Discourse is a single-page app, so +// after the user accepts once (which clears it to null) or staff bump the +// version mid-session, the bootstrapped value goes stale. `refreshOwedChecklist` +// re-syncs it from the server when the composer opens, so the gate below +// always reads the CURRENT server state — no hard page refresh needed. +export function firstPostChecklistFor(composer, currentUser) { + if (!composer || !currentUser) { + return null; + } + + if (composer.action !== CREATE_TOPIC && composer.action !== REPLY_ACTION) { + return null; + } + + const checklist = currentUser.mod_first_post_checklist; + if (!checklist) { + return null; + } + + // Statement mode has no items by design — a non-blank statement is + // what makes it active. Other modes require at least one item. + if (checklist.mode === "statement") { + if (!checklist.statement || !checklist.statement.trim()) { + return null; + } + return checklist; + } + + if (!checklist.items || checklist.items.length === 0) { + return null; + } + + return checklist; +} + +// The id of the topic being replied to in this composer (or null when the +// composer is creating a new topic). The per-topic checklist gate keys on +// this so the server can include the topic-scoped checklist in the owed +// result. +export function composerTopicId(composer) { + if (!composer) { + return null; + } + if (composer.action !== REPLY_ACTION) { + return null; + } + const topic = composer.topic; + return topic && topic.id ? topic.id : null; +} + +// Re-fetches the current user's currently-owed checklist from the server +// and writes it onto `currentUser.mod_first_post_checklist`, so a checklist +// edited or version-bumped mid-session is gated without a hard refresh. +// Returns a promise; a failed request leaves the existing value untouched. +// +// When `topicId` is provided the server also considers the per-topic +// prompt checklist for that topic; priority is targeted > per-topic > +// global, so a user owing several is shown the highest-priority one. +export function refreshOwedChecklist(currentUser, topicId = null) { + if (!currentUser) { + return Promise.resolve(); + } + + const url = topicId + ? `/discourse-mod-categories/checklist/owed.json?topic_id=${encodeURIComponent( + topicId + )}` + : "/discourse-mod-categories/checklist/owed.json"; + + return ajax(url) + .then((result) => { + currentUser.set("mod_first_post_checklist", result?.checklist ?? null); + }) + .catch(() => { + // Network error: keep whatever value we already have rather than + // dropping a checklist the user genuinely owes. + }); +} diff --git a/assets/javascripts/discourse/lib/linkify-message.js b/assets/javascripts/discourse/lib/linkify-message.js new file mode 100644 index 0000000..840e846 --- /dev/null +++ b/assets/javascripts/discourse/lib/linkify-message.js @@ -0,0 +1,22 @@ +import { htmlSafe } from "@ember/template"; + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +// Escapes a moderator-set message, turns http(s) URLs into links, keeps +// line breaks, and returns a trusted HTML string. Shared by the precheck +// confirmation dialog and the category prompt preview so both render a +// message the same way. +export function messageToHtml(text) { + const linked = escapeHtml(text || "").replace( + /(https?:\/\/[^\s<]+)/g, + (url) => + `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>` + ); + return htmlSafe(linked.replace(/\r?\n/g, "<br>")); +} diff --git a/assets/javascripts/discourse/lib/mod-note-notification.js b/assets/javascripts/discourse/lib/mod-note-notification.js new file mode 100644 index 0000000..55e7cef --- /dev/null +++ b/assets/javascripts/discourse/lib/mod-note-notification.js @@ -0,0 +1,66 @@ +import { i18n } from "discourse-i18n"; + +// Notification-type renderer for the moderator-note custom notification. +// +// All plugin notifications share the `custom` notification type, so this +// renderer keys off the `mod_note` marker the server sets in the +// notification `data`. When the marker is absent (e.g. a whisper +// notification, also `custom`) every getter reproduces core's default +// `custom` notification behavior so other custom notifications are +// untouched. Registering a `custom` renderer replaces core's `custom.js`, +// so its `icon`/`linkTitle` logic is mirrored here for the fallback. +export default function modNoteNotificationRenderer(NotificationTypeBase) { + return class extends NotificationTypeBase { + get isModNote() { + return !!this.notification.data?.mod_note; + } + + // Link straight to the moderator note on the topic — the same target + // the notes feed uses (`topic.relative_url/highest_post_number`). + get linkHref() { + if (this.isModNote && this.notification.data?.url) { + return this.notification.data.url; + } + return super.linkHref; + } + + get linkTitle() { + if (this.isModNote) { + return i18n("discourse_mod_categories.note_notification_title"); + } + // Core `custom.js` behavior. + if (this.notification.data?.title) { + return i18n(this.notification.data.title); + } + return super.linkTitle; + } + + // The plugin's registered shield icon, so the notification reads + // unambiguously as a moderator/staff item. + get icon() { + if (this.isModNote) { + return "shield-halved"; + } + // Core `custom.js` behavior. + return `notification.${this.notification.data?.message}`; + } + + // Accurate, self-describing label naming the acting moderator. + get label() { + if (this.isModNote) { + return i18n("discourse_mod_categories.note_notification", { + username: this.username, + }); + } + return super.label; + } + + // Second line: the topic the note is on. + get description() { + if (this.isModNote) { + return this.notification.data?.topic_title; + } + return super.description; + } + }; +} diff --git a/assets/javascripts/discourse/lib/mod-note-unread-title.js b/assets/javascripts/discourse/lib/mod-note-unread-title.js new file mode 100644 index 0000000..51a929f --- /dev/null +++ b/assets/javascripts/discourse/lib/mod-note-unread-title.js @@ -0,0 +1,25 @@ +// 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/javascripts/discourse/lib/mod-whisper-reply-audience.js b/assets/javascripts/discourse/lib/mod-whisper-reply-audience.js new file mode 100644 index 0000000..dd69f38 --- /dev/null +++ b/assets/javascripts/discourse/lib/mod-whisper-reply-audience.js @@ -0,0 +1,30 @@ +// Compute the reply audience for a moderator whisper: the original post's +// author plus every explicit target, deduplicated by id, minus the current +// user themself. +// +// Returns an array of { id, username, avatar_template } objects. Safe to call +// with any shape of post / currentUserId — returns [] when inputs are missing. +export function computeReplyAudience(post, currentUserId) { + if (!post || !currentUserId) { + return []; + } + const byId = new Map(); + const add = (id, username, avatarTemplate) => { + if (!id || id === currentUserId) { + return; + } + if (!byId.has(id)) { + byId.set(id, { id, username, avatar_template: avatarTemplate }); + } + }; + add(post.user_id, post.username, post.avatar_template); + const targets = Array.isArray(post.mod_whisper_targets) + ? post.mod_whisper_targets + : []; + targets.forEach((t) => { + if (t && typeof t === "object") { + add(t.id, t.username, t.avatar_template); + } + }); + return [...byId.values()]; +} diff --git a/assets/javascripts/discourse/lib/precheck-prompt.js b/assets/javascripts/discourse/lib/precheck-prompt.js new file mode 100644 index 0000000..3a28dbf --- /dev/null +++ b/assets/javascripts/discourse/lib/precheck-prompt.js @@ -0,0 +1,78 @@ +import { CREATE_TOPIC } from "discourse/models/composer"; + +// Composer action string for a reply (Composer.REPLY). +export const REPLY_ACTION = "reply"; + +export const PRECHECK_TITLE_KEY = "discourse_mod_categories.precheck.title"; +export const PRECHECK_CONFIRM_KEY = "discourse_mod_categories.precheck.confirm"; +export const PRECHECK_GO_BACK_KEY = "discourse_mod_categories.precheck.go_back"; + +function nonBlank(value) { + const trimmed = (value || "").trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// True when this user is established enough to skip the prompt. `maxTl` is +// the highest trust level a moderator still wants prompted (0-3); blank or +// 4 means everyone is prompted, so nobody is exempt. +function trustLevelExempt(currentUser, maxTl) { + if (maxTl === null || maxTl === undefined || maxTl === "") { + return false; + } + const cap = parseInt(maxTl, 10); + if (isNaN(cap) || cap >= 4) { + return false; + } + const tl = currentUser && currentUser.trust_level; + if (tl === null || tl === undefined) { + return false; + } + return tl > cap; +} + +// Returns the confirmation message to show before this composer save, or +// null when nothing should gate the save. +// +// - New topic: uses the `mod_category_new_topic_prompt` set by a +// moderator on the chosen category (feature gated by +// precheck_new_topic_enabled). +// - Reply: uses the `mod_topic_reply_prompt` set by a moderator on the +// topic being replied to (feature gated by topic_reply_prompt_enabled). +// +// A moderator can also cap the prompt by trust level: when the matching +// `*_max_tl` field is set to 0-3, users above that trust level skip it. +export function precheckPromptFor(composer, siteSettings, currentUser) { + if (!composer || !siteSettings) { + return null; + } + + if (composer.action === CREATE_TOPIC) { + if (!siteSettings.precheck_new_topic_enabled) { + return null; + } + const category = composer.category; + if (!category) { + return null; + } + if (trustLevelExempt(currentUser, category.mod_category_new_topic_prompt_max_tl)) { + return null; + } + return nonBlank(category.mod_category_new_topic_prompt); + } + + if (composer.action === REPLY_ACTION) { + if (!siteSettings.topic_reply_prompt_enabled) { + return null; + } + const topic = composer.topic; + if (!topic) { + return null; + } + if (trustLevelExempt(currentUser, topic.mod_topic_reply_prompt_max_tl)) { + return null; + } + return nonBlank(topic.mod_topic_reply_prompt); + } + + return null; +} diff --git a/assets/javascripts/discourse/lib/topic-footer-message.js b/assets/javascripts/discourse/lib/topic-footer-message.js new file mode 100644 index 0000000..f2b5809 --- /dev/null +++ b/assets/javascripts/discourse/lib/topic-footer-message.js @@ -0,0 +1,27 @@ +// The moderator-set bottom-pinned message lives on the topic as the +// `mod_topic_footer_message` custom field. +export function topicFooterMessage(topic) { + return ((topic && topic.mod_topic_footer_message) || "").trim(); +} + +// Structural gate: the feature is on and this topic is one that may show +// a footer message. Evaluated by the connector's static `shouldRender`; +// it does NOT depend on the message text, which can change at runtime +// and is handled reactively inside the connector template. +export function topicFooterFeatureActive(siteSettings, topic) { + if (!siteSettings || !siteSettings.topic_footer_message_enabled) { + return false; + } + if (!topic || topic.archetype === "private_message") { + return false; + } + return true; +} + +// Overall visibility = structural gate AND a non-empty message. +export function shouldRenderTopicFooterMessage(siteSettings, topic) { + return ( + topicFooterFeatureActive(siteSettings, topic) && + topicFooterMessage(topic).length > 0 + ); +} diff --git a/assets/javascripts/discourse/lib/trust-level-options.js b/assets/javascripts/discourse/lib/trust-level-options.js new file mode 100644 index 0000000..35c6bc4 --- /dev/null +++ b/assets/javascripts/discourse/lib/trust-level-options.js @@ -0,0 +1,20 @@ +import { i18n } from "discourse-i18n"; + +// Options for the trust-level audience dropdowns. `includeAll` adds the +// "Everyone" (4) and "Up to regulars" (3) choices used by the prompt +// caps; the first-post checklist omits them since it only gates TL0-2. +export function trustLevelOptions(includeAll = true) { + const options = []; + if (includeAll) { + options.push({ id: "4", name: i18n("discourse_mod_categories.audience.all") }); + } + options.push( + { id: "0", name: i18n("discourse_mod_categories.audience.tl0") }, + { id: "1", name: i18n("discourse_mod_categories.audience.tl1") }, + { id: "2", name: i18n("discourse_mod_categories.audience.tl2") } + ); + if (includeAll) { + options.push({ id: "3", name: i18n("discourse_mod_categories.audience.tl3") }); + } + return options; +} diff --git a/assets/stylesheets/mod-note-header-pip.scss b/assets/stylesheets/mod-note-header-pip.scss new file mode 100644 index 0000000..7dee26e --- /dev/null +++ b/assets/stylesheets/mod-note-header-pip.scss @@ -0,0 +1,35 @@ +// 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/assets/stylesheets/topic-footer-message.scss b/assets/stylesheets/topic-footer-message.scss new file mode 100644 index 0000000..a2e7dc4 --- /dev/null +++ b/assets/stylesheets/topic-footer-message.scss @@ -0,0 +1,571 @@ +// The moderator footer message — styled as an official/system notice: +// a tinted, bordered box with an accent left border and a shield icon. +.topic-footer-message { + display: flex; + gap: 0.75em; + margin: 1.5em 0; + padding: 1em 1.25em; + border: 1px solid var(--tertiary-low); + border-left: 4px solid var(--tertiary); + border-radius: var(--d-border-radius, 0.25em); + background: var(--tertiary-very-low, var(--primary-very-low)); + color: var(--primary); + overflow-wrap: anywhere; + + .topic-footer-message-icon { + flex-shrink: 0; + line-height: 1; + + .d-icon { + color: var(--tertiary); + font-size: var(--font-up-2); + } + } + + .topic-footer-message-body { + flex: 1; + min-width: 0; + } + + .topic-footer-message-label { + margin-bottom: 0.25em; + font-weight: 700; + color: var(--tertiary); + } + + .topic-footer-message-content { + img { + max-width: 100%; + height: auto; + } + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + } +} + +.mod-topic-messages-modal { + .mod-messages-label { + font-weight: 700; + } + + .mod-messages-hint { + margin: 0.15em 0 0.4em; + color: var(--primary-medium); + font-size: var(--font-down-1); + } + + textarea { + width: 100%; + box-sizing: border-box; + } +} + +// The pinned post renders as a regular post at the bottom of the topic +// (right after the post stream), not as a footer banner. +.topic-footer-pinned-post { + margin: 0; + padding: 1em 0; + border-top: 1px solid var(--primary-low); + + .pinned-post { + display: flex; + gap: 0.75em; + } + + .pinned-post-avatar { + width: 45px; + height: 45px; + border-radius: 50%; + flex-shrink: 0; + } + + .pinned-post-main { + flex: 1; + min-width: 0; + } + + .pinned-post-header { + display: flex; + align-items: center; + gap: 0.5em; + margin-bottom: 0.25em; + } + + .pinned-post-username { + font-weight: 700; + } + + .pinned-post-badge { + display: inline-flex; + align-items: center; + gap: 0.25em; + padding: 0.1em 0.5em; + border-radius: 0.7em; + background: var(--primary-low); + color: var(--primary-medium); + font-size: var(--font-down-2); + } + + .pinned-post-jump { + margin-left: auto; + display: inline-flex; + align-items: center; + color: var(--primary-medium); + + &:hover { + color: var(--primary); + } + } +} + +// Badge shown on the pinned post in its real position in the post stream. +.mod-pinned-in-stream-badge { + display: inline-flex; + align-items: center; + gap: 0.3em; + margin-bottom: 0.5em; + padding: 0.15em 0.6em; + border-radius: 0.7em; + background: var(--primary-low); + color: var(--primary-medium); + font-size: var(--font-down-2); + font-weight: 700; + + .d-icon { + color: var(--primary-medium); + } +} + +.mod-approval-control { + .mod-approval-checkbox { + display: flex; + align-items: center; + gap: 0.5em; + cursor: pointer; + } +} + +// Staff-only moderator note — shown like a (whisper-style) post: the +// moderator's avatar and name, in a muted, dashed-border card. +.mod-private-note { + margin: 1em 0; + padding: 0.75em 1em; + border: 1px dashed var(--primary-medium); + border-radius: var(--d-border-radius, 0.25em); + background: var(--primary-very-low); + overflow-wrap: anywhere; + + .mod-private-note-marker { + display: flex; + align-items: center; + gap: 0.35em; + margin-bottom: 0.5em; + font-weight: 700; + color: var(--primary-medium); + text-transform: uppercase; + font-size: var(--font-down-1); + } + + .mod-private-note-post { + display: flex; + gap: 0.75em; + } + + .mod-private-note-avatar { + width: 45px; + height: 45px; + border-radius: 50%; + flex-shrink: 0; + } + + .mod-private-note-main { + flex: 1; + min-width: 0; + } + + .mod-private-note-username { + font-weight: 700; + } + + .mod-private-note-byline { + display: flex; + align-items: baseline; + gap: 0.5em; + margin-bottom: 0.25em; + } + + .mod-private-note-time { + color: var(--primary-medium); + font-size: var(--font-down-2); + } + + .mod-private-note-reply { + margin-top: 0.75em; + padding-top: 0.75em; + border-top: 1px solid var(--primary-low); + } + + .mod-private-note-reply-text { + overflow-wrap: anywhere; + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + } + + .mod-private-note-reply-button { + margin-top: 0.6em; + } + + .mod-private-note-reply-box { + margin-top: 0.6em; + + textarea { + width: 100%; + box-sizing: border-box; + } + } + + .mod-private-note-reply-actions { + display: flex; + gap: 0.4em; + margin-top: 0.4em; + } +} + +.mod-private-note-control { + .mod-private-note-position-input { + margin-top: 0.25em; + } +} + +// The staff "Moderator notes" user-menu tab panel. +.mod-notes-panel { + .mod-notes-list { + list-style: none; + margin: 0; + padding: 0; + } + + .mod-notes-item-link { + display: flex; + gap: 0.5em; + padding: 0.6em 0.75em; + color: var(--primary); + + &:hover { + background: var(--highlight-low, var(--primary-very-low)); + } + } + + .mod-notes-item--unread .mod-notes-item-link { + background: var(--tertiary-low, var(--primary-low)); + } + + .mod-notes-item-body { + display: flex; + flex-direction: column; + min-width: 0; + } + + .mod-notes-item-title { + font-weight: 700; + } + + .mod-notes-item-note { + color: var(--primary-medium); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mod-notes-empty { + padding: 1em; + color: var(--primary-medium); + text-align: center; + } +} + +.mod-new-topic-prompt { + .mod-messages-label { + font-weight: 700; + } + + .mod-messages-hint { + margin: 0.15em 0 0.4em; + color: var(--primary-medium); + font-size: var(--font-down-1); + } + + textarea { + width: 100%; + box-sizing: border-box; + } + + .mod-new-topic-prompt-actions { + display: flex; + align-items: center; + gap: 0.5em; + margin-top: 0.5em; + } + + .mod-saved-indicator { + color: var(--success); + } +} + +// First-post checklist modal shown to new users before their first post. +.mod-first-post-checklist-modal { + .mod-checklist-intro { + color: var(--primary-medium); + } + + .mod-checklist-items { + list-style: none; + margin: 0; + padding: 0; + } + + .mod-checklist-item { + display: flex; + align-items: baseline; + gap: 0.5em; + padding: 0.4em 0; + border-bottom: 1px solid var(--primary-low); + + &:last-child { + border-bottom: none; + } + } + + .mod-checklist-item-label { + display: flex; + align-items: baseline; + gap: 0.5em; + flex: 1; + cursor: pointer; + } + + .mod-checklist-link { + white-space: nowrap; + } +} + +// First-post checklist editor on /mod-checklist. +.mod-checklist-page { + max-width: 720px; + margin: 1em auto; + padding: 0 1em; + + .mod-checklist-editor-intro { + color: var(--primary-medium); + } + + .mod-checklist-row { + display: flex; + align-items: center; + gap: 0.5em; + margin-bottom: 0.5em; + } + + .mod-checklist-row-label { + flex: 2; + margin: 0; + } + + .mod-checklist-row-url { + flex: 3; + margin: 0; + } + + .mod-checklist-empty { + color: var(--primary-medium); + font-style: italic; + } + + .mod-checklist-editor-actions { + display: flex; + align-items: center; + gap: 0.5em; + margin-top: 1em; + } + + .mod-checklist-saved { + color: var(--success); + } +} + +.mod-checklist-page { + .mod-checklist-field { + margin-top: 1em; + } + + .mod-checklist-field-label { + font-weight: 700; + } + + .mod-checklist-button-label { + width: 100%; + box-sizing: border-box; + margin: 0; + } +} + +.mod-checklist-page { + .mod-checklist-log { + margin-top: 2em; + border-top: 1px solid var(--primary-low); + padding-top: 1em; + } + + .mod-checklist-log-table { + width: 100%; + border-collapse: collapse; + + th, + td { + text-align: left; + padding: 0.4em 0.6em; + border-bottom: 1px solid var(--primary-low); + } + + th { + color: var(--primary-medium); + font-size: var(--font-down-1); + } + } + + .mod-checklist-log-empty { + color: var(--primary-medium); + font-style: italic; + } +} + +// Wider, sectioned Moderator Actions modal. +.mod-topic-messages-modal { + .d-modal__container { + max-width: 720px; + } + + .mod-messages-section { + margin: 1.2em 0 0.4em; + padding-bottom: 0.2em; + border-bottom: 1px solid var(--primary-low); + font-size: var(--font-up-1); + + &:first-child { + margin-top: 0; + } + } +} + +.mod-checklist-page { + .mod-checklist-log-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1em; + flex-wrap: wrap; + } + + .mod-checklist-log-count { + display: inline-block; + margin-left: 0.4em; + padding: 0 0.5em; + border-radius: 0.8em; + background: var(--primary-low); + font-size: var(--font-down-1); + } + + .mod-checklist-log-filter { + display: flex; + align-items: center; + gap: 0.35em; + color: var(--primary-medium); + font-size: var(--font-down-1); + } +} + +// First-post checklist modal — wide, to fit the editor + audit log. +.mod-checklist-modal { + .d-modal__container { + max-width: 820px; + } + + .d-modal__body { + max-height: 75vh; + } + + .mod-checklist-page { + max-width: 100%; + margin: 0; + padding: 0; + } +} + +.mod-checklist-inactive { + margin: 0.6em 0; + padding: 0.6em 0.8em; + border-left: 3px solid var(--danger); + background: var(--danger-low); + border-radius: 0 4px 4px 0; +} + +.mod-checklist-version { + color: var(--primary-medium); + font-size: var(--font-down-1); +} + +.mod-checklist-loading { + color: var(--primary-medium); +} + +// Live preview of the category new-topic prompt. +.mod-prompt-preview { + margin: 0.5em 0; + padding: 0.6em 0.8em; + background: var(--primary-very-low); + border: 1px solid var(--primary-low); + border-radius: 4px; + + .mod-prompt-preview-label { + display: block; + margin-bottom: 0.3em; + color: var(--primary-medium); + font-size: var(--font-down-1); + text-transform: uppercase; + } +} + +// Move-up / move-down / remove buttons grouped at the end of a row. +.mod-checklist-page { + .mod-checklist-row-controls { + display: flex; + align-items: center; + gap: 0.15em; + flex-shrink: 0; + } +} + +// Stack the cramped controls on narrow viewports. +@media (max-width: 700px) { + .mod-checklist-row { + flex-direction: column; + align-items: stretch; + } + + .mod-checklist-modal .d-modal__container, + .mod-topic-messages-modal .d-modal__container { + max-width: 100%; + } +} diff --git a/assets/stylesheets/whisper.scss b/assets/stylesheets/whisper.scss new file mode 100644 index 0000000..29af4d1 --- /dev/null +++ b/assets/stylesheets/whisper.scss @@ -0,0 +1,135 @@ +// Moderator whisper styling. Two colour codes: +// --staff : staff-authored whisper to chosen users (indigo) +// --user : staff-only whisper-back / staff-only whisper (amber) + +// Tint the composer whenever a whisper is armed. The armed-pill connector +// (rendered into the composer-fields outlet only when a whisper is armed) is +// the DOM signal; :has() does the rest. +.composer-fields:has(.mod-whisper-armed-pill), +.d-editor-container:has(.mod-whisper-armed-pill) { + background-color: rgba(99, 102, 241, 0.08); +} + +.composer-fields:has(.mod-whisper-armed-pill) + .d-editor-button-bar + button.mod-whisper-target { + color: var(--tertiary); + background-color: rgba(99, 102, 241, 0.18); +} + +.mod-whisper-armed-pill { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 6px 0 4px; + padding: 3px 6px 3px 12px; + font-size: var(--font-down-1); + color: var(--tertiary); + background: rgba(99, 102, 241, 0.12); + border: 1px solid rgba(99, 102, 241, 0.3); + border-radius: 999px; + width: fit-content; + max-width: 100%; + + &__label { + opacity: 0.85; + } + + &__users { + display: inline; + } + + &__user, + &__group { + font-weight: 600; + } + + &__close.btn { + padding: 0 6px; + min-width: 0; + min-height: 0; + color: var(--tertiary); + background: transparent; + + &:hover { + background: rgba(99, 102, 241, 0.25); + } + + .d-icon { + margin: 0; + } + } +} + +// Full-width strip rendered above a whisper post's cooked body by +// api.decorateCookedElement in the initializer. +.mod-whisper-banner { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + box-sizing: border-box; + font-size: var(--font-down-1); + padding: 4px 10px; + margin: 0 0 8px; + border-radius: 4px; + + .mod-whisper-eye { + flex-shrink: 0; + } + + &__label { + opacity: 0.85; + } + + &__user, + &__group { + font-weight: 600; + text-decoration: none; + } +} + +// The decorator marks the `.cooked` element itself — a marker on the post +// <article> does not survive Glimmer post-stream reconciliation. +.cooked.mod-whisper { + padding-left: 12px; +} + +.cooked.mod-whisper--staff { + background: rgba(99, 102, 241, 0.05); + border-left: 3px solid rgba(99, 102, 241, 0.55); + + .mod-whisper-banner { + color: var(--tertiary); + background: rgba(99, 102, 241, 0.1); + border: 1px solid rgba(99, 102, 241, 0.25); + } + + .mod-whisper-banner__user, + .mod-whisper-banner__group { + color: var(--tertiary); + } +} + +.cooked.mod-whisper--user { + background: rgba(217, 119, 6, 0.05); + border-left: 3px solid rgba(217, 119, 6, 0.55); + + .mod-whisper-banner { + color: #b45309; + background: rgba(217, 119, 6, 0.1); + border: 1px solid rgba(217, 119, 6, 0.25); + } +} + +.mod-whisper-target-modal { + .select-kit { + width: 100%; + } + + &__instructions { + color: var(--primary-medium); + font-size: var(--font-down-1); + margin-bottom: 12px; + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d0653bb..85a7c5b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4,3 +4,166 @@ en: sidebar_section_title: "Dumbcourse" sidebar_link_text: "Dumbcourse" sidebar_link_title: "Open Dumbcourse" + discourse_mod_categories: + precheck: + title: "Before you post" + confirm: "Post anyway" + go_back: "Go back" + topic_prompt_checklist: + menu_label: "Prompt Checklist" + title: "Prompt Checklist for this topic" + intro: "Users replying to this topic see a confirmation before their reply is allowed through. Saving bumps the version, so any user who already accepted is re-prompted." + loading: "Loading…" + inactive: "Inactive — no items in checklist mode (or blank statement in statement mode) means replies are not gated." + current_version: "Current version: %{count}" + item_label: "Checkbox text" + item_url: "Link URL (optional)" + add_item: "Add item" + remove_item: "Remove item" + button_label_label: "Accept button text" + button_label_placeholder: "I agree, post my reply" + save: "Save" + clear: "Clear" + cancel: "Cancel" + saved_toast: "Topic prompt checklist saved" + cleared_toast: "Topic prompt checklist cleared" + mode_label: "Mode" + mode_statement: "Statement — one message, single accept button" + mode_checklist: "Checklist — multiple items, all must be ticked" + statement_label: "Statement text" + statement_placeholder: "Write the confirmation message users see before their reply is allowed through. Markdown is supported." + frequency_label: "Show this prompt" + frequency_once: "Once per user per topic" + frequency_every_reply: "On every reply" + max_tl_label: "Show this prompt to" + legacy_migration_notice: "This topic has an older reply-prompt set under \"Moderator Actions\". It has been pre-filled below in Statement mode — review and click Save to migrate." + topic_messages: + menu_label: "Moderator Actions" + title: "Moderator Actions for this topic" + footer_label: "Pinned footer message" + footer_hint: "Shown in a banner at the bottom of this topic. Leave blank for none." + approval_label: "Require approval for replies" + approval_hint: "When on, replies to this topic go to the review queue for a moderator to approve. Staff replies are posted directly." + note_label: "Private moderator note" + note_hint: "Visible only to staff on this topic — never shown to regular users. Leave blank for none." + note_position_label: "Show the note" + note_position_top: "At the top of the topic" + note_position_bottom: "At the bottom of the topic" + save: "Save" + cancel: "Cancel" + saved_toast: "Moderator settings saved" + section_visible: "What users see" + section_moderation: "Moderation tools" + footer_message: + label: "Moderator message" + private_note: + heading: "Moderator note (staff only)" + just_now: "just now" + reply: "Reply" + add_reply: "Add reply" + cancel: "Cancel" + save: "Save" + reply_placeholder: "Reply to this moderator note…" + edit_reply: "Edit reply" + delete_reply: "Delete reply" + delete_reply_confirm: "Delete this reply from the moderator note thread?" + edit_note: "Edit note" + delete_note: "Delete note" + delete_note_confirm: "Delete this moderator note and all its replies?" + notes_tab: + title: "Moderator notes" + loading: "Loading…" + empty: "No moderator notes yet." + # Label shown on the moderator-note bell notification. %{username} is + # the moderator who set or replied to the note. + note_notification: "%{username} added a moderator note" + # Hover/title text for the same notification. + note_notification_title: "Moderator note" + audience: + label: "Show this prompt to" + all: "Everyone" + tl0: "New users only (TL0)" + tl1: "New and basic users (TL0 and TL1)" + tl2: "Up to members (TL0 to TL2)" + tl3: "Up to regulars (TL0 to TL3)" + first_post_checklist: + modal_title: "Before you post" + intro: "Please confirm each item below before posting for the first time." + last_updated: "Last updated:" + confirm: "I agree, post" + cancel: "Cancel" + open_link: "Open link" + editor_title: "First-post checklist" + editor_intro: "New members must tick every item below before they can post. Saving changes re-prompts everyone who already accepted." + loading: "Loading…" + inactive: "Inactive — with no items, users are not prompted. Add an item below to start the checklist." + item_label: "Checkbox text" + item_url: "Link URL (optional)" + add_item: "Add item" + remove_item: "Remove item" + move_up_item: "Move item up" + move_down_item: "Move item down" + save: "Save checklist" + saved: "Saved" + empty: "No items yet. Add one to start the checklist." + audience_label: "Who must accept before their next post" + button_label_label: "Accept button text" + button_label_placeholder: "I agree, post" + sidebar_title: "Configure the first-post checklist" + sidebar_text: "First-post checklist" + current_version: "Current version: %{count}" + log_title: "Acceptance log" + log_empty: "No one has accepted the checklist yet." + log_user: "User" + log_version: "Version" + log_when: "Accepted" + log_filter_current: "Current version only (%{count})" + require_reaccept: "Require re-accept" + reaccept_done: "%{username} will be prompted again on their next post" + targeted_title: "Targeted checklists" + targeted_intro: "Separate checklists aimed at specific users. A targeted checklist is always shown to its listed users — regardless of trust level or staff status." + targeted_add: "Add targeted checklist" + targeted_name_label: "Name" + targeted_users_label: "Target users" + targeted_save: "Save targeted checklist" + targeted_delete: "Delete" + targeted_saved: "Targeted checklist saved" + category_settings: + new_topic_prompt_label: "Before-new-topic prompt" + new_topic_prompt_hint: "Shown as a confirmation when someone starts a new topic in this category. Leave blank for none." + preview_label: "Preview" + save: "Save message" + saved: "Saved" + pin_post: + pin: "Pin to Bottom" + unpin: "Unpin from Bottom" + pinned_label: "Pinned post" + jump_to_original: "Go to the original post" + whisper: + toolbar_title: "Whisper this reply" + modal_title: "Whisper to users or groups" + modal_instructions: "Choose up to 10 users or groups who should see this reply. Staff always see whispers." + search_placeholder: "Search for a user or group…" + confirm: "Whisper" + clear: "Clear" + armed_pill_prefix: "Whispering to" + armed_pill_staff_only: "Whispering to staff" + clear_armed: "Clear whisper" + banner_to: "Whisper to" + banner_to_staff: "Whisper to staff" + whisper_notification: "whispered to you" + add_participant: + menu_label: "Add user to whisper" + modal_title: "Add a user to the whisper conversation" + modal_instructions: "Choose the users to add to this topic's whisper conversation. They will see every whisper in this topic from now on." + search_placeholder: "Search for a user…" + confirm: "Add to whisper" + cancel: "Cancel" + added_toast: + one: "Added %{count} user to the whisper conversation" + other: "Added %{count} users to the whisper conversation" + admin_js: + admin: + site_settings: + categories: + discourse_mod_categories: "Moderator Category Management" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 42b0828..d143f2d 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1,5 +1,10 @@ en: + discourse_mod_categories: + # Live pop-up alert text shown when another moderator sets or replies to + # a private moderator note on a topic. + note_notification_alert: '%{username} added a moderator note on "%{topic}"' site_settings: + # --- dumbcourse --- dumbcourse_enabled: "Enable Dumbcourse" dumbcourse_default_theme: "Default Dumbcourse theme" dumbcourse_default_view: "Default Dumbcourse view" @@ -31,3 +36,9 @@ en: dumbcourse_languagetool_api_username_description: "Only required for official API plans that use username + API key." dumbcourse_languagetool_api_key: "LanguageTool API key (optional)" dumbcourse_languagetool_api_key_description: "Required when a username is set." + # --- discourse-mod --- + mod_categories_enabled: "Allow moderators to create, edit, and delete categories." + precheck_new_topic_enabled: "Enable the per-category 'before you post a new topic' confirmation prompt. Moderators set the message on each category." + topic_footer_message_enabled: "Enable the per-topic pinned footer message. Moderators set the message on each topic." + topic_reply_prompt_enabled: "Enable the per-topic 'before you post a reply' confirmation prompt. Moderators set the message on each topic." + mod_whisper_enabled: "Enable moderator whisper posts — in-topic replies visible only to staff, the post author, the topic's whisper participants, and any users staff explicitly target." diff --git a/config/routes.rb b/config/routes.rb index 6e852d8..9aa9400 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,12 @@ # frozen_string_literal: true +# Routes for the DiscourseDumbcourse engine. +# +# The DiscourseModCategories engine has its own routes file at +# discourse-mod/config/routes.rb (wired in via config.paths on its +# Engine class in plugin.rb). Keeping the two engines' routes in +# separate files prevents Rails' routes_reloader from loading the +# same file twice and re-mounting either engine. DiscourseDumbcourse::Engine.routes.draw do post "/hcaptcha" => "app#hcaptcha" diff --git a/config/settings.yml b/config/settings.yml index 0507eb6..da0432a 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -76,3 +76,21 @@ plugins: dumbcourse_languagetool_api_key: default: "" client: false + +# --- discourse-mod --- +discourse_mod_categories: + mod_categories_enabled: + default: false + client: true + precheck_new_topic_enabled: + default: true + client: true + topic_footer_message_enabled: + default: true + client: true + topic_reply_prompt_enabled: + default: true + client: true + mod_whisper_enabled: + default: true + client: true diff --git a/discourse-mod/CHANGELOG.md b/discourse-mod/CHANGELOG.md new file mode 100644 index 0000000..595b3a9 --- /dev/null +++ b/discourse-mod/CHANGELOG.md @@ -0,0 +1,119 @@ +# Changelog + +All notable changes to the `discourse-mod` plugin (module +`DiscourseModCategories`) are recorded here. + +## Unreleased + +### Moderator-notes header-level indicators + +- Added a staff-only **header shield pip** showing the moderator-notes + unread count in the page header next to the avatar. Visible whenever + the user menu is closed and `mod_note_unread_count > 0`; clicking it + opens the user menu on the shield tab. Rendered via the + `before-header-panel` plugin outlet so the indicator works across + Discourse versions. +- Added a **browser tab-title prefix** (`(N) original-title`) mirroring + the bell's behaviour, so an inactive browser tab still announces unread + moderator notes. Implemented as a pure `applyUnreadPrefix` helper + + a `document.title` setter wrapper so route transitions keep the prefix. +- Both indicators are reactive: the count updates on initial bootstrap, + drops to 0 when the staff member opens the shield tab, and bumps live + on new notes via a dedicated `/mod-note-unread-count/{user_id}` + MessageBus channel — published from `notify_staff_of_note` (a + `{ delta: 1 }` per recipient) and from `notes_feed_seen` (a + `{ reset: true }` to the staff member themselves, so multi-tab + sessions stay in lockstep). + +### Per-topic prompt checklist — modes, frequency, audience cap + +- Added a **Statement** mode to the per-topic prompt checklist: a single + Markdown-cooked message + a single accept button. Replaces the legacy + per-topic before-reply prompt as the supported way to set it. +- Added a **Frequency** selector (`once` — the default, per-user-version- + tracked — or `every_reply` — always prompt; no acceptance recorded). +- Added a **`max_tl`** trust-level audience cap to the per-topic prompt, + mirroring the global checklist's `max_tl`. Staff always see the prompt + regardless of cap; targeted checklists still override. +- Removed the legacy **Before-reply prompt** textarea + audience combo-box + from the topic admin "Moderator Actions" modal. Staff now configure + the per-topic prompt exclusively through the dedicated **Prompt + Checklist** editor. +- The legacy `mod_topic_reply_prompt` and `mod_topic_reply_prompt_max_tl` + custom fields stay registered — opening the new editor on a topic that + still has them pre-fills the editor in Statement mode and saving + clears the legacy fields. The legacy composer gate continues to work + for any unmigrated topic. + +## 0.1.0 + +Initial release. The plugin gives moderators a set of category and +per-topic controls without granting full admin access. + +### Category management + +- Moderators can create, edit, and delete categories — abilities normally + reserved for admins — via prepended `Guardian` overrides. +- Category deletion by a moderator is limited to empty categories with no + sub-categories, and never the uncategorized category. +- Gated by the `mod_categories_enabled` site setting (default off), which + is also the plugin's master switch. + +### Moderator topic tools + +Set from the topic admin (wrench) menu → **Moderator Actions** modal, which +is split into *What users see* and *Moderation tools* sections and confirms +with a save toast: + +- **Per-topic footer message** — a moderator-trusted HTML banner pinned at + the bottom of a topic. Site setting `topic_footer_message_enabled` + (default on). +- **Per-topic reply prompt** — a confirm-or-go-back dialog shown before a + user replies, with a trust-level cap (a Discourse combo-box) and + clickable links in the message. Site setting `topic_reply_prompt_enabled` + (default on). +- **Per-topic reply approval** — flags a topic so non-staff replies route + to the review queue instead of publishing directly, enforced by a + `NewPostManager` handler. +- **Private moderator note** — a staff-only note shown like a post (author, + avatar, relative timestamp), positionable at the top or bottom of the + topic, with a staff-only reply thread. Never serialized to non-staff. + +### Per-category new-topic prompt + +- A confirm-or-go-back dialog shown before a user starts a new topic in a + category, set on the category Settings tab with a live preview, a + trust-level cap, and clickable links. Site setting + `precheck_new_topic_enabled` (default on). + +### Pin a post to the bottom + +- Moderators can pin any post so a rendered copy also appears at the end of + the topic with a pin badge; the original is badged in place. If the + pinned post is already last, only the badge shows. + +### Moderator-note notifications and user-menu tab + +- Staff get a shield tab in the user menu listing moderator notes from + across the forum, with an unread count. +- Setting a note or replying to one sends a real Discourse notification + (bell + live pop-up) to the other staff members, linking to the topic. + +### First-post checklist + +- A forum-wide checklist a not-yet-trusted user must tick before their + first post. Configured by staff in a modal opened from the **First-post + checklist** link in the sidebar's Community section. +- Each item is a line of text with an optional link. Staff can set a + trust-level audience (TL0, TL0–TL1, or TL0–TL2) and custom accept-button + text. +- The checklist is versioned: every save bumps the version and re-prompts + everyone who already accepted. +- Every acceptance is written to an append-only audit log (latest 500 + entries) shown in the editor. + +### Permissions + +- All moderator-set content is gated server-side by `Guardian` checks + (`can_manage_mod_messages?` / `can_edit_category?`); regular and + anonymous users receive a `403`. diff --git a/discourse-mod/README.md b/discourse-mod/README.md new file mode 100644 index 0000000..29c9047 --- /dev/null +++ b/discourse-mod/README.md @@ -0,0 +1,193 @@ +# discourse-mod-categories + +A Discourse plugin that grants **moderators** the ability to create, edit, and delete categories — capabilities that are normally reserved for admins. + +Everything else moderators already have in core Discourse (editing topics, moving posts, bulk-changing topic categories, managing tags, etc.) is left untouched. + +## How it works + +1. Install the plugin +2. Enable `mod_categories_enabled` in site settings +3. Anyone in the built-in moderators group can now create, edit, and delete categories + +## Settings + +Admins use these site settings only to switch the capabilities on. The +**content** of every message is set by moderators, per topic or per +category (see below). + +| Setting | Default | Description | Docs | +|---------|---------|-------------|------| +| `mod_categories_enabled` | `false` | Allow moderators to create, edit, and delete categories; also the plugin's overall enable switch | [docs](docs/mod-categories-enabled.md) | +| `topic_footer_message_enabled` | `true` | Enable the per-topic pinned footer message | [docs](docs/topic-footer-message.md) | +| `precheck_new_topic_enabled` | `true` | Enable the per-category "before you post a new topic" prompt | [docs](docs/precheck-new-topic.md) | +| `topic_reply_prompt_enabled` | `true` | Enable the per-topic "before you post a reply" prompt | [docs](docs/topic-reply-prompt.md) | +| `mod_whisper_enabled` | `true` | Enable moderator whisper posts (staff-only side conversations in a topic) | [docs](docs/whisper.md) | + +`mod_categories_enabled` is `false` by default, so the whole plugin is off until an admin turns it on. The other settings default to `true`, so once `mod_categories_enabled` is on, the footer message, new-topic prompt, reply prompt, and moderator whisper are all available unless an admin explicitly turns them off. + +## Enabling the features + +All four settings live at **Admin → Settings**, in the **Moderator Category Management** category (`/admin/site_settings/category/discourse_mod_categories`). To change one, search for its name and tick or untick the checkbox. + +1. **`mod_categories_enabled`** (default off) — turn this on **first**. It is the plugin's master switch; nothing else works until it is on. Moderators can then create, edit, and delete categories, and the plugin's moderator UIs load. +2. **`topic_footer_message_enabled`** (default on) — enables the per-topic **footer message** and **pinned posts**. Moderators set content from a topic's admin (wrench) menu → **Moderator Actions**. Untick to disable. +3. **`precheck_new_topic_enabled`** (default on) — enables the per-category **new-topic prompt**. Moderators set the message on each category's **Settings** tab. Untick to disable. +4. **`topic_reply_prompt_enabled`** (default on) — enables the per-topic **reply prompt**. Moderators set it in the topic admin menu's **Moderator Actions** modal. Untick to disable. + +Per-topic **reply approval** and the **private moderator note** are also set from the topic admin menu → **Moderator Actions** (no separate site setting). The whole plugin can be switched off at any time by un-ticking `mod_categories_enabled`. + +## Who can set the messages + +Only **moderators and admins** can set the messages. The endpoints that +persist them are gated by `Guardian#can_manage_mod_messages?`; regular and +anonymous users get a `403`. Moderators have this ability while the plugin +is enabled (`mod_categories_enabled`); admins always do. + +## Per-topic footer message + +When `topic_footer_message_enabled` is on, a moderator opens a topic, uses +the topic admin (wrench) menu → **Moderator Actions**, and sets the +**Pinned footer message**. It renders in an official-notice box at the +bottom of that topic (below the last post, above suggested topics) — a +shield icon and a "Moderator message" label above the content. Stored on +the topic as the `mod_topic_footer_message` custom field and cooked as +Markdown for display; not shown in private messages or when blank. + +## Per-topic reply prompt (now under Prompt Checklist) + +The legacy stand-alone reply prompt has moved into the +[Per-topic prompt checklist](docs/topic-prompt-checklist.md) entry under +its **Statement mode** — a single Markdown-cooked message + an accept +button. Staff configure it from the topic admin (wrench) menu → +**Prompt Checklist**, alongside the existing **Checklist mode** for +multi-item flows. The legacy `mod_topic_reply_prompt` / +`mod_topic_reply_prompt_max_tl` custom fields remain registered for any +topic that hasn't been migrated; opening the new editor for such a topic +pre-fills it in Statement mode so saving migrates it. See +[Per-topic reply prompt (legacy)](docs/topic-reply-prompt.md) for the +older details. + +## Per-category new-topic prompt + +When `precheck_new_topic_enabled` is on, a moderator edits a category +(category → Settings tab) and sets the **Before-new-topic prompt**. The +field shows a live preview of how the message will look in the dialog, and +a **Show this prompt to** combo-box caps it by trust level. When a user +starts a new topic in that category, a confirmation dialog shows the +moderator's message before the topic posts. Stored as the +`mod_category_new_topic_prompt` and `mod_category_new_topic_prompt_max_tl` +category custom fields. + +## First-post checklist + +While the plugin is enabled, staff can define a forum-wide checklist that a +new member must tick before posting for the first time. Each item is a line +of text with an optional link to a post or page. A user at or below a +configurable trust-level cap (TL0, TL0–TL1, or TL0–TL2) who creates a topic +or replies is shown a modal listing the items; every box must be ticked +before the post goes through. Staff configure the list — items, trust-level +cap, and accept-button text — in a modal opened from the **First-post +checklist** link in the sidebar's Community section; saving bumps a version +that re-prompts everyone who already accepted. See +[the docs](docs/first-post-checklist.md) for details. + +## Per-topic prompt checklist + +Staff can attach a **Prompt Checklist** to a single topic from the topic +admin (wrench) menu's dedicated **Prompt Checklist** entry. Two modes: +**Statement** (single Markdown-cooked message + accept button — replaces +the legacy per-topic reply prompt) and **Checklist** (multiple items, +all of which must be ticked). Configurable frequency +(once-per-user-per-topic or on every reply) and a trust-level audience +cap. Acceptance in `once` mode is recorded per-topic per-user with a +version, so a staff edit re-prompts every previously-accepted user. See +[the docs](docs/topic-prompt-checklist.md) for details. + +## Pin a post to the bottom + +From any post's admin (moderator actions) menu, a moderator can choose +**Pin to Bottom**. The pinned post is shown at the bottom of the topic as +a regular-looking post (avatar, username, content) marked with a pin +symbol. **Unpin from Bottom** clears it. Stored as the +`mod_topic_pinned_post_id` topic custom field. + +## Per-topic reply approval + +In the topic admin menu modal, a moderator can tick **Require approval for +replies**. While on, replies to that topic are routed to the review queue +for a moderator to approve instead of being published directly; staff +replies still post immediately. Stored as the +`mod_topic_require_reply_approval` topic custom field, and enforced server +-side via a `NewPostManager` handler — the per-topic analogue of a +category's "require reply approval". + +## Moderator-notes user-menu tab + +While the plugin is enabled, staff get an extra **shield** tab in their user +menu (next to the notifications bell). It carries an unread count and lists +the moderator notes set on topics across the forum, each linking to its +topic. Visible to staff only. See +[the docs](docs/moderator-notes-tab.md) for details. + +## Moderator whisper + +When `mod_whisper_enabled` is on (default), staff can post **whispers** — +in-topic replies visible only to the post author, all staff, the chosen +target users, and the topic's whisper participants. Staff arm a whisper from +the composer's eye button and pick up to 10 targets. A non-staff user who +has been whispered to can whisper *back* staff-only. Whispers are filtered +out of the topic stream for everyone else. Staff can also add a user to a +topic's whisper conversation from a whisper post's admin menu, so that user +sees every whisper in the topic. See +[the docs](docs/whisper.md) for details. + +## Documentation + +Each setting and feature has a dedicated page in [`docs/`](docs/): + +- [Feature list](docs/features.md) +- [Settings & features index](docs/settings.md) +- [`mod_categories_enabled` — master switch & category management](docs/mod-categories-enabled.md) +- [Category management](docs/categories.md) +- [Moderators vs Admins](docs/comparison.md) +- [Per-topic footer message](docs/topic-footer-message.md) +- [Pin a post to the bottom](docs/pin-post-to-bottom.md) +- [Per-topic reply prompt](docs/topic-reply-prompt.md) +- [Per-topic reply approval](docs/topic-reply-approval.md) +- [Per-category new-topic prompt](docs/precheck-new-topic.md) +- [First-post checklist](docs/first-post-checklist.md) +- [Per-topic prompt checklist](docs/topic-prompt-checklist.md) +- [Private moderator note](docs/private-moderator-note.md) +- [Moderator-notes user-menu tab](docs/moderator-notes-tab.md) +- [Moderator whisper](docs/whisper.md) +- [`mod_whisper_enabled`](docs/mod-whisper-enabled.md) +- [Tests & screenshots](docs/testing.md) +- [Changelog](CHANGELOG.md) + +## Permissions granted + +| Action | Default | +|--------|---------| +| Create categories | All categories | +| Edit categories | All categories | +| Delete categories | All categories (must be empty, no children) | + +## Installation + +Add the plugin's repository URL to your `app.yml`: + +```yaml +hooks: + after_code: + - exec: + cd: $home/plugins + cmd: + - git clone https://github.com/Shalom-Karr/discourse-mod.git +``` + +Then rebuild the container: + +``` +./launcher rebuild app +``` diff --git a/discourse-mod/about.json b/discourse-mod/about.json new file mode 100644 index 0000000..80cd2db --- /dev/null +++ b/discourse-mod/about.json @@ -0,0 +1,4 @@ +{ + "name": "discourse-mod-categories", + "about": "Allows moderators to create, edit, and delete categories" +} diff --git a/discourse-mod/app/controllers/discourse_mod_categories/checklist_controller.rb b/discourse-mod/app/controllers/discourse_mod_categories/checklist_controller.rb new file mode 100644 index 0000000..79a6f4c --- /dev/null +++ b/discourse-mod/app/controllers/discourse_mod_categories/checklist_controller.rb @@ -0,0 +1,472 @@ +# frozen_string_literal: true + +module ::DiscourseModCategories + # Endpoints for the first-post checklist. Two flavours are managed here: + # + # * the forum-wide checklist — one list every not-yet-trusted user must + # tick, kept in the plugin store as + # { "version" => Integer, "items" => [{ "label" =>, "url" => }] } + # * targeted checklists — separate lists aimed at specific users, kept + # as a JSON array. A targeted checklist applies to its listed users + # regardless of trust level or staff status. + # + # Any user records that they have acknowledged a checklist; staff read and + # edit the lists and the acceptance audit log. + class ChecklistController < ::ApplicationController + requires_login + + NS = DiscourseModCategories::CHECKLIST_STORE_NAMESPACE + KEY = DiscourseModCategories::CHECKLIST_STORE_KEY + LOG_KEY = DiscourseModCategories::CHECKLIST_LOG_KEY + TARGETED_KEY = DiscourseModCategories::TARGETED_CHECKLISTS_KEY + VERSION_FIELD = DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + TARGETED_FIELD = DiscourseModCategories::USER_TARGETED_CHECKLIST_FIELD + TOPIC_FIELD = DiscourseModCategories::TOPIC_PROMPT_CHECKLIST_FIELD + USER_TOPIC_FIELD = DiscourseModCategories::USER_TOPIC_CHECKLIST_FIELD + # Most recent acceptances kept in the audit log. + LOG_LIMIT = 500 + + # Returns the current forum-wide checklist, the targeted checklists, and + # the acceptance audit log for the moderator config page. + def show + guardian.ensure_can_manage_mod_messages! + render json: + checklist_json(DiscourseModCategories.checklist_config).merge( + log: acceptance_log, + targeted: targeted_json, + ) + end + + # Replaces the forum-wide checklist and bumps the version so users who + # already accepted an older version are prompted again. + def update + guardian.ensure_can_manage_mod_messages! + + items = parse_items(params[:items]) + existing = DiscourseModCategories.checklist_config || {} + version = existing["version"].to_i + 1 + + config = { + "version" => version, + "items" => items, + "max_tl" => normalize_max_tl(params[:max_tl]), + "button_label" => params[:button_label].to_s.strip, + "updated_at" => Time.zone.now.iso8601, + } + PluginStore.set(NS, KEY, config) + + render json: checklist_json(config) + end + + # Returns the single checklist the CURRENT user still owes before they + # can post — the same `{ kind, id, version, items, button_label, + # updated_at }` shape as the `mod_first_post_checklist` serializer, or + # `null`. The frontend polls this when the composer opens so a checklist + # version bumped mid-session is gated without a hard page refresh. + def owed + topic_id = params[:topic_id].presence + render json: { + checklist: + DiscourseModCategories.owed_checklist_for( + current_user, + topic_id: topic_id, + ), + } + end + + # Records that the current user has acknowledged a checklist. `kind` + # selects which store the acceptance is recorded against. The accepted + # version is clamped to the version actually published so a stale client + # cannot mark a user ahead of the real checklist. + def accept + kind = params[:kind].to_s + submitted = params[:version].to_i + + if kind == "targeted" + checklist = + DiscourseModCategories.targeted_checklists.find do |c| + c["id"] == params[:id].to_s + end + raise Discourse::NotFound unless checklist + + accepted_version = [submitted, checklist["version"].to_i].min + map = current_user.custom_fields[TARGETED_FIELD] + map = {} unless map.is_a?(Hash) + map[checklist["id"]] = accepted_version + current_user.custom_fields[TARGETED_FIELD] = map + current_user.save_custom_fields(true) + append_log_entry(accepted_version, kind: "targeted", id: checklist["id"]) + elsif kind == "topic" + topic_id = params[:id].to_i + checklist = DiscourseModCategories.topic_prompt_checklist(topic_id) + raise Discourse::NotFound unless checklist + + accepted_version = [submitted, checklist["version"].to_i].min + map = current_user.custom_fields[USER_TOPIC_FIELD] + map = {} unless map.is_a?(Hash) + map[topic_id.to_s] = accepted_version + current_user.custom_fields[USER_TOPIC_FIELD] = map + current_user.save_custom_fields(true) + append_log_entry(accepted_version, kind: "topic", id: topic_id.to_s) + else + current = + DiscourseModCategories.checklist_config&.dig("version").to_i + accepted_version = [submitted, current].min + current_user.custom_fields[VERSION_FIELD] = accepted_version + current_user.save_custom_fields(true) + append_log_entry(accepted_version, kind: "global") + end + + render json: success_json + end + + # Resets a user so they are re-prompted for the forum-wide checklist on + # their next post, by zeroing their recorded accepted version. + def require_reaccept + guardian.ensure_can_manage_mod_messages! + + user = find_target_user + raise Discourse::NotFound unless user + + user.custom_fields[VERSION_FIELD] = 0 + user.save_custom_fields(true) + + render json: { success: "OK", log: acceptance_log } + end + + # Creates a targeted checklist with a stable id, starting at version 1. + def create_targeted + guardian.ensure_can_manage_mod_messages! + + checklist = { + "id" => SecureRandom.hex(8), + "name" => params[:name].to_s.strip, + "user_ids" => validate_user_ids(params[:user_ids]), + "items" => parse_items(params[:items]), + "version" => 1, + "button_label" => params[:button_label].to_s.strip, + "updated_at" => Time.zone.now.iso8601, + } + checklists = DiscourseModCategories.targeted_checklists + [checklist] + PluginStore.set(NS, TARGETED_KEY, checklists) + + render json: { targeted: targeted_json } + end + + # Replaces a targeted checklist's content and bumps its version so its + # users are re-prompted. + def update_targeted + guardian.ensure_can_manage_mod_messages! + + checklists = DiscourseModCategories.targeted_checklists + checklist = checklists.find { |c| c["id"] == params[:id].to_s } + raise Discourse::NotFound unless checklist + + checklist["name"] = params[:name].to_s.strip + checklist["user_ids"] = validate_user_ids(params[:user_ids]) + checklist["items"] = parse_items(params[:items]) + checklist["button_label"] = params[:button_label].to_s.strip + checklist["version"] = checklist["version"].to_i + 1 + checklist["updated_at"] = Time.zone.now.iso8601 + PluginStore.set(NS, TARGETED_KEY, checklists) + + render json: { targeted: targeted_json } + end + + # Removes a targeted checklist. + def delete_targeted + guardian.ensure_can_manage_mod_messages! + + checklists = DiscourseModCategories.targeted_checklists + remaining = checklists.reject { |c| c["id"] == params[:id].to_s } + raise Discourse::NotFound if remaining.size == checklists.size + + PluginStore.set(NS, TARGETED_KEY, remaining) + + render json: { targeted: targeted_json } + end + + # --- Per-topic prompt checklist ---------------------------------- + + # Returns the per-topic prompt checklist for a topic, in the same + # shape the editor expects. Empty/absent stores return zeroed fields + # so the editor can render an empty form. + def show_topic + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound unless topic + guardian.ensure_can_manage_mod_messages! + + render json: topic_checklist_json(topic) + end + + # Replaces the topic's prompt checklist and bumps the version so any + # user who already accepted is re-prompted. Accepts the new mode, + # statement, frequency, and max_tl fields alongside the historical + # items + button_label shape. Saving migrates a legacy reply-prompt: + # the two legacy `mod_topic_reply_prompt*` custom fields are cleared + # so the new config wins outright. + def update_topic + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound unless topic + guardian.ensure_can_manage_mod_messages! + + mode = params[:mode].to_s + mode = "checklist" unless %w[statement checklist].include?(mode) + frequency = params[:frequency].to_s + frequency = "once" unless %w[once every_reply].include?(frequency) + statement = params[:statement].to_s + max_tl = normalize_topic_max_tl(params[:max_tl]) + + items = parse_items(params[:items]) + existing = topic_checklist_raw(topic) || {} + version = existing["version"].to_i + 1 + + config = { + "version" => version, + "mode" => mode, + "statement" => statement, + "items" => items, + "frequency" => frequency, + "max_tl" => max_tl, + "button_label" => params[:button_label].to_s.strip, + "updated_at" => Time.zone.now.iso8601, + } + topic.custom_fields[TOPIC_FIELD] = config + # Migrate away from the legacy per-topic reply prompt fields: the + # new config supersedes them, and the composer gate already prefers + # the per-topic checklist. Clearing means a stale legacy prompt + # never appears after the staff explicitly saves the new config. + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD + ] = nil + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_TL_FIELD + ] = nil + topic.save_custom_fields(true) + + render json: topic_checklist_json(topic) + end + + # Clears the per-topic prompt checklist. + def delete_topic + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound unless topic + guardian.ensure_can_manage_mod_messages! + + topic.custom_fields[TOPIC_FIELD] = nil + topic.save_custom_fields(true) + + render json: topic_checklist_json(topic) + end + + private + + # Resolves the user named by a `username` or `user_id` param. + def find_target_user + if params[:user_id].present? + User.find_by(id: params[:user_id]) + elsif params[:username].present? + User.find_by_username(params[:username]) + end + end + + # Resolves the submitted target list to ids of real User records. The + # frontend's user picker sends usernames, but a numeric id is accepted + # too. A browser form-encodes an array as an index-keyed hash, so + # accept that shape as well. + def validate_user_ids(raw) + raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h) + raw = raw.values if raw.is_a?(Hash) + tokens = Array(raw).map(&:to_s).map(&:strip).reject(&:empty?).uniq + + numeric, names = tokens.partition { |t| t.match?(/\A\d+\z/) } + ids = numeric.map(&:to_i) + ids += User.where(username_lower: names.map(&:downcase)).pluck(:id) + User.where(id: ids.uniq).pluck(:id) + end + + # Appends one acceptance to the audit log, keeping the most recent + # LOG_LIMIT entries. + def append_log_entry(version, kind: "global", id: nil) + raw = PluginStore.get(NS, LOG_KEY) + entries = raw.is_a?(Array) ? raw : [] + entries << { + "user_id" => current_user.id, + "version" => version, + "at" => Time.zone.now.iso8601, + "kind" => kind, + "checklist_id" => id, + } + PluginStore.set(NS, LOG_KEY, entries.last(LOG_LIMIT)) + end + + # The acceptance audit log, newest first, with usernames resolved. + def acceptance_log + raw = PluginStore.get(NS, LOG_KEY) + entries = raw.is_a?(Array) ? raw : [] + users = User.where(id: entries.map { |e| e["user_id"] }.uniq).index_by(&:id) + names = + DiscourseModCategories + .targeted_checklists + .each_with_object({}) { |c, h| h[c["id"]] = c["name"] } + + entries.reverse.map do |entry| + user = users[entry["user_id"]] + { + username: user&.username, + name: user&.name, + version: entry["version"].to_i, + accepted_at: entry["at"], + kind: entry["kind"].presence || "global", + checklist_id: entry["checklist_id"], + checklist_name: names[entry["checklist_id"]], + } + end + end + + def checklist_json(config) + config ||= {} + { + version: config["version"].to_i, + items: config["items"].is_a?(Array) ? config["items"] : [], + max_tl: config.key?("max_tl") ? config["max_tl"].to_i : 2, + button_label: config["button_label"].to_s, + updated_at: config["updated_at"], + } + end + + # The targeted checklists, with their target users resolved to + # username/avatar so the editor can render the picker. + def targeted_json + checklists = DiscourseModCategories.targeted_checklists + all_ids = checklists.flat_map { |c| Array(c["user_ids"]).map(&:to_i) }.uniq + users = User.where(id: all_ids).index_by(&:id) + + checklists.map do |checklist| + ids = Array(checklist["user_ids"]).map(&:to_i) + { + id: checklist["id"], + name: checklist["name"].to_s, + version: checklist["version"].to_i, + button_label: checklist["button_label"].to_s, + updated_at: checklist["updated_at"], + items: checklist["items"].is_a?(Array) ? checklist["items"] : [], + user_ids: ids, + users: + ids.filter_map do |id| + user = users[id] + next nil unless user + { + id: user.id, + username: user.username, + name: user.name, + avatar_template: user.avatar_template, + } + end, + } + end + end + + # Highest trust level still required to accept the checklist: 0-2, + # defaulting to 2 (TL0, TL1, and TL2 all must accept). + def normalize_max_tl(value) + return 2 if value.nil? || value.to_s.strip.empty? + [[value.to_i, 0].max, 2].min + end + + # Normalises submitted rows. A browser form-encodes an array of + # objects as a hash keyed by index ({ "0" => {...}, "1" => {...} }), + # so coerce that back to an array. Each row needs a non-blank label; + # the url is optional. Blank-label rows are dropped. + # Reads the per-topic checklist hash off the topic, coercing a legacy + # string-stored value back to a hash. + def topic_checklist_raw(topic) + raw = topic.custom_fields[TOPIC_FIELD] + raw = JSON.parse(raw) rescue nil if raw.is_a?(String) + raw.is_a?(Hash) ? raw : nil + end + + # The editor-facing payload for the per-topic checklist. When no new + # config exists for the topic but a legacy `mod_topic_reply_prompt` + # has been set on the topic, pre-fill the editor in Statement mode + # with the legacy text + the legacy `mod_topic_reply_prompt_max_tl` + # cap so the moderator can save once to migrate. + def topic_checklist_json(topic) + raw = topic_checklist_raw(topic) + + if raw.nil? + legacy_text = + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD + ].to_s + if legacy_text.strip.length > 0 + legacy_max_tl = + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_TL_FIELD + ] + legacy_max_tl = + legacy_max_tl.nil? || legacy_max_tl.to_s.strip.empty? ? 4 : + [[legacy_max_tl.to_i, 0].max, 4].min + return( + { + topic_id: topic.id, + version: 0, + mode: "statement", + statement: legacy_text, + items: [], + frequency: "once", + max_tl: legacy_max_tl, + button_label: "", + updated_at: nil, + from_legacy: true, + } + ) + end + raw = {} + end + + mode = raw["mode"].to_s + mode = "checklist" unless %w[statement checklist].include?(mode) + frequency = raw["frequency"].to_s + frequency = "once" unless %w[once every_reply].include?(frequency) + max_tl = raw.key?("max_tl") ? normalize_topic_max_tl(raw["max_tl"]) : 4 + + { + topic_id: topic.id, + version: raw["version"].to_i, + mode: mode, + statement: raw["statement"].to_s, + items: raw["items"].is_a?(Array) ? raw["items"] : [], + frequency: frequency, + max_tl: max_tl, + button_label: raw["button_label"].to_s, + updated_at: raw["updated_at"], + from_legacy: false, + } + end + + # Per-topic trust-level cap: 0-4, defaulting to 4 (everyone). Mirrors + # the global checklist's `max_tl` but allows TL3/TL4 too so a staff + # member can wholly opt out of trust-level filtering. + def normalize_topic_max_tl(value) + return 4 if value.nil? || value.to_s.strip.empty? + [[value.to_i, 0].max, 4].min + end + + def parse_items(raw) + raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h) + raw = raw.values if raw.is_a?(Hash) + rows = raw.is_a?(Array) ? raw : [] + rows + .map do |row| + row = row.to_unsafe_h if row.respond_to?(:to_unsafe_h) + next nil unless row.is_a?(Hash) + label = row["label"].to_s.strip + url = row["url"].to_s.strip + next nil if label.empty? + { "label" => label, "url" => url } + end + .compact + end + end +end diff --git a/discourse-mod/app/controllers/discourse_mod_categories/messages_controller.rb b/discourse-mod/app/controllers/discourse_mod_categories/messages_controller.rb new file mode 100644 index 0000000..ce1a5cf --- /dev/null +++ b/discourse-mod/app/controllers/discourse_mod_categories/messages_controller.rb @@ -0,0 +1,525 @@ +# frozen_string_literal: true + +module ::DiscourseModCategories + # Persistence endpoint for the moderator-set messages. Every action is + # Guardian-gated so only moderators and admins can write; regular users + # get a 403. + class MessagesController < ::ApplicationController + requires_login + + TOPIC_FOOTER_FIELD = DiscourseModCategories::TOPIC_FOOTER_FIELD + TOPIC_REPLY_PROMPT_FIELD = DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD + TOPIC_PINNED_POST_FIELD = DiscourseModCategories::TOPIC_PINNED_POST_FIELD + TOPIC_REQUIRE_REPLY_APPROVAL_FIELD = + DiscourseModCategories::TOPIC_REQUIRE_REPLY_APPROVAL_FIELD + TOPIC_PRIVATE_NOTE_FIELD = DiscourseModCategories::TOPIC_PRIVATE_NOTE_FIELD + TOPIC_PRIVATE_NOTE_POSITION_FIELD = + DiscourseModCategories::TOPIC_PRIVATE_NOTE_POSITION_FIELD + TOPIC_PRIVATE_NOTE_USER_FIELD = + DiscourseModCategories::TOPIC_PRIVATE_NOTE_USER_FIELD + TOPIC_PRIVATE_NOTE_CREATED_AT_FIELD = + DiscourseModCategories::TOPIC_PRIVATE_NOTE_CREATED_AT_FIELD + TOPIC_PRIVATE_NOTE_REPLIES_FIELD = + DiscourseModCategories::TOPIC_PRIVATE_NOTE_REPLIES_FIELD + TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD = + DiscourseModCategories::TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD + USER_NOTES_SEEN_FIELD = DiscourseModCategories::USER_NOTES_SEEN_FIELD + CATEGORY_NEW_TOPIC_PROMPT_FIELD = + DiscourseModCategories::CATEGORY_NEW_TOPIC_PROMPT_FIELD + TOPIC_REPLY_PROMPT_TL_FIELD = + DiscourseModCategories::TOPIC_REPLY_PROMPT_TL_FIELD + CATEGORY_NEW_TOPIC_PROMPT_TL_FIELD = + DiscourseModCategories::CATEGORY_NEW_TOPIC_PROMPT_TL_FIELD + TOPIC_WHISPER_PARTICIPANTS_FIELD = + DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD + + def update_topic + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound unless topic + + guardian.ensure_can_manage_mod_messages! + + if params.key?(:footer_message) + topic.custom_fields[TOPIC_FOOTER_FIELD] = params[:footer_message].to_s + end + + if params.key?(:reply_prompt) + topic.custom_fields[TOPIC_REPLY_PROMPT_FIELD] = params[:reply_prompt].to_s + end + + if params.key?(:reply_prompt_max_tl) + topic.custom_fields[TOPIC_REPLY_PROMPT_TL_FIELD] = + normalize_max_tl(params[:reply_prompt_max_tl]) + end + + if params.key?(:pinned_post_id) + raw = params[:pinned_post_id].to_s.strip + if raw.empty? || raw == "0" + topic.custom_fields[TOPIC_PINNED_POST_FIELD] = nil + else + post = topic.posts.find_by(id: raw.to_i) + raise Discourse::InvalidParameters.new(:pinned_post_id) unless post + topic.custom_fields[TOPIC_PINNED_POST_FIELD] = post.id + end + end + + if params.key?(:require_reply_approval) + topic.custom_fields[TOPIC_REQUIRE_REPLY_APPROVAL_FIELD] = + ActiveModel::Type::Boolean.new.cast( + params[:require_reply_approval], + ) || false + end + + if params.key?(:private_note) + topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD] = + params[:private_note].to_s + # Record who set the note, and when, so it can be shown like a post. + topic.custom_fields[TOPIC_PRIVATE_NOTE_USER_FIELD] = current_user.id + topic.custom_fields[TOPIC_PRIVATE_NOTE_CREATED_AT_FIELD] = Time + .zone + .now + .iso8601 + topic.custom_fields[TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD] = Time + .zone + .now + .iso8601 + end + + if params.key?(:private_note_position) + position = params[:private_note_position].to_s + position = "bottom" unless %w[top bottom].include?(position) + topic.custom_fields[TOPIC_PRIVATE_NOTE_POSITION_FIELD] = position + end + + topic.save_custom_fields(true) + + if params.key?(:private_note) && + topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD].present? + notify_staff_of_note(topic) + end + + render json: { + footer_message: topic.custom_fields[TOPIC_FOOTER_FIELD].to_s, + 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], + 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: + topic.custom_fields[TOPIC_PRIVATE_NOTE_POSITION_FIELD].presence || + "bottom", + private_note_author: private_note_author(topic), + } + end + + def update_category + category = Category.find_by(id: params[:category_id]) + raise Discourse::NotFound unless category + + # Editing a category is already a moderator-granted ability in this + # plugin; reuse that gate for the per-category prompt. + guardian.ensure_can_edit_category!(category) + + category.custom_fields[CATEGORY_NEW_TOPIC_PROMPT_FIELD] = params[ + :new_topic_prompt + ].to_s + + if params.key?(:new_topic_prompt_max_tl) + category.custom_fields[CATEGORY_NEW_TOPIC_PROMPT_TL_FIELD] = + normalize_max_tl(params[:new_topic_prompt_max_tl]) + end + + category.save_custom_fields(true) + + render json: { + new_topic_prompt: + category.custom_fields[CATEGORY_NEW_TOPIC_PROMPT_FIELD].to_s, + new_topic_prompt_max_tl: + category.custom_fields[CATEGORY_NEW_TOPIC_PROMPT_TL_FIELD], + } + end + + # Appends a staff reply to the topic's private moderator note thread. + def add_note_reply + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound unless topic + + guardian.ensure_can_manage_mod_messages! + + raw = params[:raw].to_s.strip + raise Discourse::InvalidParameters.new(:raw) if raw.empty? + + replies = note_replies(topic) + replies << { + "id" => SecureRandom.hex(8), + "user_id" => current_user.id, + "raw" => raw, + "created_at" => Time.zone.now.iso8601, + } + topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD] = replies + topic.custom_fields[TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD] = Time + .zone + .now + .iso8601 + topic.save_custom_fields(true) + notify_staff_of_note(topic) + + render json: { replies: serialized_note_replies(topic) } + end + + # Edits the `raw` body of a single reply in the note thread. + def update_note_reply + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound unless topic + + guardian.ensure_can_manage_mod_messages! + + raw = params[:raw].to_s.strip + raise Discourse::InvalidParameters.new(:raw) if raw.empty? + + reply_id = params[:reply_id].to_s + replies = note_replies(topic) + reply = replies.find { |r| r["id"] == reply_id } + raise Discourse::InvalidParameters.new(:reply_id) unless reply + + reply["raw"] = raw + topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD] = replies + topic.custom_fields[TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD] = Time + .zone + .now + .iso8601 + topic.save_custom_fields(true) + + render json: note_thread_json(topic) + end + + # Removes a single reply from the note thread. + def delete_note_reply + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound unless topic + + guardian.ensure_can_manage_mod_messages! + + reply_id = params[:reply_id].to_s + replies = note_replies(topic) + raise Discourse::InvalidParameters.new(:reply_id) unless replies.any? do |r| + r["id"] == reply_id + end + + replies.reject! { |r| r["id"] == reply_id } + topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD] = replies + topic.custom_fields[TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD] = Time + .zone + .now + .iso8601 + topic.save_custom_fields(true) + + render json: note_thread_json(topic) + end + + # Clears the note body, its author/created-at, and its whole reply thread. + def delete_note + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound unless topic + + guardian.ensure_can_manage_mod_messages! + + topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD] = "" + topic.custom_fields[TOPIC_PRIVATE_NOTE_USER_FIELD] = nil + topic.custom_fields[TOPIC_PRIVATE_NOTE_CREATED_AT_FIELD] = nil + topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD] = [] + topic.custom_fields[TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD] = Time + .zone + .now + .iso8601 + topic.save_custom_fields(true) + + render json: note_thread_json(topic) + end + + # Lists recent moderator notes across topics, for the staff user-menu + # tab, newest first. + def notes_feed + guardian.ensure_can_manage_mod_messages! + + topic_ids = + TopicCustomField + .where(name: TOPIC_PRIVATE_NOTE_FIELD) + .where.not(value: [nil, ""]) + .order(updated_at: :desc) + .limit(50) + .pluck(:topic_id) + + seen_at = + current_user.custom_fields[USER_NOTES_SEEN_FIELD].presence || + "1970-01-01T00:00:00Z" + + notes = + Topic + .where(id: topic_ids) + .map do |topic| + note = topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD].to_s + next if note.blank? + replies = topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD] + activity_at = + topic.custom_fields[TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD].to_s + { + topic_id: topic.id, + topic_title: topic.title, + url: "#{topic.relative_url}/#{topic.highest_post_number}", + note: note, + reply_count: replies.is_a?(Array) ? replies.size : 0, + activity_at: activity_at, + unread: activity_at > seen_at, + } + end + .compact + .sort_by { |n| n[:activity_at] } + .reverse + + render json: { notes: notes } + end + + # Marks the staff user's moderator-note feed as read. + def notes_feed_seen + guardian.ensure_can_manage_mod_messages! + + current_user.custom_fields[USER_NOTES_SEEN_FIELD] = Time.zone.now.iso8601 + current_user.save_custom_fields(true) + + # Also flip the underlying Notification rows to read. Otherwise the + # bell counter keeps reflecting the prior mod-note bumps after the + # shield tab has cleared them — Discourse counts unread Notifications, + # not our custom seen_at timestamp. The `mod_note` marker in the JSON + # `data` column distinguishes our notifications from other custom + # ones so we only touch our own rows. + marked = + Notification + .where( + user_id: current_user.id, + notification_type: Notification.types[:custom], + read: false, + ) + .where("data LIKE ?", "%\"mod_note\":true%") + .update_all(read: true) + + # Push the recalculated bell counts to every open tab so they refresh + # in lockstep with the shield tab being opened. + 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 + + # 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). + def add_whisper_participant + raise Discourse::NotFound unless SiteSetting.mod_whisper_enabled + + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::NotFound unless topic + + guardian.ensure_can_manage_mod_messages! + + user = + if params[:user_id].present? + User.find_by(id: params[:user_id]) + elsif params[:username].present? + User.find_by_username(params[:username]) + end + raise Discourse::InvalidParameters.new(:username) unless user + + existing = + Array(topic.custom_fields[TOPIC_WHISPER_PARTICIPANTS_FIELD]).map(&:to_i) + merged = (existing + [user.id]).reject { |i| i <= 0 }.uniq + + if merged.sort != existing.sort + topic.custom_fields[TOPIC_WHISPER_PARTICIPANTS_FIELD] = merged + topic.save_custom_fields(true) + notify_whisper_participant(topic, user) + end + + render json: { participant_ids: merged } + end + + private + + # Notifies a newly added user that they were added to the topic's whisper + # conversation, mirroring the whisper `post_created` notification pattern. + def notify_whisper_participant(topic, user) + return if user.id == current_user.id + + Notification.create!( + notification_type: Notification.types[:custom], + user_id: user.id, + topic_id: topic.id, + post_number: topic.highest_post_number, + data: { + topic_title: topic.title, + display_username: current_user.username, + message: "discourse_mod_categories.whisper.whisper_notification", + }.to_json, + ) + end + + # Clamps a submitted trust-level cap to 0-4. 4 (the default) means the + # prompt is shown to everyone; 0-3 limits it to that trust level and + # below. + def normalize_max_tl(value) + [[value.to_i, 0].max, 4].min + end + + # Sends a real Discourse notification (bell + live pop-up) to every + # other staff member when a moderator note or reply is added. + # + # The notification carries `high_priority: true` so it sorts and badges + # like a flag/review notification, and a `mod_note` marker in `data` so + # the frontend notification-type renderer can render it with the shield + # icon, accurate text, and a link straight to the moderator note. The + # note lives on the topic, so the link resolves to the topic at its + # highest post number — the same target the notes feed uses. + # + # The live pop-up alert is published on the same `/notification-alert/` + # MessageBus channel core uses for flags/replies. Creating a + # `Notification` row alone only fills the bell list — it never pops up. + def notify_staff_of_note(topic) + note = topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD].to_s + note_url = "#{topic.relative_url}/#{topic.highest_post_number}" + + User + .where(admin: true) + .or(User.where(moderator: true)) + .where.not(id: current_user.id) + .find_each do |staff_user| + data = { + topic_title: topic.title, + display_username: current_user.username, + # Stable marker the frontend renderer keys off to recognize THIS + # custom notification as a moderator note. + mod_note: true, + url: note_url, + message: "discourse_mod_categories.note_notification", + title: "discourse_mod_categories.note_notification_title", + } + + Notification.create!( + notification_type: Notification.types[:custom], + user_id: staff_user.id, + topic_id: topic.id, + post_number: topic.highest_post_number, + high_priority: true, + data: data.to_json, + ) + + publish_note_alert(staff_user, topic, note, note_url) + publish_unread_count_bump(staff_user) + 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 + # moderator note, and a `post_url` pointing straight at the note. + def publish_note_alert(staff_user, topic, note, note_url) + return if staff_user.suspended? + return unless staff_user.allow_live_notifications? + + payload = { + notification_type: Notification.types[:custom], + topic_title: topic.title, + topic_id: topic.id, + post_number: topic.highest_post_number, + excerpt: note.truncate(300), + username: current_user.username, + post_url: note_url, + translated_title: + I18n.t( + "discourse_mod_categories.note_notification_alert", + username: current_user.username, + topic: topic.title, + ), + } + + MessageBus.publish( + "/notification-alert/#{staff_user.id}", + payload, + user_ids: [staff_user.id], + ) + end + + def private_note_author(topic) + user_id = topic.custom_fields[TOPIC_PRIVATE_NOTE_USER_FIELD] + user = user_id && User.find_by(id: user_id) + return nil unless user + + { + username: user.username, + name: user.name, + avatar_template: user.avatar_template, + } + end + + # Reads the topic's reply thread, backfilling a stable `id` onto any + # legacy reply that predates ids so edit/delete can target it. + def note_replies(topic) + replies = topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD] + replies = [] unless replies.is_a?(Array) + replies.each { |entry| entry["id"] = SecureRandom.hex(8) if entry["id"].blank? } + replies + end + + # The updated note + replies JSON returned by every note-thread action so + # the frontend can refresh without an extra request. + def note_thread_json(topic) + { + private_note: topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD].to_s, + private_note_author: private_note_author(topic), + private_note_created_at: + topic.custom_fields[TOPIC_PRIVATE_NOTE_CREATED_AT_FIELD], + replies: serialized_note_replies(topic), + } + end + + def serialized_note_replies(topic) + note_replies(topic).map do |entry| + author = entry["user_id"] && User.find_by(id: entry["user_id"]) + { + id: entry["id"], + raw: entry["raw"].to_s, + created_at: entry["created_at"], + author: + author && + { + username: author.username, + name: author.name, + avatar_template: author.avatar_template, + }, + } + end + end + end +end diff --git a/discourse-mod/config/routes.rb b/discourse-mod/config/routes.rb new file mode 100644 index 0000000..939d149 --- /dev/null +++ b/discourse-mod/config/routes.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Routes for the DiscourseModCategories engine. +# +# This file is wired in as the engine's `config/routes.rb` via +# `config.paths["config/routes.rb"]` on the engine class in the merged +# plugin.rb. Doing so gives our engine its OWN routes file so Rails does +# not load the plugin-root config/routes.rb (dumbcourse's) twice — once +# per engine. +DiscourseModCategories::Engine.routes.draw do + put "/topic/:topic_id" => "messages#update_topic" + put "/category/:category_id" => "messages#update_category" + post "/topic/:topic_id/note-reply" => "messages#add_note_reply" + put "/topic/:topic_id/note-reply" => "messages#update_note_reply" + 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 "/notes-feed" => "messages#notes_feed" + post "/notes-feed/seen" => "messages#notes_feed_seen" + get "/checklist" => "checklist#show" + get "/checklist/owed" => "checklist#owed" + put "/checklist" => "checklist#update" + post "/checklist/accept" => "checklist#accept" + post "/checklist/require-reaccept" => "checklist#require_reaccept" + post "/checklist/targeted" => "checklist#create_targeted" + put "/checklist/targeted/:id" => "checklist#update_targeted" + delete "/checklist/targeted/:id" => "checklist#delete_targeted" + get "/topic/:topic_id/prompt-checklist" => "checklist#show_topic" + put "/topic/:topic_id/prompt-checklist" => "checklist#update_topic" + delete "/topic/:topic_id/prompt-checklist" => "checklist#delete_topic" +end + +Discourse::Application.routes.draw do + mount ::DiscourseModCategories::Engine, at: "discourse-mod-categories" +end diff --git a/discourse-mod/docs/categories.md b/discourse-mod/docs/categories.md new file mode 100644 index 0000000..27c9e7b --- /dev/null +++ b/discourse-mod/docs/categories.md @@ -0,0 +1,43 @@ +# Category Management + +## Prerequisites + +- `mod_categories_enabled` must be on +- User must be a moderator + +## What the plugin adds + +When `mod_categories_enabled` is on, any moderator can: + +- **Create** any category — top-level or as a subcategory under any other category +- **Edit** any category (name, description, permissions, settings, etc.) +- **Delete** any category, provided it is empty (no topics) and has no subcategories + +## What is unchanged + +Every other moderator capability comes from Discourse core and is untouched by this plugin: + +- Editing topics +- Closing, reopening, archiving, pinning topics +- Splitting/merging topics +- Moving posts +- Bulk-changing topic categories +- Managing tags +- The full review queue, user management, and admin actions that core grants moderators + +The plugin **only** extends category create/edit/delete. It does not change behavior for admins, regular users, trust-level-4 users, or category group moderators. + +## How it works technically + +The plugin prepends a Guardian extension that overrides four methods: + +| Method | What it controls | +|--------|------------------| +| `can_create_category?` | Creating new categories | +| `can_edit_category?` | Editing category settings | +| `can_edit_serialized_category?` | Whether the category shows as editable in the site category list | +| `can_delete_category?` | Deleting empty categories | + +Each override calls `super` first — if core Discourse already allows the action (e.g. for an admin), the plugin doesn't interfere. Otherwise it returns `true` for moderators when the plugin setting is enabled. + +`can_delete_category?` additionally enforces the standard core constraints: the category must have no topics, no subcategories, and must not be the uncategorized category. diff --git a/discourse-mod/docs/comparison.md b/discourse-mod/docs/comparison.md new file mode 100644 index 0000000..1556a39 --- /dev/null +++ b/discourse-mod/docs/comparison.md @@ -0,0 +1,56 @@ +# Moderators vs Admins + +This compares a Discourse moderator with the plugin enabled against a Discourse admin. + +In terms of **core capabilities**, the plugin only extends category +create/edit/delete to moderators — every other core moderator/admin ability +is unchanged. On top of that, the plugin adds a set of **new +moderator-only tools** that core does not have at all (per-topic footer +message, new-topic and reply prompts, pin-a-post-to-bottom, per-topic reply +approval, the private moderator note and its user-menu tab, and the +first-post checklist). Those are listed under [Features](features.md); the +tables below cover only how the plugin changes the moderator/admin split +for core abilities. + +## Content organization + +| Ability | Admin | Moderator (with plugin) | Source | +|---------|-------|-------------------------|--------| +| Create categories | All | All | Plugin | +| Edit categories | All | All | Plugin | +| Delete categories | All (empty) | All (empty, no children) | Plugin | +| Create/edit/delete tags | Yes | Yes | Core | +| Edit topics | Yes | Yes | Core | +| Bulk change topic category | Yes | Yes | Core | + +## Topic moderation + +| Ability | Admin | Moderator | Source | +|---------|-------|-----------|--------| +| Close / reopen topics | Yes | Yes | Core | +| Reply on closed topics | Yes | Yes | Core | +| Archive / pin / unlist topics | Yes | Yes | Core | +| Split / merge topics | Yes | Yes | Core | +| Move posts | Yes | Yes | Core | +| Delete topics / posts | Yes | Yes | Core | + +## User and site administration + +| Ability | Admin | Moderator | +|---------|-------|-----------| +| Suspend / silence users | Yes | Yes | +| Review queue / handle flags | Yes | Yes | +| View deleted content | Yes | Yes | +| Access admin panel | Yes | Yes (limited) | +| Manage site settings | Yes | No | +| Install plugins | Yes | No | +| Manage admin users | Yes | No | + +## Summary + +The plugin closes a specific gap in core: moderators can manage almost +every aspect of the forum, but cannot create, edit, or delete categories +themselves. With this plugin enabled, they can — without elevating them to +full admins. The plugin also gives moderators several brand-new per-topic +and per-category tools (see [Features](features.md)) that no role has in +core Discourse. diff --git a/discourse-mod/docs/features.md b/discourse-mod/docs/features.md new file mode 100644 index 0000000..623c2fc --- /dev/null +++ b/discourse-mod/docs/features.md @@ -0,0 +1,68 @@ +# Features + +`discourse-mod` ("Mod") is a Discourse plugin that gives moderators a set of per-topic and per-category controls without needing admin access. This page lists everything the plugin adds; each feature has its own page with full details. + +## Moderator category management + +Lets the built-in moderators group create, edit, and delete categories — abilities normally reserved for admins. This is also the plugin's master switch (`mod_categories_enabled`). See [Category management](categories.md) and [`mod_categories_enabled`](mod-categories-enabled.md). + +## Per-topic footer message + +A moderator-set message shown in an official-notice box (shield icon + "Moderator message" label) at the bottom of a single topic, cooked as Markdown. See [Per-topic footer message](topic-footer-message.md). + +## Per-category new-topic prompt + +A confirm-or-go-back dialog shown to a user before they post a new topic in a category. The message is set per category by a moderator, and can be limited to users at or below a chosen trust level (e.g. only TL0 and TL1) via a Discourse combo-box. The category Settings field shows a live preview of the dialog, and URLs in the message render as clickable links. See [Per-category new-topic prompt](precheck-new-topic.md). + +## Per-topic reply prompt (superseded) + +Originally a stand-alone confirm-or-go-back dialog shown to a user before they reply to a topic. **This feature now lives under the [Per-topic prompt checklist](topic-prompt-checklist.md) entry in Statement mode** — staff configure it from the topic admin (wrench) menu → **Prompt Checklist**. The legacy `mod_topic_reply_prompt` field still works for any topic that hasn't been migrated yet, but opening the new editor pre-fills it in Statement mode so saving migrates the topic. See [Per-topic reply prompt (legacy)](topic-reply-prompt.md). + +## First-post checklist + +A forum-wide checklist of staff-defined items (each with an optional link) that a new member at trust level 0 to 2 must tick before posting for the first time. Staff configure it — items, trust-level audience, and accept-button text — in a modal opened from the **First-post checklist** link in the sidebar's Community section. Editing it bumps a version that re-prompts everyone who already accepted, and every acceptance is recorded in an audit log. From the audit log staff can also **require a specific user to re-accept**. Alongside the forum-wide list, staff can create **targeted checklists** — separate checklists aimed at specific users that apply regardless of trust level or staff status. See [First-post checklist](first-post-checklist.md). + +## Per-topic prompt checklist + +A topic-scoped confirmation a user must accept before their reply to that topic is allowed through. Staff attach it from the topic admin (wrench) menu via the dedicated **Prompt Checklist** entry. Two modes: + +- **Statement** — a single Markdown-cooked message + a single accept button. Replaces the legacy per-topic reply prompt. +- **Checklist** — multiple items, each with an optional link, all of which must be ticked before the accept button enables. + +Frequency can be **once per user per topic** (the default — a staff edit bumps the version and re-prompts) or **on every reply** (no acceptance is recorded). A trust-level cap (`max_tl`) filters out higher-TL non-staff users, mirroring the global checklist. Targeted checklists still take priority over the per-topic prompt. See [Per-topic prompt checklist](topic-prompt-checklist.md). + +## Pin a post to the bottom + +Lets a moderator pin any post so it also appears at the end of the topic with a pin badge; the original post is badged in place. If the pinned post is already last, only the badge is shown. See [Pin a post to the bottom](pin-post-to-bottom.md). + +## Per-topic reply approval + +Lets a moderator flag a topic so replies route to the review queue for moderator approval instead of being published directly. Staff replies still post directly. See [Per-topic reply approval](topic-reply-approval.md). + +## Private moderator note + +A staff-only note on a topic — shown like a post with the author's avatar, name, and a relative timestamp — with a staff-only reply thread. Never visible to, or even sent to, regular users. See [Private moderator note](private-moderator-note.md). + +## Moderator-note notifications & user-menu tab + +Staff get a shield tab in the user menu listing moderator notes from across the forum, with an unread count. Setting a note or replying to one also sends a real Discourse notification (bell + live pop-up) to the other staff members, linking to the topic. The shield-tab's unread count is also surfaced in the page header as a **shield pip** next to the avatar, and as a **`(N)` prefix on the browser-tab title** — both update live (no refresh) via a dedicated MessageBus channel. See [Moderator-notes user-menu tab](moderator-notes-tab.md). + +## Moderator whisper + +Staff can post a **whisper** — an in-topic reply visible only to a chosen audience (the targeted users, all staff, and the topic's whisper participants), marked with a full-width banner. Only staff can start one; once a user has been whispered to in a topic they can whisper back to staff. Staff can also add a user to a topic's whisper conversation from a whisper post's admin menu, so that user sees every whisper in the topic. Gated by `mod_whisper_enabled` (default on). See [Moderator whisper](whisper.md). + +## Where moderators set things + +| Feature | Where it is set | +|---|---| +| Footer message, reply approval, private note | Topic admin (wrench) menu → **Moderator Actions** modal — split into *What users see* and *Moderation tools* sections, with a save toast | +| Per-topic reply prompt (statement) | Topic admin (wrench) menu → **Prompt Checklist** entry (mode: Statement) | +| New-topic prompt | Category → **Settings** tab | +| Pin a post to the bottom | A post's admin (moderator actions) menu | +| First-post checklist | **First-post checklist** link in the sidebar's Community section (opens a modal) | +| Per-topic prompt checklist | Topic admin (wrench) menu → **Prompt Checklist** entry (opens its own modal) | +| Category create / edit / delete | The normal Discourse category UI | + +## Permissions + +Every moderator action is gated by a Guardian permission — `can_manage_mod_messages?` for the topic/checklist tools, `can_edit_category?` for the per-category prompt — so only moderators and admins can set this content; regular and anonymous users are blocked with a `403`. The private moderator note is sent only to staff. See [Settings & features reference](settings.md) for the full breakdown. diff --git a/discourse-mod/docs/first-post-checklist.md b/discourse-mod/docs/first-post-checklist.md new file mode 100644 index 0000000..e64f1cf --- /dev/null +++ b/discourse-mod/docs/first-post-checklist.md @@ -0,0 +1,173 @@ +# First-post checklist + +A forum-wide checklist a not-yet-trusted user must tick before they are +allowed to post for the first time. + +## What it is + +When the plugin is enabled (`mod_categories_enabled`), staff can define a +list of checklist items — each a line of text plus an optional link to a +post or page. A user whose trust level is at or below the configured cap +who tries to create a topic or post a reply is shown a modal listing those +items; every box must be ticked before the post goes through. Staff +(moderators and admins) never see it. + +Once a user accepts the checklist it is not shown again — unless staff +edit it, which re-prompts everyone (see *Versioning* below). + +## How a moderator configures it + +Any staff member (moderator or admin) clicks **First-post checklist** in +the sidebar's **Community** section — it opens the checklist editor in a +modal. The editor lets you: + +- **Add item** — appends a blank row. +- Edit each row's **Checkbox text** (required) and **Link URL** (optional). + One row makes a single agreement; several rows make a multi-checkbox + list. +- **Remove item** — deletes a row. +- **Move up / move down** — the up/down arrows on each row reorder the + items; the saved order is the order users see in the checklist modal. +- **Who must accept before their next post** — the trust-level cap: + - *New users only (TL0)* + - *New and basic users (TL0 and TL1)* + - *Up to members (TL0 to TL2)* — the default. +- **Accept button text** — what the modal's confirm button says; leave + blank for the default ("I agree, post"). +- **Save checklist** — persists the list. + +A row with a blank checkbox text is dropped on save. Saving an empty list +disables the feature (nothing to confirm, so the modal never appears). + +## What the user sees + +On their first topic or reply, the user gets a *Before you post* modal +listing each item as a checkbox. Items with a link show an **Open link** +button beside them (opens in a new tab). The **I agree, post** button +stays disabled until every box is ticked; **Cancel** aborts the post and +returns to the composer with content intact. + +A **Last updated** line under the intro shows when staff last saved the +checklist — a relative time (e.g. *2 days ago*) with the exact timestamp +on hover, the same format the acceptance log uses. + +## Acceptance audit log + +The checklist editor shows an **Acceptance log** below the items — a +table of every acceptance, newest first, with the **user**, the checklist +**version** they accepted, and **when**. Because the checklist is +versioned, the log shows a fresh row each time a user re-accepts after a +bump, so staff can see who has acknowledged the current version. The log +keeps the most recent 500 entries. + +Each log row also has a **Require re-accept** button. Clicking it resets +that user's recorded forum-wide checklist version to 0, so the modal +appears again on their next post. A toast confirms the reset. + +## Targeted checklists + +Below the audit log the editor has a **Targeted checklists** section. A +*targeted checklist* is a whole separate checklist aimed at specific +users — its own name, target users, item rows, accept-button text, and +version. Staff can create several. + +A targeted checklist applies to its listed users **regardless of trust +level or staff status** — a targeted moderator or admin still has to +accept it, and a high-trust user above the forum-wide cap still sees it. + +Each targeted checklist offers: + +- **Name** — a label for staff. +- **Target users** — a user picker (users only, no groups). +- Item rows — the same **Checkbox text** / **Link URL** rows as the + forum-wide editor. +- **Accept button text**. +- **Save targeted checklist** — creates it (version 1) or updates it + (bumping its version, which re-prompts its users). +- **Delete** — removes it. + +When a user owes more than one checklist, they are shown one per post. +Targeted checklists take priority over the forum-wide checklist; the +highest-priority owed one is shown each time. + +## Versioning / re-prompting + +The checklist carries a version number. Every **Save** bumps it. A user +records the highest version they have accepted; when the published +version is newer than what they accepted, the modal appears again on +their next post. This lets staff revise the checklist and be sure every +new member re-confirms. + +Re-prompting works **mid-session, without a hard page refresh**. Discourse +is a single-page app, so the `currentUser.mod_first_post_checklist` value +bootstrapped on page load goes stale once the user accepts (it is cleared +to `null`) or once staff bump the version. To avoid trusting that stale +value, the composer gate re-fetches `GET /checklist/owed` every time the +user clicks post; the modal then reflects the current server state. A +version bumped while the user is browsing is therefore caught on their +next post, with no reload needed. + +## Storage & API + +- **Config:** plugin store, namespace `discourse_mod_categories`, key + `first_post_checklist` — + `{ version, items: [{ label, url }], max_tl, button_label, updated_at }`. + `updated_at` is an ISO8601 timestamp set on every save. +- **Targeted checklists:** plugin store, same namespace, key + `targeted_checklists` — a JSON array of + `{ id, name, user_ids: [Integer], items: [{ label, url }], version, + button_label, updated_at }`. `id` is a stable `SecureRandom.hex(8)`; + `updated_at` is set on create and on every update. +- **Audit log:** plugin store, same namespace, key + `first_post_checklist_log` — an append-only array of + `{ user_id, version, at, kind, checklist_id }`, capped at the latest + 500 entries. +- **Per-user:** `mod_checklist_accepted_version` user custom field (the + highest forum-wide version that user has accepted), and + `mod_checklist_targeted_accepted` (a json map + `{ checklist_id => accepted_version }`). +- **Endpoints** (engine-mounted at `/discourse-mod-categories`): + - `GET /checklist` — current forum-wide checklist, audit log, and + targeted checklists (staff only). + - `GET /checklist/owed` — the single checklist the current user still + owes, as `{ checklist: { kind, id, version, items, button_label, + updated_at } }` or `{ checklist: null }`. Any logged-in user; runs the + same `DiscourseModCategories.owed_checklist_for` logic as the + `mod_first_post_checklist` serializer. The composer gate polls this so + a mid-session version bump is caught without a hard page refresh. + - `PUT /checklist` — replace the forum-wide checklist, bump the version + (staff only, `Guardian#can_manage_mod_messages?`). + - `POST /checklist/accept` — record the current user's acknowledgement; + takes `kind` (`"global"` or `"targeted"`), `version`, and `id` for a + targeted checklist. The accepted version is clamped to the published + version. + - `POST /checklist/require-reaccept` — reset a user's forum-wide + accepted version to 0 (staff only); takes `username` or `user_id`. + - `POST /checklist/targeted` — create a targeted checklist (staff only). + - `PUT /checklist/targeted/:id` — update a targeted checklist, bumping + its version (staff only). + - `DELETE /checklist/targeted/:id` — delete a targeted checklist (staff + only). +- The current-user serializer exposes `mod_first_post_checklist` as the + single checklist the user most needs. It carries a `kind` discriminator: + a `"targeted"` checklist (shown to its listed users regardless of trust + level or staff status) takes priority over the `"global"` forum-wide + checklist (non-staff TL0-TL2 only). It is `null` when nothing is owed. + +## Implementation + +- The composer initializer + (`assets/javascripts/discourse/initializers/precheck-prompt.js`) hooks + `composerBeforeSave`; the checklist gate runs before the moderator + prompt gate. The gate first calls `refreshOwedChecklist` + (`assets/javascripts/discourse/lib/first-post-checklist.js`), which + fetches `GET /checklist/owed` and writes the result onto + `currentUser.mod_first_post_checklist`, so the gate always reads current + server state rather than the stale bootstrapped value. +- The owed-checklist computation is shared: + `DiscourseModCategories.owed_checklist_for(user)` in `plugin.rb` backs + both the `mod_first_post_checklist` serializer and the `/checklist/owed` + endpoint, so the two cannot diverge. +- The modal is the `ModFirstPostChecklist` component; the editor page is + the `ModChecklistModal` component (opened from the sidebar) rendering + `ModChecklistEditor`. diff --git a/discourse-mod/docs/mod-categories-enabled.md b/discourse-mod/docs/mod-categories-enabled.md new file mode 100644 index 0000000..d36f189 --- /dev/null +++ b/discourse-mod/docs/mod-categories-enabled.md @@ -0,0 +1,26 @@ +# `mod_categories_enabled` + +- **Type:** boolean +- **Default:** `false` +- **Client-exposed:** yes +- **Admin path:** `/admin/site_settings/category/discourse_mod_categories` + +The plugin's master switch and the category-management capability. + +## What it does + +When enabled: + +- Members of the built-in **moderators** group can create, edit, and delete categories — abilities normally reserved for admins. +- The plugin's frontend assets load (the topic and category moderator UIs). +- Moderators may set the plugin's per-topic and per-category messages, gated by `Guardian#can_manage_mod_messages?`. + +When disabled, the plugin is inactive and only admins have the usual core abilities. + +## Category deletion rule + +A moderator may delete a category only when it has **no topics**, **no sub-categories**, and is **not** the uncategorized category. Admins are unaffected. + +## Implementation + +`lib/discourse_mod_categories/guardian_extensions.rb` prepends overrides onto `Guardian`: `can_create_category?`, `can_edit_category?`, `can_edit_serialized_category?`, `can_delete_category?`, and `can_manage_mod_messages?`. Each falls back to the moderator grant only after the core check (`super`) declines. diff --git a/discourse-mod/docs/mod-whisper-enabled.md b/discourse-mod/docs/mod-whisper-enabled.md new file mode 100644 index 0000000..28d5831 --- /dev/null +++ b/discourse-mod/docs/mod-whisper-enabled.md @@ -0,0 +1,29 @@ +# `mod_whisper_enabled` + +- **Type:** boolean +- **Default:** `true` +- **Client-exposed:** yes +- **Admin path:** `/admin/site_settings/category/discourse_mod_categories` + +Enables the moderator whisper feature. + +## What it does + +When enabled, staff (admins and moderators) can post **whispers** — in-topic +replies visible only to a limited audience instead of everyone. See +[Moderator whisper](whisper.md) for the full feature description. + +When disabled: + +- The composer eye button and target modal do not load. +- The visibility filter is bypassed — any post that previously carried the + whisper custom field becomes a plain post visible to everyone. +- No whisper notifications are sent. + +## Implementation + +Registered in `config/settings.yml` under `discourse_mod_categories:`. The +server-side guards (`Guardian#can_see_post?`, +`DiscourseModCategories::WhisperQueryFilter`, the `before_create_post` / +`post_created` hooks) all short-circuit on `SiteSetting.mod_whisper_enabled`, +and the frontend initializer returns early when it is off. diff --git a/discourse-mod/docs/moderator-notes-tab.md b/discourse-mod/docs/moderator-notes-tab.md new file mode 100644 index 0000000..997841a --- /dev/null +++ b/discourse-mod/docs/moderator-notes-tab.md @@ -0,0 +1,68 @@ +# Moderator-notes user-menu tab + +A staff-only tab in the user menu — alongside the notifications bell — that surfaces the [private moderator notes](private-moderator-note.md) set on topics across the forum. + +## What it is + +While the plugin is enabled (`mod_categories_enabled`), staff (moderators and admins) get an extra **shield** tab in their user menu. It carries an unread count and lists recent moderator notes. + +## What it looks like + +### The shield tab in the user menu + +The shield tab appears in the user-menu icon strip, next to the notifications bell, with an unread-count badge. + +![The shield tab in the user menu](../screenshots/49_user_menu_shield_tab.png) + +### The moderator-notes panel + +Opening the tab lists recent moderator notes — each entry shows the topic title and the note, and links to the topic. Unread entries are highlighted. + +![The moderator-notes panel](../screenshots/50_moderator_notes_tab_panel.png) + +## Behaviour + +- Visible **only to staff** — the tab does not render for regular users. +- The badge count is the number of topics whose moderator note (or a reply to it) has had activity since the staff member last opened the tab. +- Opening the tab marks the feed as seen and clears the count. +- Each entry links directly to the topic's last post. + +## Pop-up notifications + +Setting a moderator note, or replying to one, also sends a **real Discourse notification** to the other staff members — it appears in the notifications bell and pops up live, like a flag/review notification. The notification links to the topic's last post. + +## Header-level indicators + +Because the shield-tab's unread count is only visible when the user menu is open, the plugin also surfaces the same count in two places that are visible **without** opening the menu: + +### Header shield pip + +A small shield pip is rendered in the page header next to the avatar whenever the staff member has unread moderator notes (`currentUser.mod_note_unread_count > 0`). Clicking the pip opens the user menu and switches it to the moderator-notes tab, matching the existing shield-tab behaviour. + +![The header shield pip with an unread count](../screenshots/190_mod_note_header_pip_visible.png) + +### Browser tab-title prefix + +The plugin prefixes `document.title` with `(N)` mirroring the bell's behaviour, so an inactive browser tab still announces unread moderator notes. The prefix is applied via a pure helper (`applyUnreadPrefix`) wrapped around the document's `title` setter so route transitions re-add it automatically. + +![Browser tab with an unread-count title prefix](../screenshots/191_mod_note_browser_title_prefix.png) + +Both indicators are reactive: + +- **On page load** the count comes from the bootstrapped `currentUser.mod_note_unread_count`. +- **When the staff member opens the shield tab** the panel sets `currentUser.mod_note_unread_count` to 0 — both the pip (a tracked-property render) and the title prefix (a property observer) clear immediately. +- **When a new note or reply arrives** the server publishes a `{ delta: 1 }` payload on `/mod-note-unread-count/{user_id}` (alongside the existing `/notification-alert/` push that drives the bell). The client subscribes to this dedicated channel and bumps the local count, so the pip + title prefix update without a hard refresh. +- **Marking the feed as seen on another tab** also publishes a `{ reset: true }` payload on the same channel, so multi-tab sessions stay in lockstep. + +![Header pip and title prefix cleared after opening the tab](../screenshots/192_mod_note_header_indicators_cleared_after_seen.png) + +## Implementation + +- A topic records `mod_topic_private_note_activity_at` whenever its note or a reply to it changes. +- Each staff user records `mod_notes_seen_at` (when they last opened the tab). +- `CurrentUserSerializer` exposes `mod_note_unread_count` — topics with activity newer than `mod_notes_seen_at`. +- Endpoints: `GET /discourse-mod-categories/notes-feed` (the list) and `POST /discourse-mod-categories/notes-feed/seen` (mark the feed read). Both are staff-gated. +- The tab is registered with `api.registerUserMenuTab`; its panel is the `ModNotesPanel` component. +- The header pip is a Glimmer component (`ModNoteHeaderPip`) rendered via the `before-header-panel` plugin outlet, gated on `currentUser.staff && mod_note_unread_count > 0`. +- The title-prefix logic lives in `lib/mod-note-unread-title.js` as pure `applyUnreadPrefix(title, count)` / `stripUnreadPrefix(title)` helpers; the `discourse-mod-note-header-indicators` initializer wraps `document.title`'s setter so route transitions don't drop the prefix. +- Live updates come from a dedicated MessageBus channel `/mod-note-unread-count/{user_id}`, published from `notify_staff_of_note` (a `{ delta: 1 }` bump per recipient) and from `notes_feed_seen` (a `{ reset: true }` payload to the staff member themselves). diff --git a/discourse-mod/docs/pin-post-to-bottom.md b/discourse-mod/docs/pin-post-to-bottom.md new file mode 100644 index 0000000..9420859 --- /dev/null +++ b/discourse-mod/docs/pin-post-to-bottom.md @@ -0,0 +1,29 @@ +# Pin a post to the bottom + +Part of the topic footer feature — uses the **`topic_footer_message_enabled`** switch. + +A moderator can pin any post to the bottom of its topic. + +## How a moderator pins a post + +Open the post's admin (moderator actions) menu → **Pin to Bottom**. To remove it, choose **Unpin from Bottom** on the same post. + +## What it looks like + +This is the intended result — the pinned post as the last post in the topic, above the reply button: + +![A pinned post shown as the bottom post of a topic](../screenshots/39_pinned_post_as_bottom_post.png) + +## Behaviour + +- The pinned post renders as the **last post in the topic** — at the end of the post stream, **above the reply button** — as a regular-looking post (avatar, username, cooked content) marked with a pin badge. +- A button at the top-right links up to the original post in the stream. +- It is a rendered copy: the original post keeps its own position, number, and likes. (Discourse does not let a plugin move the real post in the stream.) +- Appears and disappears live when pinned/unpinned — no page reload. + +## Storage & API + +- **Topic custom field:** `mod_topic_pinned_post_id` (integer) +- **Endpoint:** `PUT /discourse-mod-categories/topic/:topic_id` with the `pinned_post_id` param — a post id to pin, or an empty value / `0` to unpin. +- The post must belong to the topic, otherwise the endpoint returns `400`. +- Only moderators and admins may pin (`Guardian#can_manage_mod_messages?`); the menu button is staff-only. diff --git a/discourse-mod/docs/precheck-new-topic.md b/discourse-mod/docs/precheck-new-topic.md new file mode 100644 index 0000000..a64a12a --- /dev/null +++ b/discourse-mod/docs/precheck-new-topic.md @@ -0,0 +1,47 @@ +# Per-category new-topic prompt + +Feature switch: **`precheck_new_topic_enabled`** — boolean, default `false`, client-exposed. + +A confirmation dialog shown before a user posts a **new topic** in a category. + +## How a moderator sets it + +Category → **Settings** tab → **Before-new-topic prompt** field → **Save message**. + +## What it looks like + +![The new-topic confirmation prompt](../screenshots/28_new_topic_prompt_dialog.png) + +## Behaviour + +- When a user starts a new topic in that category and clicks submit, a dialog shows the moderator's message. +- **Post anyway** submits the topic; **Go back** returns to the composer with content intact. +- Categories without a message set never prompt. +- Replies and edits are never gated by this feature — see the [reply prompt](topic-reply-prompt.md). +- URLs in the message render as clickable links. + +## Limit by trust level + +The **Show this prompt to** dropdown (below the message field) caps the +prompt by the poster's trust level: + +- **Everyone** (default) — every user sees it. +- **New users only (TL0)** — only brand-new accounts. +- **New and basic users (TL0 and TL1)** — TL0 and TL1. +- **Up to members (TL0 to TL2)** / **Up to regulars (TL0 to TL3)**. + +A user above the cap skips the dialog. This lets a category require, say, +only TL0 and TL1 users to confirm they read the guidelines before posting, +while established members post without the extra step. + +## Storage & API + +- **Category custom fields:** `mod_category_new_topic_prompt` (string), + `mod_category_new_topic_prompt_max_tl` (integer 0-4; 4/blank means everyone) +- **Endpoint:** `PUT /discourse-mod-categories/category/:category_id` with + the `new_topic_prompt` and `new_topic_prompt_max_tl` params +- Gated by `Guardian#can_edit_category?` (moderators have this while the plugin is enabled; admins always). + +## Implementation + +The composer initializer (`assets/javascripts/discourse/initializers/precheck-prompt.js`) hooks `composerBeforeSave`; for a new topic it reads `composer.category.mod_category_new_topic_prompt`. diff --git a/discourse-mod/docs/private-moderator-note.md b/discourse-mod/docs/private-moderator-note.md new file mode 100644 index 0000000..0136db6 --- /dev/null +++ b/discourse-mod/docs/private-moderator-note.md @@ -0,0 +1,62 @@ +# Private moderator note + +A staff-only note on a topic, for moderator coordination — never shown to regular users. + +## How a moderator sets it + +Topic → admin (wrench) menu → **Moderator Actions** → the **Private moderator note** field. Choose whether the note shows **at the top** or **at the bottom** of the topic, then **Save**. + +## What it looks like + +The note renders like a post — the moderator who set it, with their avatar and name, in a muted whisper-style card: + +![A private moderator note shown to staff](../screenshots/40_private_note_staff_view.png) + +## Behaviour + +- Visible **only to staff** (moderators and admins). The note is not even serialized to regular users — a non-staff user's topic JSON never contains it. +- Shown like a post: the moderator who set it, with their avatar, name, and a relative timestamp. +- Renders at the top or the bottom of the topic, per the chosen position. +- Updates live when saved — no page reload. + +## Reply thread + +The note card has a **Reply** button that opens an inline box for another +moderator to add to it. Replies form a staff-only thread, each entry showing +its author and time. Setting the note or adding a reply also sends a +[notification to the other staff members](moderator-notes-tab.md) and bumps +the topic's note activity timestamp, which drives the moderator-notes +user-menu tab. + +## Editing & deleting entries + +Every entry in the note thread is editable and removable by staff: + +- Each **reply** carries a small pencil (edit) and trash-can (delete) control. + Edit opens an inline textarea with save/cancel; delete asks for confirmation, + then removes that reply. Each reply has a stable `id` so edit/delete targets + the right entry even after other entries are removed (legacy replies without + an `id` are backfilled one on first read). +- The **note body** itself has an edit control (reopens the Moderator Actions + modal) and a delete control that clears the note, its author/created-at, and + the entire reply thread after confirmation. + +After any edit or delete the note card refreshes in place — no page reload. + +## Storage & API + +- **Topic custom fields:** `mod_topic_private_note` (string), `mod_topic_private_note_position` (`top` / `bottom`), `mod_topic_private_note_user_id` (the author), `mod_topic_private_note_created_at` (ISO-8601), `mod_topic_private_note_replies` (JSON array of `{ id, user_id, raw, created_at }`), `mod_topic_private_note_activity_at` (ISO-8601, bumped on every note/reply change). +- **Endpoints:** + - `PUT /discourse-mod-categories/topic/:topic_id` with `private_note` and `private_note_position` — set or clear the note. + - `POST /discourse-mod-categories/topic/:topic_id/note-reply` with `raw` — append a reply to the thread. + - `PUT /discourse-mod-categories/topic/:topic_id/note-reply` with `reply_id` and `raw` — edit a single reply's body (blank `raw` is rejected). + - `DELETE /discourse-mod-categories/topic/:topic_id/note-reply` with `reply_id` — remove a single reply. + - `DELETE /discourse-mod-categories/topic/:topic_id/note` — clear the note body, its author/created-at, and the whole reply thread. +- Each reply carries a stable `id` (`SecureRandom.hex(8)`), so edit/delete targets a specific entry even as the array shifts. Legacy replies without one are backfilled an `id` whenever the array is read. +- The edit/delete endpoints return the updated note + replies JSON so the frontend refreshes in place. +- The note, its position, author, created-at, and replies (each including `id`) are serialized with `include_condition: scope.is_staff?`, so they reach staff only. +- Setting the note triggers a staff notification (see the [moderator-notes tab](moderator-notes-tab.md)). + +## Who can use it + +Only **moderators and admins** may set, reply to, edit, or delete the note and its entries — every endpoint is gated by `Guardian#can_manage_mod_messages?`; regular and anonymous users get a `403`. The note and its UI never render for non-staff. diff --git a/discourse-mod/docs/settings.md b/discourse-mod/docs/settings.md new file mode 100644 index 0000000..3ed10e5 --- /dev/null +++ b/discourse-mod/docs/settings.md @@ -0,0 +1,42 @@ +# Settings & features reference + +The site settings below live under **Moderator Category Management** at `/admin/site_settings/category/discourse_mod_categories`. They only switch capabilities **on** — the message content is set by moderators, per topic or per category. + +Each setting and feature has its own page: + +## Site settings + +| Setting | Default | Documentation | +|---|---|---| +| `mod_categories_enabled` | `false` | [Master switch & category management](mod-categories-enabled.md) | +| `topic_footer_message_enabled` | `true` | [Per-topic footer message](topic-footer-message.md) | +| `precheck_new_topic_enabled` | `true` | [Per-category new-topic prompt](precheck-new-topic.md) | +| `topic_reply_prompt_enabled` | `true` | [Per-topic reply prompt](topic-reply-prompt.md) | +| `mod_whisper_enabled` | `true` | [Moderator whisper](mod-whisper-enabled.md) | + +`mod_categories_enabled` defaults to `false` — the whole plugin is inactive until an admin turns it on. The feature switches default to `true`, so each feature works as soon as the master switch is on; an admin can turn an individual feature off without disabling the plugin. + +## Moderator-set features (no site setting) + +| Feature | Documentation | +|---|---| +| Pin a post to the bottom of a topic | [Pin a post to the bottom](pin-post-to-bottom.md) | +| Require approval for replies to a topic | [Per-topic reply approval](topic-reply-approval.md) | +| Private moderator note on a topic | [Private moderator note](private-moderator-note.md) | +| Staff "Moderator notes" user-menu tab | [Moderator-notes user-menu tab](moderator-notes-tab.md) | +| Forum-wide first-post checklist (configured from the sidebar) | [First-post checklist](first-post-checklist.md) | +| Moderator whisper (staff-only side conversations in a topic) | [Moderator whisper](whisper.md) | + +## Who can set what + +Only **moderators and admins** can set the per-topic and per-category content. The persistence endpoints (`PUT /discourse-mod-categories/topic/:id` and `/category/:id`) are Guardian-gated; regular and anonymous users get `403`. + +## Related pages + +- [Feature list](features.md) — everything the plugin adds. +- [Category management](categories.md) — what moderators can do with categories. +- [Moderators vs Admins](comparison.md) — how the plugin changes the role split. + +## Tests & screenshots + +See [Tests & screenshots](testing.md) for the test-suite overview and a screenshot of every feature. diff --git a/discourse-mod/docs/testing.md b/discourse-mod/docs/testing.md new file mode 100644 index 0000000..cc68035 --- /dev/null +++ b/discourse-mod/docs/testing.md @@ -0,0 +1,974 @@ +# Tests & screenshots + +## Test suite + +The plugin is covered by four CI workflows (see `.github/workflows/`): + +| Workflow | What it runs | +|---|---| +| **Plugin Tests** | rspec — `plugin_spec.rb`, `spec/lib`, `spec/requests` (Guardian, permissions, the message endpoints, serialization, reply-approval enforcement, moderator-note notifications, the prompt trust-level caps, and the first-post checklist) | +| **Category Save Tests** | rspec — `spec/saves` (category-save regression suite) | +| **QUnit Tests** | `test/javascripts/unit` — the pure-logic matrix suites (precheck-prompt resolution incl. trust-level caps, footer message, first-post checklist gate) | +| **Frontend System Tests** | `spec/system` — Capybara/Playwright end-to-end specs; uploads the screenshots below as the `ui-screenshots` artifact | + +Every screenshot below is produced by a passing system spec, so they double as a visual regression record. Each one is explained under its own heading. + +### Coverage for the newer features + +These features are covered by request specs and QUnit unit tests; their +end-to-end screenshots are not yet part of the gallery below: + +| Feature | Tests | +|---|---| +| Moderator-note pop-up notifications | `spec/requests/mod_messages_spec.rb` — notifications go to other staff, not the actor or regular users | +| Clickable links in the prompt dialogs | exercised by the prompt system specs | +| Prompt trust-level caps | `spec/requests/mod_messages_spec.rb` (persistence, clamping) + `test/javascripts/unit/precheck-prompt-test.js` (trust-level matrix) | +| First-post checklist | `spec/requests/checklist_spec.rb` (read/edit/accept gating, version bump, trust-level cap, button label, serializer audience), `test/javascripts/unit/first-post-checklist-test.js` (gate matrix), and the system spec below | +| Per-topic prompt checklist | `spec/requests/topic_prompt_checklist_spec.rb` (staff CRUD with `mode`/`statement`/`frequency`/`max_tl`, `/checklist/owed?topic_id=…`, per-topic-per-user acceptance with clamp, version bump, topic_view serializer, statement-mode payload, frequency=`every_reply` always returns, `max_tl` cap filters higher-TL non-staff, legacy reply-prompt pre-fill + migration) and `spec/system/topic_prompt_checklist_spec.rb` (staff opens the wrench-menu "Prompt Checklist", adds items, saves; another user replies and is prompted; accepts; second reply is NOT prompted; version-bump re-prompts; statement-mode modal renders without checkboxes; `frequency: every_reply` re-prompts on the second reply; `max_tl` cap skips higher-TL users; Moderator Actions modal no longer carries the Before-reply prompt section) — screenshots 171–186 | +| Moderator-notes header pip & title prefix | `spec/requests/mod_note_header_indicators_spec.rb` (`mod_note_unread_count` across states + MessageBus publishes on note set/reply and on `notes-feed/seen`), `test/javascripts/unit/mod-note-unread-title-test.js` (pure `applyUnreadPrefix` / `stripUnreadPrefix` matrix), and `spec/system/mod_note_header_indicators_spec.rb` (header pip + `(N)` title prefix render for staff with unread notes, clear after the shield tab is opened, never render for a regular user) — screenshots 190-192 | + +--- + +## Per-topic footer message + +### Topic page, moderator view + +The starting point — a regular topic as a moderator sees it, before any moderator message is set. + +![Topic page, moderator view](../screenshots/01_topic_page_moderator.png) + +### The topic admin (wrench) menu + +Opening the topic admin menu reveals the **Moderator Actions** button that staff use to manage this plugin's per-topic content. + +![Topic admin menu open](../screenshots/02_topic_admin_menu_open.png) + +### The Moderator Actions modal — empty + +The modal opened from that menu, before anything is entered. + +![Moderator Actions modal — empty](../screenshots/03_mod_messages_modal_empty.png) + +### The footer message field filled in + +A moderator types the pinned footer message into the modal. + +![Footer message filled in](../screenshots/04_mod_messages_footer_filled.png) + +### Footer message and reply prompt both filled in + +The modal with both the footer message and the before-reply prompt entered. + +![Footer + reply prompt filled in](../screenshots/05_mod_messages_both_filled.png) + +### The footer message rendered after saving + +After saving, the footer message appears in a banner at the bottom of the topic. + +![Footer rendered after save](../screenshots/06_footer_rendered_after_save.png) + +### A topic that already has a footer message + +A topic loaded with a footer message already set — the state before an edit. + +![Topic with an existing footer](../screenshots/07_topic_with_existing_footer.png) + +### The modal re-opened for editing + +Re-opening the modal pre-fills it with the topic's current values, ready to edit. + +![Modal re-opened for editing](../screenshots/08_mod_messages_modal_editing.png) + +### The modal after editing the values + +The modal with the footer message changed to a new value. + +![Modal after editing](../screenshots/09_mod_messages_modal_edited.png) + +### The footer updated after the edit + +The footer banner now shows the edited message — updated live, without a reload. + +![Footer updated after edit](../screenshots/10_footer_updated_after_edit.png) + +### The footer present, before it is cleared + +A topic with a footer message, about to be cleared. + +![Footer before clearing](../screenshots/11_footer_before_clearing.png) + +### The modal with the footer field cleared + +The moderator empties the footer message field in the modal. + +![Modal with the field cleared](../screenshots/12_mod_messages_modal_cleared.png) + +### The footer removed after clearing + +After saving the empty field, the footer banner is gone. + +![Footer removed after clearing](../screenshots/13_footer_removed_after_clearing.png) + +### The footer message as a regular user sees it + +A non-staff member viewing the topic sees the footer banner just the same. + +![Footer visible to a regular user](../screenshots/20_footer_visible_to_user.png) + +### The footer message rendered as HTML + +HTML in the footer message (here, bold text) is rendered — it is admin/moderator-trusted content. + +![Footer message rendered as HTML](../screenshots/36_footer_html_rendered.png) + +### The footer still shown on a closed topic + +The footer message keeps showing even after the topic is closed. + +![Footer still shown on a closed topic](../screenshots/37_footer_on_closed_topic.png) + +--- + +## Per-category new-topic prompt + +### The category Settings tab — prompt field + +The **Before-new-topic prompt** field added to a category's Settings tab. + +![Category settings — prompt field](../screenshots/21_category_settings_prompt_field.png) + +### The category prompt filled in + +A moderator types the prompt for that category. + +![Category prompt filled in](../screenshots/22_category_prompt_filled.png) + +### The category prompt saved + +The prompt saved, with the green "Saved" confirmation. + +![Category prompt saved](../screenshots/23_category_prompt_saved.png) + +### The new-topic composer in that category + +A user starts a new topic in the category that has a prompt set. + +![New-topic composer in the category](../screenshots/27_new_topic_composer_in_category.png) + +### The new-topic confirmation dialog + +On submit, the moderator's message appears as a confirmation before the topic posts. + +![New-topic confirmation dialog](../screenshots/28_new_topic_prompt_dialog.png) + +### Going back from the new-topic prompt + +Choosing "Go back" returns to the composer with the content intact. + +![Go back from the new-topic prompt](../screenshots/29_new_topic_prompt_go_back.png) + +### The new topic posted after confirming + +Choosing "Post anyway" submits the topic as normal. + +![New topic posted after confirming](../screenshots/30_new_topic_posted_after_confirm.png) + +### No prompt in a category without one + +In a category that has no prompt set, a new topic posts directly with no dialog. + +![No prompt in a plain category](../screenshots/31_no_prompt_plain_category.png) + +--- + +## Per-topic reply prompt + +### A user viewing the topic + +A regular user opens a topic that has a before-reply prompt set. + +![User views the topic](../screenshots/14_user_views_topic.png) + +### The user's reply composer + +The user opens the reply composer and writes a reply. + +![User reply composer](../screenshots/15_user_reply_composer.png) + +### The before-reply confirmation dialog + +On submit, the moderator's reply prompt appears as a confirmation. + +![Reply prompt dialog](../screenshots/16_reply_prompt_dialog.png) + +### Going back from the reply prompt + +"Go back" keeps the composer open with the reply intact. + +![Go back from the reply prompt](../screenshots/17_reply_prompt_go_back.png) + +### Posting anyway from the reply prompt + +"Post anyway" submits the reply. + +![Post anyway from the reply prompt](../screenshots/18_reply_prompt_post_anyway.png) + +### No prompt when none is set + +A topic with no reply prompt configured — replies post directly. + +![No prompt when none is set](../screenshots/33_reply_no_prompt.png) + +### The reply prompt dialog (end-to-end flow) + +The before-reply dialog shown in the full reply flow. + +![Reply prompt dialog](../screenshots/34_reply_prompt_dialog.png) + +### The reply posted after confirming + +The reply is posted once the user confirms. + +![Reply posted after confirming](../screenshots/35_reply_posted_after_confirm.png) + +--- + +## Pin a post to the bottom + +### The "Pin to Bottom" option in the post admin menu + +A post's admin (moderator actions) menu, showing the **Pin to Bottom** button. + +![Pin option in the post admin menu](../screenshots/25_post_admin_menu_pin_option.png) + +### A post pinned to the bottom + +After pinning, the post is shown at the bottom of the topic. + +![Post pinned to the bottom](../screenshots/26_post_pinned_to_bottom.png) + +### A footer message and a pinned post together + +A topic showing both a moderator footer message and a pinned post. + +![Footer message and pinned post together](../screenshots/27_footer_message_and_pinned_post_together.png) + +### A pinned post, before unpinning + +A pinned post in place, about to be unpinned. + +![Pinned post before unpinning](../screenshots/28_pinned_post_before_unpin.png) + +### The topic after unpinning + +After "Unpin from Bottom", the pinned copy is gone. + +![Pinned post after unpinning](../screenshots/29_pinned_post_after_unpin.png) + +### The pinned post rendered as the bottom post + +The pinned post shown at the end of the post stream as a regular-looking post with a pin badge and a jump-to-original button. + +![Pinned post as the bottom post](../screenshots/39_pinned_post_as_bottom_post.png) + +### Pinning the last post + +When the pinned post is already the last post, only the in-stream pin badge shows — no duplicate copy. + +![Pinned post — last post](../screenshots/42_pinned_last_post.png) + +### Pinning the second-to-last post + +A non-last post gets the in-stream badge plus the copy at the bottom. + +![Pinned post — second-to-last](../screenshots/43_pinned_second_to_last.png) + +### Pinning the third-to-last post + +Same behaviour for the third-to-last post — badge plus bottom copy. + +![Pinned post — third-to-last](../screenshots/44_pinned_third_to_last.png) + +### Pinning the tenth-to-last post + +And for a post well up the thread (tenth-to-last) — badge plus bottom copy. + +![Pinned post — tenth-to-last](../screenshots/45_pinned_tenth_to_last.png) + +--- + +## Per-topic reply approval + +### "Require approval for replies" set in the modal + +The **Require approval for replies** checkbox ticked in the topic modal. While on, replies route to the review queue. + +![Require approval for replies](../screenshots/38_mod_messages_require_approval_checked.png) + +--- + +## Private moderator note + +### The private note as staff see it + +A staff-only note shown like a post — the moderator's avatar and name — never visible to regular users. + +![Private note — staff view](../screenshots/40_private_note_staff_view.png) + +### The private note hidden from a regular user + +The same topic as a non-staff user: the private note is absent (it is not even sent to non-staff). + +![Private note hidden from a regular user](../screenshots/41_private_note_hidden_from_user.png) + +### The note with a timestamp and a Reply button + +The note shows a relative timestamp ("how long ago") and a small **Reply** button. + +![Private note with timestamp and reply button](../screenshots/46_private_note_with_timestamp_and_reply_button.png) + +### The reply box on the note + +Clicking Reply opens a box for a moderator to add to the note thread. + +![Private note reply box](../screenshots/47_private_note_reply_box.png) + +### A reply added to the note thread + +The reply, posted — the note is now a staff-only thread, each entry with its author and time. + +![Private note reply added](../screenshots/48_private_note_reply_added.png) + +--- + +## Moderator-notes user-menu tab + +### The shield tab in the user menu + +Staff get a shield tab in the user menu, next to the bell, with an unread-count badge. + +![The shield tab in the user menu](../screenshots/49_user_menu_shield_tab.png) + +### The moderator-notes panel + +Opening the tab lists the moderator notes across topics — each entry shows the topic and the note, and links to the topic. + +![The moderator-notes panel](../screenshots/50_moderator_notes_tab_panel.png) + +### Header shield pip — unread count visible without opening the menu + +A staff-only shield pip rendered in the page header, carrying the same unread count as the shield tab. Visible whenever the user menu is closed and `mod_note_unread_count > 0`. Clicking it opens the menu on the shield tab. + +![Header shield pip with an unread count](../screenshots/190_mod_note_header_pip_visible.png) + +### Browser tab title prefixed with `(N)` + +`document.title` is prefixed with the unread count, mirroring the bell. The prefix is added by wrapping the global `title` setter so route transitions never drop it. + +![Browser tab title prefixed with the unread count](../screenshots/191_mod_note_browser_title_prefix.png) + +### Header pip and title prefix cleared after the shield tab is opened + +Opening the shield tab marks the feed as seen (`POST /notes-feed/seen` updates `mod_notes_seen_at` and publishes a `reset` on the dedicated MessageBus channel). The pip and the `(N)` prefix both disappear without a hard refresh. + +![Header pip and title prefix cleared](../screenshots/192_mod_note_header_indicators_cleared_after_seen.png) + +--- + +## First-post checklist + +### The checklist editor — empty + +The config modal, opened from the **First-post checklist** link in the sidebar's Community section. With no items yet, it shows the "inactive" notice — nothing is shown to users until at least one item is added. + +![The checklist editor, empty](../screenshots/51_checklist_editor_empty.png) + +### The checklist editor — filled in + +A moderator has added two checklist items (each with optional link), chosen the trust-level audience, and set custom accept-button text. + +![The checklist editor, filled in](../screenshots/52_checklist_editor_filled.png) + +### The checklist editor — saved + +After **Save checklist**, the green "Saved" confirmation appears and the items round-trip back into the editor. + +![The checklist editor, saved](../screenshots/53_checklist_editor_saved.png) + +### The checklist modal — a new user's first post + +A trust-level-0 user who tries to post for the first time is shown the checklist modal before the post goes through. The accept button is disabled until every box is ticked. + +![The checklist modal for a new user](../screenshots/54_tl0_checklist_modal.png) + +### The checklist modal — all boxes ticked + +Once the user ticks every item, the accept button (with its staff-set label) becomes enabled. + +![The checklist modal with all boxes ticked](../screenshots/55_tl0_modal_all_checked.png) + +### The reply posts after accepting + +After the user accepts the checklist, their reply is posted to the topic as normal. + +![The reply posted after accepting the checklist](../screenshots/56_tl0_reply_posted_after_accept.png) + +### The second post is not gated + +The same user's next post goes straight through — the checklist is shown only until it is accepted once. + +![The user's second post is not prompted](../screenshots/57_tl0_second_post_no_prompt.png) + +### The checklist applies across the trust-level cap + +With the audience set to "Up to members (TL0 to TL2)", a trust-level-1 user is also shown the checklist. + +![A TL1 user sees the checklist](../screenshots/58_tl1_checklist_modal.png) + +### The checklist version is bumped on edit + +Each save bumps the checklist version, shown in the editor. + +![The checklist version bumped](../screenshots/59_checklist_version_bumped.png) + +### A user is re-prompted after a version bump + +A user who already accepted an older version is shown the checklist again after staff publish a new version. + +![A user re-prompted after a version bump](../screenshots/60_reprompt_after_version_bump.png) + +### The acceptance audit log + +The editor lists every acceptance — the user, the version they accepted, and when — with a count and a current-version filter. + +![The acceptance audit log](../screenshots/61_checklist_acceptance_log.png) + +## Moderator whisper + +### The whisper button in the composer + +A staff member's reply composer shows the whisper (eye) toolbar button. + +![The whisper button in the composer](../screenshots/62_composer_whisper_button.png) + +### The whisper target modal — empty + +Clicking the whisper button opens a modal to pick which user(s) the whisper goes to. + +![The whisper target modal, empty](../screenshots/63_whisper_target_modal_empty.png) + +### The whisper target modal — users selected + +The moderator has chosen the recipients of the whisper. + +![The whisper target modal with users selected](../screenshots/64_whisper_target_modal_users_selected.png) + +### The armed-whisper pill + +With a whisper armed, the composer shows a pill naming the recipients and tints to signal the reply will be a whisper. + +![The armed-whisper pill](../screenshots/65_whisper_armed_pill.png) + +### A staff whisper posted, with its banner + +The posted whisper renders with a full-width banner listing who it was whispered to. + +![A staff whisper posted with its banner](../screenshots/66_staff_whisper_posted_banner.png) + +### A recipient sees the whisper + +A targeted user sees the whisper post and its banner in the topic. + +![A recipient sees the whisper](../screenshots/67_recipient_sees_whisper.png) + +### A non-recipient does not see the whisper + +A user who is not in the whisper's audience never sees the post. + +![A non-recipient does not see the whisper](../screenshots/68_stranger_does_not_see_whisper.png) + +### Staff oversight + +Any staff member — not just the author or targets — can see every whisper in the topic. + +![Staff oversight of whispers](../screenshots/69_staff_oversight.png) + +### A whispered-to user can whisper back + +Once staff have whispered to a user, that user's reply composer auto-arms a staff-only whisper-back. + +![A participant's whisper-back armed](../screenshots/70_participant_whisper_back_armed.png) + +### The whisper-back banner + +A user's whisper-back posts with a banner showing it is addressed to staff. + +![The whisper-back banner](../screenshots/71_whisper_back_banner.png) + +### A non-participant cannot whisper + +A non-staff user who has never been whispered to cannot start a whisper. + +![A non-participant cannot whisper](../screenshots/72_non_participant_no_op.png) + +### The site setting + +The `mod_whisper_enabled` site setting, on by default. + +![The whisper site setting](../screenshots/73_site_setting_page.png) + +### With the plugin disabled + +When the feature is off, a former whisper is visible to everyone like a normal post. + +![A whisper with the plugin disabled](../screenshots/74_plugin_disabled_visible_to_all.png) + +## Permissions + +### A regular user sees no moderator controls + +A non-staff member has no topic admin menu and no Moderator Actions button — the moderator features are staff-only. + +![A regular user sees no moderator controls](../screenshots/19_regular_user_no_mod_button.png) + +--- + +## Moderator category management + +### The categories list as a moderator + +The main `/categories` page as a moderator — the plugin's master switch lets staff see and curate every category. + +![Moderator-view categories list](../screenshots/104_moderator_categories_list.png) + +### The categories page chrome + +The categories page header as a moderator sees it, with the standard navigation. + +![Categories page chrome for a moderator](../screenshots/105_moderator_categories_page_chrome.png) + +### The category Settings tab + +The category Settings tab — the home of the per-category new-topic prompt field. + +![Category Settings tab](../screenshots/106_category_edit_settings_tab.png) + +### The category General tab + +The category General tab as a moderator. + +![Category General tab](../screenshots/107_category_edit_general_tab.png) + +### The category Security tab + +The category Security tab as a moderator. + +![Category Security tab](../screenshots/108_category_edit_security_tab.png) + +### A category's topic list + +The topic list inside a category with several topics. + +![Category topic list](../screenshots/109_category_topic_list_view.png) + +### Category badge in a topic header + +A topic header showing the category badge it belongs to. + +![Category badge in topic header](../screenshots/110_category_badge_in_topic_header.png) + +--- + +## Per-topic footer message — extra states + +### A multi-paragraph markdown footer message + +A footer message with multiple paragraphs and a markdown link, cooked as Markdown. + +![Multi-paragraph markdown footer](../screenshots/111_footer_multiparagraph_markdown.png) + +### A footer with a markdown link + +A footer message that includes an inline markdown link to external guidelines. + +![Footer with a markdown link](../screenshots/112_footer_with_markdown_link.png) + +### The official-notice box with the shield icon + +The footer rendered as Discourse's official-notice box, with the shield icon and "Moderator message" label. + +![Footer official-notice box](../screenshots/113_footer_shield_icon_box.png) + +### Footer on a thread with multiple posts + +The footer message still pinned to the bottom of a topic with many replies. + +![Footer on multi-post thread](../screenshots/114_footer_on_multi_post_thread.png) + +### The modal with only the footer field set + +The Moderator Actions modal with only the footer message field filled in. + +![Modal with only the footer field set](../screenshots/115_modal_only_footer_field_set.png) + +### The modal with only the reply prompt field set + +The Moderator Actions modal with only the reply-prompt field filled in. + +![Modal with only the reply prompt set](../screenshots/116_modal_only_reply_prompt_set.png) + +### Footer on a closed topic with a banner + +The footer message still rendered on a closed topic, alongside Discourse's "closed" banner. + +![Footer on closed topic with banner](../screenshots/117_footer_on_closed_topic_with_banner.png) + +--- + +## Per-topic reply prompt — extra states + +### Reply-prompt audience capped at TL0 + +The Moderator Actions modal with the reply-prompt audience combo-box set to "Up to new (TL0)". + +![Reply prompt audience capped at TL0](../screenshots/118_reply_prompt_audience_capped_tl0.png) + +### Reply-prompt audience capped at TL2 + +The Moderator Actions modal with the reply-prompt audience combo-box set to "Up to members (TL0-TL2)". + +![Reply prompt audience capped at TL2](../screenshots/119_reply_prompt_audience_capped_tl2.png) + +### A multi-line reply prompt entered + +The modal with a multi-line reply prompt — bullet-style guidance plus a URL. + +![Modal multi-line reply prompt](../screenshots/120_modal_multiline_reply_prompt.png) + +### The reply prompt persisted on reopen + +Re-opening the modal shows the previously-saved reply prompt prefilled. + +![Modal reopened with reply prompt persisted](../screenshots/121_modal_reopened_reply_prompt_persisted.png) + +### Clickable link inside the reply prompt dialog + +The user-facing reply confirmation dialog with a clickable URL inside its body. + +![Reply prompt clickable link](../screenshots/122_reply_prompt_clickable_link_dialog.png) + +### A TL4 user skips a TL1-capped reply prompt + +A TL4 leader replying to a topic whose reply prompt is capped at TL1 — no dialog appears, the reply posts directly. + +![Reply prompt skipped above cap](../screenshots/123_reply_prompt_skipped_above_cap.png) + +--- + +## Per-category new-topic prompt — extra states + +### Live preview with bold and a link + +The category Settings live preview rendering a markdown bold plus a linkified URL. + +![Category prompt preview — bold + link](../screenshots/124_category_prompt_preview_bold_link.png) + +### Live preview with multi-line input + +The live preview honouring line breaks in the input. + +![Category prompt preview — multi-line](../screenshots/125_category_prompt_preview_multiline.png) + +### Audience dropdown set to TL1 + +The new-topic prompt audience combo-box set to "Up to basic (TL0-TL1)". + +![Category prompt audience TL1](../screenshots/126_category_prompt_audience_tl1.png) + +### Audience dropdown set to TL0 + +The new-topic prompt audience combo-box set to "Up to new (TL0)". + +![Category prompt audience TL0](../screenshots/127_category_prompt_audience_tl0.png) + +### Persisted prompt on revisit + +Re-opening the category Settings tab shows the previously-saved prompt and its cap. + +![Category prompt persisted state](../screenshots/128_category_prompt_persisted_state.png) + +### Empty preview + +The live preview area when the prompt field is cleared. + +![Category prompt empty preview](../screenshots/129_category_prompt_preview_empty.png) + +--- + +## Pin a post to the bottom — extra states + +### Bottom-pinned post as a regular user sees it + +A topic with both a footer message and a bottom-pinned post, viewed by a regular user. + +![Pinned post — regular user](../screenshots/130_pinned_post_regular_user_view.png) + +### In-stream pin badge close-up + +The pin badge added to the original post in the post stream. + +![Pinned post in-stream badge](../screenshots/131_pinned_post_in_stream_badge.png) + +### Jump-to-original on the bottom copy + +The bottom-pinned copy includes a jump-to-original anchor link. + +![Pinned post — jump to original](../screenshots/132_pinned_post_jump_to_original.png) + +### Pinned post alongside a private note + +A topic showing both a bottom-pinned post and a staff-only private note. + +![Pinned post with private note](../screenshots/133_pinned_post_with_private_note.png) + +### Post admin menu while a post is already pinned + +The post admin menu showing the **Pin to Bottom** option for a post that is already pinned. + +![Post admin menu while pinned](../screenshots/134_post_admin_menu_while_pinned.png) + +### Pinned post and footer together, regular user + +A regular user's view of a topic that has both a bottom-pinned post and a footer message. + +![Pinned and footer — user view](../screenshots/135_pinned_plus_footer_user_view.png) + +--- + +## Per-topic reply approval — extra states + +### Approval checkbox unchecked by default + +The Moderator Actions modal opened on a fresh topic — the approval checkbox is unchecked. + +![Approval checkbox unchecked](../screenshots/136_approval_checkbox_unchecked.png) + +### Approval checkbox ticked + +The Moderator Actions modal with the approval checkbox just ticked. + +![Approval checkbox ticked](../screenshots/137_approval_checkbox_ticked.png) + +### Approval checkbox toggled back off + +The same modal after toggling the checkbox off again. + +![Approval checkbox toggled back off](../screenshots/138_approval_checkbox_untoggled.png) + +### Approval state persisted across reopen + +Re-opening the modal on a topic that already requires approval — the checkbox is pre-ticked. + +![Approval checkbox persisted](../screenshots/139_approval_checkbox_persisted.png) + +### Approval with messages filled in + +The modal with the approval checkbox ticked plus the footer/reply-prompt fields filled in. + +![Approval with messages filled](../screenshots/140_approval_with_messages_filled.png) + +### Topic view as a regular user — no approval UI + +A regular user viewing a topic that requires reply approval — no extra UI is surfaced to them. + +![Approval — user view](../screenshots/141_approval_topic_view_regular_user.png) + +--- + +## Private moderator note — extra states + +### Note positioned at the top + +A private moderator note configured to render at the top of the topic. + +![Private note — position top](../screenshots/142_private_note_position_top.png) + +### Note positioned at the bottom + +A private moderator note configured to render at the bottom of the topic. + +![Private note — position bottom](../screenshots/143_private_note_position_bottom.png) + +### Note thread with three staff replies + +A private note plus a three-message staff-only reply thread. + +![Private note — three replies](../screenshots/144_private_note_three_replies_thread.png) + +### Edit and delete affordances on a reply + +A note reply showing its inline edit and delete buttons. + +![Private note — edit/delete affordances](../screenshots/145_private_note_reply_edit_delete_affordances.png) + +### Drafting a reply in the reply composer + +The note's reply composer open with text being drafted. + +![Private note reply composer drafting](../screenshots/146_private_note_reply_composer_drafting.png) + +### Regular user — no private-note DOM node + +A regular user viewing a topic that has a private note — no DOM node for the note exists. + +![Private note hidden from user — no node](../screenshots/147_private_note_user_no_node.png) + +### Anonymous visitor — no private note + +An anonymous visitor viewing the same topic — the private note is absent. + +![Private note — anonymous view](../screenshots/148_private_note_anonymous_view.png) + +--- + +## Moderator-notes user-menu tab — extra states + +### Shield tab with an unread badge + +The user menu shield tab showing an unread badge for newly-arrived moderator notes. + +![Shield tab with unread badge](../screenshots/149_user_menu_shield_tab_with_unread.png) + +### Notes panel listing multiple entries + +The moderator-notes panel listing several notes from across the forum. + +![Notes panel — multiple entries](../screenshots/150_notes_panel_multiple_entries.png) + +### Notes panel with a single entry + +The notes panel scrolled to a single-entry state. + +![Notes panel — single entry](../screenshots/151_notes_panel_single_entry.png) + +### Notes panel after the seen marker is recorded + +The notes panel as it looks after `mod_notes_seen_at` has been recorded. + +![Notes panel — after seen](../screenshots/152_notes_panel_after_seen.png) + +### Empty-state notes panel + +The notes panel when there are no moderator notes anywhere on the forum. + +![Notes panel — empty state](../screenshots/153_notes_panel_empty_state.png) + +### Notes panel — clicking a note navigates + +Clicking a note entry lands the staff member on its topic. + +![Notes panel link navigated](../screenshots/154_notes_panel_link_navigated.png) + +--- + +## First-post checklist — extra states + +### Inactive notice in the editor + +The checklist editor's inactive notice when no items have been configured yet. + +![Checklist editor inactive notice](../screenshots/155_checklist_editor_inactive_notice.png) + +### Checklist editor with a custom button label + +The editor populated with a checklist whose accept-button label has been overridden. + +![Checklist editor — custom button label](../screenshots/156_checklist_editor_custom_button_label.png) + +### Checklist editor audience set to TL1 + +The editor with the audience combo-box set to "Up to basic (TL0-TL1)". + +![Checklist editor — audience TL1](../screenshots/157_checklist_editor_audience_tl1.png) + +### "Last updated" line on the user-facing modal + +The user-facing checklist modal showing its "Last updated" line. + +![Checklist user modal — last updated](../screenshots/158_checklist_user_modal_last_updated.png) + +### Custom button label on the user-facing modal + +The user-facing checklist modal with the staff-set custom accept-button label. + +![Checklist user modal — custom button](../screenshots/159_checklist_user_modal_custom_button.png) + +### Audit log with many entries + +The acceptance audit log populated with five entries across multiple versions and users. + +![Checklist audit log — many entries](../screenshots/160_checklist_audit_log_many_entries.png) + +### Targeted checklist listed in the editor + +The editor showing a targeted checklist alongside the global one. + +![Targeted checklist listed](../screenshots/161_targeted_checklist_listed.png) + +### TL2 user prompted under a TL0-TL2 cap + +A TL2 user prompted with the checklist because the audience cap is TL2. + +![Checklist — TL2 user prompted](../screenshots/162_checklist_tl2_user_prompted.png) + +--- + +## Moderator whisper — extra states + +### Whisper banner with one target + +A staff whisper post with a single targeted user in its banner. + +![Whisper banner — one target](../screenshots/163_whisper_banner_one_target.png) + +### Whisper banner with three targets + +A staff whisper post with three targeted users listed in its banner. + +![Whisper banner — three targets](../screenshots/164_whisper_banner_three_targets.png) + +### Staff-only whisper banner + +A staff-only whisper post (no user targets) — banner indicates the whisper is to staff. + +![Whisper banner — staff only](../screenshots/165_whisper_banner_staff_only.png) + +### Group-targeted whisper banner + +A whisper targeted at a group, with the group name shown in the banner. + +![Whisper banner — group target](../screenshots/166_whisper_banner_group_target.png) + +### Armed-whisper pill with a single user + +The composer's armed-whisper pill showing a single chosen recipient. + +![Armed whisper pill — single user](../screenshots/167_armed_whisper_pill_single_user.png) + +### Add-participant modal with a chosen user + +The whisper add-participant modal with a user picked, ready to confirm. + +![Add-participant modal — user chosen](../screenshots/168_whisper_add_participant_modal_user_chosen.png) + +### Non-participant topic view + +A non-participant user viewing the topic — the whisper post is absent. + +![Whisper — non-participant view](../screenshots/169_whisper_non_participant_view.png) + +### Recipient topic view + +A whisper recipient's view of the topic — the banner is visible. + +![Whisper — recipient view](../screenshots/170_whisper_recipient_full_view.png) diff --git a/discourse-mod/docs/topic-footer-message.md b/discourse-mod/docs/topic-footer-message.md new file mode 100644 index 0000000..44383fd --- /dev/null +++ b/discourse-mod/docs/topic-footer-message.md @@ -0,0 +1,40 @@ +# Per-topic footer message + +Feature switch: **`topic_footer_message_enabled`** — boolean, default `true`, client-exposed. + +A moderator-set message pinned to the bottom of a single topic. + +## How a moderator sets it + +Topic → admin (wrench) menu → **Moderator Actions** → **Pinned footer message** +field → **Save**. + +The **Moderator Actions** modal is split into two sections — *What users +see* (the footer message and reply prompt) and *Moderation tools* (reply +approval and the private note). Saving any field shows a green "Moderator +settings saved" toast and closes the modal. + +## What it looks like + +![The footer message at the bottom of a topic](../screenshots/06_footer_rendered_after_save.png) + +## Behaviour + +- Renders in an official-notice box at the bottom of the topic — below the + last post, above suggested topics — with a shield icon and a + **Moderator message** label. +- The message is cooked as **Markdown**: plain text, bare URLs, links + (`[text](url)`), and basic formatting all render correctly. It is stored + raw; only the display is cooked. +- Not shown in private messages, or when the message is blank. +- Appears, updates, and disappears live when saved — no page reload. + +## Storage & API + +- **Topic custom field:** `mod_topic_footer_message` (string) +- **Endpoint:** `PUT /discourse-mod-categories/topic/:topic_id` with the `footer_message` param +- Only moderators and admins may set it (`Guardian#can_manage_mod_messages?`); regular and anonymous users get `403`. + +## Related + +The same bottom-of-topic area also shows a [pinned post](pin-post-to-bottom.md) when one is set. diff --git a/discourse-mod/docs/topic-prompt-checklist.md b/discourse-mod/docs/topic-prompt-checklist.md new file mode 100644 index 0000000..28cda1a --- /dev/null +++ b/discourse-mod/docs/topic-prompt-checklist.md @@ -0,0 +1,131 @@ +# Per-topic prompt checklist + +A topic-scoped confirmation a user must accept before their reply to that +topic is allowed through. Sits alongside (and integrates with) the +forum-wide first-post checklist and the targeted checklists. Two modes: + +- **Statement** — a single Markdown-cooked message + an accept button. + No checkboxes; the accept button is enabled immediately. Replaces the + legacy per-topic before-reply prompt. +- **Checklist** — multiple items, each with an optional link, all of + which must be ticked before the accept button enables. + +## Audience + +Configurable. By default everyone replying — including staff — +sees the prompt. A `max_tl` cap (Discourse combo-box) hides the prompt +from non-staff users above the chosen trust level; staff always see it. +A targeted checklist still takes priority over the per-topic prompt for +the users it targets. + +## Frequency + +Configurable per topic: + +- **Once per user per topic** (default) — version-tracked acceptance. + A staff edit bumps the version and re-prompts every previously-accepted + user. +- **On every reply** — the prompt always fires. No acceptance is + recorded, so the user is prompted again on their next reply. + +## Staff: attaching a prompt to a topic + +1. Open the topic. +2. Click the wrench (topic admin) menu. +3. Click **Prompt Checklist** (a dedicated entry, separate from + "Moderator Actions"). +4. Pick the **Mode** (Statement or Checklist). +5. Fill in the statement text *or* add one or more checklist items. +6. Pick the **frequency** (Once per user per topic, or On every reply). +7. Optionally set the **trust-level cap**. +8. Set the **accept-button text** and **Save**. + +Saving bumps the prompt version. To clear, click **Clear** in the editor. + +### Migrating from the legacy reply prompt + +When the Prompt Checklist editor loads for a topic that has a legacy +`mod_topic_reply_prompt` set (and no new prompt-checklist config yet), +the editor pre-fills itself in **Statement mode** with the legacy text +and the legacy `mod_topic_reply_prompt_max_tl` as `max_tl`. A migration +notice is shown above the form. Clicking Save writes the new config and +clears the two legacy fields on the topic, so the new prompt is the +sole source of truth from then on. + +## User: replying to a gated topic + +When the composer opens for a reply to a topic with an active prompt, +the user is shown the same modal used by the forum-wide checklist. + +- **Statement mode:** the statement is rendered as cooked Markdown + (links, bold, etc.) and the accept button is enabled immediately. +- **Checklist mode:** every item must be ticked before the accept + button enables. + +Closing the modal cancels the reply. + +Once a user accepts version N for a topic in `once` mode, they are not +re-prompted for subsequent replies in that topic until staff bump the +version. In `every_reply` mode they are prompted every time. + +## Storage + +- Topic custom field `mod_topic_prompt_checklist` (json), shape + ``` + { + "version": Int, + "mode": "statement" | "checklist", + "statement": String, # used in statement mode + "items": [{ "label", "url" }], # used in checklist mode + "frequency": "once" | "every_reply", + "max_tl": 0-4, # 4 = everyone + "button_label": String, + "updated_at": ISO-8601 + } + ``` + Absent / inactive (no items in checklist mode, or blank statement in + statement mode) = no prompt. +- User custom field `mod_topic_checklist_accepted` (json), map of + `{ topic_id => accepted_version }`. Only written for `frequency: once`. + +The legacy custom fields `mod_topic_reply_prompt` and +`mod_topic_reply_prompt_max_tl` remain registered so any topic that +hasn't been migrated yet still works. Saving a new per-topic config +clears them. + +## API + +Staff-gated routes mounted under the plugin engine: + +- `GET /discourse-mod-categories/topic/:topic_id/prompt-checklist` + - Returns the editor payload, including `mode`, `statement`, + `frequency`, `max_tl`, and `from_legacy: true` when seeded from + the legacy reply-prompt fields. +- `PUT /discourse-mod-categories/topic/:topic_id/prompt-checklist` + - Accepts `mode`, `statement`, `items` (index-keyed-hash also OK), + `frequency`, `max_tl`, `button_label`. Bumps the version. Clears + the legacy `mod_topic_reply_prompt*` fields. +- `DELETE /discourse-mod-categories/topic/:topic_id/prompt-checklist` + +The composer gate uses the existing `GET /discourse-mod-categories/checklist/owed` +endpoint and passes `topic_id` so the server can include the per-topic +prompt in the owed result. Priority: targeted > per-topic > global. +When per-topic is owed, the returned payload has `kind: "topic"`, +`id: topic_id`, plus `mode`, `statement`, `items`, `frequency`, and +`max_tl`. + +Acceptance reuses `POST /discourse-mod-categories/checklist/accept` with +`kind: "topic"` and `id: topic_id`; the accepted version is recorded +into `mod_topic_checklist_accepted[topic_id]`, clamped to the published +version. For `every_reply` frequency the write is harmless (the next +post is prompted again anyway). + +## Interactions with the other checklists + +Priority on `/checklist/owed`: + +1. Targeted (regardless of trust level or staff status) +2. Per-topic (only when `topic_id` is supplied — i.e. replying) +3. Global forum-wide (non-staff, trust-level cap) + +A user owing more than one is shown the highest-priority one. diff --git a/discourse-mod/docs/topic-reply-approval.md b/discourse-mod/docs/topic-reply-approval.md new file mode 100644 index 0000000..737b8cf --- /dev/null +++ b/discourse-mod/docs/topic-reply-approval.md @@ -0,0 +1,30 @@ +# Per-topic reply approval + +Part of the topic moderator settings — set per topic, no separate site setting. + +A moderator can require that replies to a specific topic be approved before they appear — the per-topic analogue of a category's "require reply approval". + +## How a moderator enables it + +Topic → admin (wrench) menu → **Moderator Actions** → tick **Require approval for replies** → **Save**. + +## What it looks like + +![The 'Require approval for replies' option in the topic modal](../screenshots/38_mod_messages_require_approval_checked.png) + +## Behaviour + +- While on, replies to that topic are routed to the **review queue** for a moderator to approve, instead of being published directly. +- **Staff** replies (moderators/admins, and anyone who can review the topic) are posted directly. +- **New topics** are unaffected — only replies to the flagged topic are gated. + +## Storage & implementation + +- **Topic custom field:** `mod_topic_require_reply_approval` (boolean) +- **Endpoint:** `PUT /discourse-mod-categories/topic/:topic_id` with the `require_reply_approval` param +- The current value is exposed on the topic view as `mod_topic_require_reply_approval` so the modal can pre-fill the checkbox. +- Enforced server-side in `plugin.rb` by a `NewPostManager.add_handler` block: for a reply whose topic has the flag set (and whose author cannot review the topic), it calls `manager.enqueue("mod_topic_requires_reply_approval")`, creating a `ReviewableQueuedPost`. + +## Who can use it + +Only **moderators and admins** can change the flag — the persistence endpoint is gated by `Guardian#can_manage_mod_messages?`; regular and anonymous users get a `403`. The checkbox itself only renders for staff. diff --git a/discourse-mod/docs/topic-reply-prompt.md b/discourse-mod/docs/topic-reply-prompt.md new file mode 100644 index 0000000..3cb1599 --- /dev/null +++ b/discourse-mod/docs/topic-reply-prompt.md @@ -0,0 +1,60 @@ +# Per-topic reply prompt (legacy — superseded) + +> **Superseded.** This feature has been folded into the +> [Per-topic prompt checklist](topic-prompt-checklist.md) under its +> **Statement mode**. The per-topic reply prompt is no longer configured +> from the "Moderator Actions" modal — staff set it from the topic admin +> (wrench) menu → **Prompt Checklist** instead. The legacy fields below +> still work for any topic that hasn't been migrated yet; opening the +> Prompt Checklist editor for such a topic pre-fills it in Statement +> mode and saving migrates the topic. + +## What it used to be + +A confirmation dialog shown before a user posts a **reply** to a topic. +A moderator set the message + a trust-level audience cap. + +- **Post anyway** submitted the reply; **Go back** kept the composer open. +- Only replies were gated — new topics and edits were not. +- Topics without a message set never prompted. +- URLs rendered as clickable links. + +Feature switch: `topic_reply_prompt_enabled` — kept for backwards +compatibility; defaults to `false`. + +## What replaced it + +The [Per-topic prompt checklist](topic-prompt-checklist.md), specifically +its **Statement mode**: + +- Single Markdown-cooked message + a single accept button (replaces the + legacy dialog). +- A `max_tl` audience cap (replaces `mod_topic_reply_prompt_max_tl`). +- A `frequency` selector (new — pick once-per-user-per-topic or on every + reply). +- A version that bumps on every save (new — staff edits re-prompt + previously-accepted users). +- A unified storage shape covering both Statement and Checklist modes. + +## Migration + +When the new Prompt Checklist editor opens for a topic that still has +`mod_topic_reply_prompt` set, the editor pre-fills itself in Statement +mode with the legacy text and the legacy `mod_topic_reply_prompt_max_tl` +as the new `max_tl`. A notice in the editor calls this out. Clicking +**Save** writes the new config and clears the two legacy custom fields. + +## Storage (legacy fields) + +- `mod_topic_reply_prompt` — string. Still registered. +- `mod_topic_reply_prompt_max_tl` — integer 0-4; 4 / blank means everyone. + +These are cleared automatically once a moderator saves the new per-topic +prompt config for the topic. + +## Related + +- [Per-topic prompt checklist](topic-prompt-checklist.md) — the + current home for this feature. +- [Per-topic reply approval](topic-reply-approval.md) — for requiring + moderator approval of replies (not just a prompt). diff --git a/discourse-mod/docs/whisper.md b/discourse-mod/docs/whisper.md new file mode 100644 index 0000000..596b4ba --- /dev/null +++ b/discourse-mod/docs/whisper.md @@ -0,0 +1,103 @@ +# Moderator whisper + +A **whisper** is an in-topic post that is visible only to a limited audience +instead of everyone reading the topic. It lets staff hold a side +conversation — with a specific member, or staff-only — without leaving the +thread or creating a separate private message. + +Controlled by the [`mod_whisper_enabled`](mod-whisper-enabled.md) site +setting (default **on**). + +## Who sees a whisper + +A whisper post is visible to, and only to: + +- the **post author**; +- **all staff** (admins and moderators), for oversight; +- the post's **explicit user targets** — the users staff picked; +- members of any of the post's **explicit target groups**; +- the topic's **whisper participants** — every non-staff user who has ever + been whispered to in that topic. + +Everyone else, and anonymous visitors, never see the post: it is filtered +out of the topic stream at the SQL level and hidden by `Guardian`. + +## Posting a whisper + +Open the reply composer and click the **eye** button in the toolbar. + +- **Staff** get a modal to pick up to 10 targets — individual **users**, + whole **groups**, or a mix of both. Posting the reply marks it as a + whisper to that audience; every member of a target group can see it. The + non-staff user targets are recorded as the topic's cumulative whisper + participants. +- A **non-staff whisper participant** (someone staff whispered to earlier in + the topic) can whisper *back*. Their whisper is always **staff-only** — it + carries an empty target list, so only staff and the author see it. +- A **non-staff non-participant** has no whisper ability; the eye button + does nothing for them. + +When replying directly to a whisper, the composer auto-arms a whisper-back +so the side conversation stays contained. + +## Adding a user to a whisper conversation + +Staff can add a user to a topic's whisper conversation after the fact. On any +whisper post, open the post admin (wrench) menu and choose **Add user to +whisper**. A modal opens a user chooser; the chosen users are merged into the +topic's `mod_whisper_participant_ids`. From then on each added user sees +**every** whisper in that topic — past and future — and can whisper back. + +This is staff-only (`Guardian#can_manage_mod_messages?` — admins always, +moderators while the plugin is enabled) and requires `mod_whisper_enabled`. +Adding the same user twice is a no-op (ids are de-duplicated). Each newly +added user receives a notification. + +Endpoint: `POST /discourse-mod-categories/topic/:topic_id/whisper-participant` +with a `username` (or `user_id`); it returns the updated participant list. + +## Visual treatment + +A received whisper shows a full-width banner above its body and a coloured +left border on the post: + +- **indigo** — a staff-authored whisper to chosen users; +- **amber** — a staff-only whisper (empty target list / whisper-back). + +## Notifications + +- A staff-authored whisper notifies its chosen targets. +- A non-staff whisper-back notifies all staff. + +## Data model + +| Field | Scope | Meaning | +|---|---|---| +| `mod_whisper_target_user_ids` | post custom field (json) | The whisper's target user ids. **Key presence** marks the post a whisper — even an empty `[]` (a staff-only whisper-back). | +| `mod_whisper_target_group_ids` | post custom field (json) | The whisper's target group ids. A member of any of these groups can see the whisper. May be empty even on a whisper with user targets. | +| `mod_whisper_participant_ids` | topic custom field (json) | Cumulative non-staff users ever whispered to in the topic. Gates "can whisper back" and is part of every whisper's audience. | + +Constant `DiscourseModCategories::MAX_WHISPER_TARGETS` caps a single +whisper at 10 targets. + +## Implementation + +- `lib/discourse_mod_categories/guardian_extensions.rb` — `can_see_post?` + and `can_whisper_in_topic?`. +- `lib/discourse_mod_categories/whisper_query_filter.rb` — the SQL stream + filter, wired through `TopicView.apply_custom_default_scope`. It must + agree with the Guardian override (a parity spec locks this). +- `plugin.rb` — the `before_create_post` hook that marks the post and + updates the topic participants, the `post_created` notification hook, and + the post serializer attributes (`mod_is_whisper`, + `mod_whisper_target_user_ids`, `mod_whisper_targets`, + `mod_whisper_target_group_ids`, `mod_whisper_target_groups`, + `mod_whisper_is_staff_only`, `mod_whisper_author_is_staff`). +- `assets/javascripts/discourse/initializers/mod-whisper.js` — composer + toolbar button, target modal, armed pill, and the cooked-element banner + decorator. +- `app/controllers/discourse_mod_categories/messages_controller.rb` — + `add_whisper_participant` adds a user to the topic's whisper conversation. +- `assets/javascripts/discourse/initializers/mod-whisper-add-participant.js` + and `components/mod-whisper-add-participant-modal.gjs` — the "Add user to + whisper" post-admin-menu button and its user-chooser modal. diff --git a/discourse-mod/lib/discourse_mod_categories/guardian_extensions.rb b/discourse-mod/lib/discourse_mod_categories/guardian_extensions.rb new file mode 100644 index 0000000..75e7133 --- /dev/null +++ b/discourse-mod/lib/discourse_mod_categories/guardian_extensions.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module DiscourseModCategories + module GuardianExtensions + def can_create_category?(parent = nil) + return true if super + mod_categories_grant? + end + + def can_edit_category?(category) + return true if super + mod_categories_grant? + end + + def can_edit_serialized_category?(category_id:, read_restricted:) + return true if super + mod_categories_grant? + end + + def can_delete_category?(category) + return true if super + return false if !mod_categories_grant? + category.topic_count == 0 && !category.uncategorized? && !category.has_children? + end + + # Whether the current user may set the plugin's moderator messages + # (per-topic footer, per-topic reply prompt). Admins always may; + # moderators may while the plugin is enabled. Regular users never may. + def can_manage_mod_messages? + return true if is_admin? + mod_categories_grant? + end + + # A whisper post is one that carries the POST_WHISPER_TARGETS_FIELD custom + # field — KEY PRESENCE marks it, even an empty `[]` array. It is visible + # only to: the post author, all staff, the post's explicit targets, and + # the topic's cumulative whisper participants. Anyone else (and anonymous + # viewers) cannot see it. + def can_see_post?(post) + return super unless SiteSetting.mod_whisper_enabled + return super unless post.is_a?(::Post) + return super unless post.custom_fields.key?( + DiscourseModCategories::POST_WHISPER_TARGETS_FIELD, + ) + + # Anonymous viewers never see whispers. Check BEFORE touching @user.id. + return false unless authenticated? + + # Author always sees their own whisper. + return super if post.user_id == @user.id + # Staff (admins + moderators) see every whisper for oversight. + return super if @user.staff? + # Explicit user targets see it. + return super if mod_whisper_target_ids(post).include?(@user.id) + # Members of any explicit target group see it. + target_group_ids = mod_whisper_target_group_ids(post) + if target_group_ids.any? && + ::GroupUser.exists?( + group_id: target_group_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, + ) + + false + end + + # Whether the current user may post a whisper in the given topic. Staff + # always may; a non-staff user may only if they are already a recorded + # whisper participant of the topic (i.e. staff whispered to them before). + def can_whisper_in_topic?(topic) + return false unless SiteSetting.mod_whisper_enabled + return false unless authenticated? + return true if is_staff? + + mod_whisper_participant_ids(topic).include?(@user.id) + end + + private + + def mod_whisper_target_ids(post) + raw = post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGETS_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_target_group_ids(post) + raw = + post.custom_fields[ + DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_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 + + raw = + topic.custom_fields[ + DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD + ] + Array(raw) + .map { |id| id.is_a?(Numeric) || id.is_a?(String) ? id.to_i : 0 } + .reject { |id| id <= 0 } + end + + def mod_categories_grant? + SiteSetting.mod_categories_enabled && is_moderator? + end + end +end diff --git a/discourse-mod/lib/discourse_mod_categories/whisper_query_filter.rb b/discourse-mod/lib/discourse_mod_categories/whisper_query_filter.rb new file mode 100644 index 0000000..4ded3da --- /dev/null +++ b/discourse-mod/lib/discourse_mod_categories/whisper_query_filter.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module DiscourseModCategories + # Apply a whisper-visibility filter to an ActiveRecord Post scope. A post is + # a whisper when it has a `mod_whisper_target_user_ids` custom field (key + # PRESENCE marks it — even an empty `[]` array). The returned scope drops + # any whisper post whose audience does not include the given user. + # + # Audience = topic whisper-participants + all staff + post author + the + # post's explicit user targets + members of any of the post's target + # groups. Staff bypass the filter entirely. Anonymous viewers see no + # whispers at all. + # + # The same visibility rules as GuardianExtensions#can_see_post? apply — the + # two must agree (see the parity spec). + module WhisperQueryFilter + module_function + + def apply(scope, user) + return scope unless SiteSetting.mod_whisper_enabled + return scope if user&.staff? + + field = DiscourseModCategories::POST_WHISPER_TARGETS_FIELD + join_sql = <<~SQL + LEFT JOIN post_custom_fields mw_pcf + ON mw_pcf.post_id = posts.id + AND mw_pcf.name = '#{field}' + SQL + + if user + participant_field = + DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD + groups_field = DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD + where_sql = <<~SQL + mw_pcf.id IS NULL + OR posts.user_id = :uid + OR mw_pcf.value::jsonb @> :uid_json::jsonb + OR EXISTS ( + SELECT 1 + FROM topic_custom_fields mw_tcf + WHERE mw_tcf.topic_id = posts.topic_id + AND mw_tcf.name = '#{participant_field}' + AND mw_tcf.value IS NOT NULL + AND mw_tcf.value <> '' + AND mw_tcf.value::jsonb @> :uid_json::jsonb + ) + OR EXISTS ( + SELECT 1 + FROM post_custom_fields mw_gcf + JOIN group_users mw_gu + ON mw_gu.user_id = :uid + AND mw_gcf.value::jsonb @> to_jsonb(mw_gu.group_id) + WHERE mw_gcf.post_id = posts.id + AND mw_gcf.name = '#{groups_field}' + AND mw_gcf.value IS NOT NULL + AND mw_gcf.value <> '' + AND mw_gcf.value <> '[]' + ) + SQL + + scope.joins(join_sql).where( + where_sql, + uid: user.id, + uid_json: user.id.to_json, + ) + else + scope.joins(join_sql).where("mw_pcf.id IS NULL") + end + end + end +end diff --git a/discourse-mod/screenshots/01_topic_page_moderator.png b/discourse-mod/screenshots/01_topic_page_moderator.png new file mode 100644 index 0000000..a6edfdb Binary files /dev/null and b/discourse-mod/screenshots/01_topic_page_moderator.png differ diff --git a/discourse-mod/screenshots/02_topic_admin_menu_open.png b/discourse-mod/screenshots/02_topic_admin_menu_open.png new file mode 100644 index 0000000..8193122 Binary files /dev/null and b/discourse-mod/screenshots/02_topic_admin_menu_open.png differ diff --git a/discourse-mod/screenshots/03_mod_messages_modal_empty.png b/discourse-mod/screenshots/03_mod_messages_modal_empty.png new file mode 100644 index 0000000..33e5d7b Binary files /dev/null and b/discourse-mod/screenshots/03_mod_messages_modal_empty.png differ diff --git a/discourse-mod/screenshots/04_mod_messages_footer_filled.png b/discourse-mod/screenshots/04_mod_messages_footer_filled.png new file mode 100644 index 0000000..38158d0 Binary files /dev/null and b/discourse-mod/screenshots/04_mod_messages_footer_filled.png differ diff --git a/discourse-mod/screenshots/05_mod_messages_both_filled.png b/discourse-mod/screenshots/05_mod_messages_both_filled.png new file mode 100644 index 0000000..38158d0 Binary files /dev/null and b/discourse-mod/screenshots/05_mod_messages_both_filled.png differ diff --git a/discourse-mod/screenshots/06_footer_rendered_after_save.png b/discourse-mod/screenshots/06_footer_rendered_after_save.png new file mode 100644 index 0000000..237f4d9 Binary files /dev/null and b/discourse-mod/screenshots/06_footer_rendered_after_save.png differ diff --git a/discourse-mod/screenshots/07_topic_with_existing_footer.png b/discourse-mod/screenshots/07_topic_with_existing_footer.png new file mode 100644 index 0000000..848c2d1 Binary files /dev/null and b/discourse-mod/screenshots/07_topic_with_existing_footer.png differ diff --git a/discourse-mod/screenshots/08_mod_messages_modal_editing.png b/discourse-mod/screenshots/08_mod_messages_modal_editing.png new file mode 100644 index 0000000..ccfaf37 Binary files /dev/null and b/discourse-mod/screenshots/08_mod_messages_modal_editing.png differ diff --git a/discourse-mod/screenshots/09_mod_messages_modal_edited.png b/discourse-mod/screenshots/09_mod_messages_modal_edited.png new file mode 100644 index 0000000..1e2e437 Binary files /dev/null and b/discourse-mod/screenshots/09_mod_messages_modal_edited.png differ diff --git a/discourse-mod/screenshots/100_targeted_checklist_saved.png b/discourse-mod/screenshots/100_targeted_checklist_saved.png new file mode 100644 index 0000000..59197a8 Binary files /dev/null and b/discourse-mod/screenshots/100_targeted_checklist_saved.png differ diff --git a/discourse-mod/screenshots/101_targeted_checklist_prompt.png b/discourse-mod/screenshots/101_targeted_checklist_prompt.png new file mode 100644 index 0000000..dd8a763 Binary files /dev/null and b/discourse-mod/screenshots/101_targeted_checklist_prompt.png differ diff --git a/discourse-mod/screenshots/102_reprompt_session_first_accept.png b/discourse-mod/screenshots/102_reprompt_session_first_accept.png new file mode 100644 index 0000000..22a0238 Binary files /dev/null and b/discourse-mod/screenshots/102_reprompt_session_first_accept.png differ diff --git a/discourse-mod/screenshots/103_reprompt_session_after_bump.png b/discourse-mod/screenshots/103_reprompt_session_after_bump.png new file mode 100644 index 0000000..4d02b69 Binary files /dev/null and b/discourse-mod/screenshots/103_reprompt_session_after_bump.png differ diff --git a/discourse-mod/screenshots/104_moderator_categories_list.png b/discourse-mod/screenshots/104_moderator_categories_list.png new file mode 100644 index 0000000..059729c Binary files /dev/null and b/discourse-mod/screenshots/104_moderator_categories_list.png differ diff --git a/discourse-mod/screenshots/105_moderator_categories_page_chrome.png b/discourse-mod/screenshots/105_moderator_categories_page_chrome.png new file mode 100644 index 0000000..2d4f56c Binary files /dev/null and b/discourse-mod/screenshots/105_moderator_categories_page_chrome.png differ diff --git a/discourse-mod/screenshots/106_category_edit_settings_tab.png b/discourse-mod/screenshots/106_category_edit_settings_tab.png new file mode 100644 index 0000000..3c83793 Binary files /dev/null and b/discourse-mod/screenshots/106_category_edit_settings_tab.png differ diff --git a/discourse-mod/screenshots/107_category_edit_general_tab.png b/discourse-mod/screenshots/107_category_edit_general_tab.png new file mode 100644 index 0000000..920d140 Binary files /dev/null and b/discourse-mod/screenshots/107_category_edit_general_tab.png differ diff --git a/discourse-mod/screenshots/108_category_edit_security_tab.png b/discourse-mod/screenshots/108_category_edit_security_tab.png new file mode 100644 index 0000000..4446467 Binary files /dev/null and b/discourse-mod/screenshots/108_category_edit_security_tab.png differ diff --git a/discourse-mod/screenshots/109_category_topic_list_view.png b/discourse-mod/screenshots/109_category_topic_list_view.png new file mode 100644 index 0000000..ac013f8 Binary files /dev/null and b/discourse-mod/screenshots/109_category_topic_list_view.png differ diff --git a/discourse-mod/screenshots/10_footer_updated_after_edit.png b/discourse-mod/screenshots/10_footer_updated_after_edit.png new file mode 100644 index 0000000..0eacc51 Binary files /dev/null and b/discourse-mod/screenshots/10_footer_updated_after_edit.png differ diff --git a/discourse-mod/screenshots/110_category_badge_in_topic_header.png b/discourse-mod/screenshots/110_category_badge_in_topic_header.png new file mode 100644 index 0000000..b6444c4 Binary files /dev/null and b/discourse-mod/screenshots/110_category_badge_in_topic_header.png differ diff --git a/discourse-mod/screenshots/111_footer_multiparagraph_markdown.png b/discourse-mod/screenshots/111_footer_multiparagraph_markdown.png new file mode 100644 index 0000000..c65d556 Binary files /dev/null and b/discourse-mod/screenshots/111_footer_multiparagraph_markdown.png differ diff --git a/discourse-mod/screenshots/112_footer_with_markdown_link.png b/discourse-mod/screenshots/112_footer_with_markdown_link.png new file mode 100644 index 0000000..2a64c36 Binary files /dev/null and b/discourse-mod/screenshots/112_footer_with_markdown_link.png differ diff --git a/discourse-mod/screenshots/113_footer_shield_icon_box.png b/discourse-mod/screenshots/113_footer_shield_icon_box.png new file mode 100644 index 0000000..19ae6fe Binary files /dev/null and b/discourse-mod/screenshots/113_footer_shield_icon_box.png differ diff --git a/discourse-mod/screenshots/114_footer_on_multi_post_thread.png b/discourse-mod/screenshots/114_footer_on_multi_post_thread.png new file mode 100644 index 0000000..8798e10 Binary files /dev/null and b/discourse-mod/screenshots/114_footer_on_multi_post_thread.png differ diff --git a/discourse-mod/screenshots/115_modal_only_footer_field_set.png b/discourse-mod/screenshots/115_modal_only_footer_field_set.png new file mode 100644 index 0000000..f298f05 Binary files /dev/null and b/discourse-mod/screenshots/115_modal_only_footer_field_set.png differ diff --git a/discourse-mod/screenshots/116_modal_only_reply_prompt_set.png b/discourse-mod/screenshots/116_modal_only_reply_prompt_set.png new file mode 100644 index 0000000..a2a786f Binary files /dev/null and b/discourse-mod/screenshots/116_modal_only_reply_prompt_set.png differ diff --git a/discourse-mod/screenshots/117_footer_on_closed_topic_with_banner.png b/discourse-mod/screenshots/117_footer_on_closed_topic_with_banner.png new file mode 100644 index 0000000..997f6a9 Binary files /dev/null and b/discourse-mod/screenshots/117_footer_on_closed_topic_with_banner.png differ diff --git a/discourse-mod/screenshots/118_reply_prompt_audience_capped_tl0.png b/discourse-mod/screenshots/118_reply_prompt_audience_capped_tl0.png new file mode 100644 index 0000000..b832179 Binary files /dev/null and b/discourse-mod/screenshots/118_reply_prompt_audience_capped_tl0.png differ diff --git a/discourse-mod/screenshots/119_reply_prompt_audience_capped_tl2.png b/discourse-mod/screenshots/119_reply_prompt_audience_capped_tl2.png new file mode 100644 index 0000000..faf51ce Binary files /dev/null and b/discourse-mod/screenshots/119_reply_prompt_audience_capped_tl2.png differ diff --git a/discourse-mod/screenshots/11_footer_before_clearing.png b/discourse-mod/screenshots/11_footer_before_clearing.png new file mode 100644 index 0000000..d8090a7 Binary files /dev/null and b/discourse-mod/screenshots/11_footer_before_clearing.png differ diff --git a/discourse-mod/screenshots/120_modal_multiline_reply_prompt.png b/discourse-mod/screenshots/120_modal_multiline_reply_prompt.png new file mode 100644 index 0000000..6ae6b3e Binary files /dev/null and b/discourse-mod/screenshots/120_modal_multiline_reply_prompt.png differ diff --git a/discourse-mod/screenshots/121_modal_reopened_reply_prompt_persisted.png b/discourse-mod/screenshots/121_modal_reopened_reply_prompt_persisted.png new file mode 100644 index 0000000..025e206 Binary files /dev/null and b/discourse-mod/screenshots/121_modal_reopened_reply_prompt_persisted.png differ diff --git a/discourse-mod/screenshots/122_reply_prompt_clickable_link_dialog.png b/discourse-mod/screenshots/122_reply_prompt_clickable_link_dialog.png new file mode 100644 index 0000000..0492bb2 Binary files /dev/null and b/discourse-mod/screenshots/122_reply_prompt_clickable_link_dialog.png differ diff --git a/discourse-mod/screenshots/123_reply_prompt_skipped_above_cap.png b/discourse-mod/screenshots/123_reply_prompt_skipped_above_cap.png new file mode 100644 index 0000000..5b2dbf4 Binary files /dev/null and b/discourse-mod/screenshots/123_reply_prompt_skipped_above_cap.png differ diff --git a/discourse-mod/screenshots/124_category_prompt_preview_bold_link.png b/discourse-mod/screenshots/124_category_prompt_preview_bold_link.png new file mode 100644 index 0000000..1a4bbd8 Binary files /dev/null and b/discourse-mod/screenshots/124_category_prompt_preview_bold_link.png differ diff --git a/discourse-mod/screenshots/125_category_prompt_preview_multiline.png b/discourse-mod/screenshots/125_category_prompt_preview_multiline.png new file mode 100644 index 0000000..8813733 Binary files /dev/null and b/discourse-mod/screenshots/125_category_prompt_preview_multiline.png differ diff --git a/discourse-mod/screenshots/126_category_prompt_audience_tl1.png b/discourse-mod/screenshots/126_category_prompt_audience_tl1.png new file mode 100644 index 0000000..d7fa530 Binary files /dev/null and b/discourse-mod/screenshots/126_category_prompt_audience_tl1.png differ diff --git a/discourse-mod/screenshots/127_category_prompt_audience_tl0.png b/discourse-mod/screenshots/127_category_prompt_audience_tl0.png new file mode 100644 index 0000000..f8174f0 Binary files /dev/null and b/discourse-mod/screenshots/127_category_prompt_audience_tl0.png differ diff --git a/discourse-mod/screenshots/128_category_prompt_persisted_state.png b/discourse-mod/screenshots/128_category_prompt_persisted_state.png new file mode 100644 index 0000000..3c83793 Binary files /dev/null and b/discourse-mod/screenshots/128_category_prompt_persisted_state.png differ diff --git a/discourse-mod/screenshots/129_category_prompt_preview_empty.png b/discourse-mod/screenshots/129_category_prompt_preview_empty.png new file mode 100644 index 0000000..7ef8f56 Binary files /dev/null and b/discourse-mod/screenshots/129_category_prompt_preview_empty.png differ diff --git a/discourse-mod/screenshots/12_mod_messages_modal_cleared.png b/discourse-mod/screenshots/12_mod_messages_modal_cleared.png new file mode 100644 index 0000000..fcbb529 Binary files /dev/null and b/discourse-mod/screenshots/12_mod_messages_modal_cleared.png differ diff --git a/discourse-mod/screenshots/130_pinned_post_regular_user_view.png b/discourse-mod/screenshots/130_pinned_post_regular_user_view.png new file mode 100644 index 0000000..95e7b2b Binary files /dev/null and b/discourse-mod/screenshots/130_pinned_post_regular_user_view.png differ diff --git a/discourse-mod/screenshots/131_pinned_post_in_stream_badge.png b/discourse-mod/screenshots/131_pinned_post_in_stream_badge.png new file mode 100644 index 0000000..c39bb1a Binary files /dev/null and b/discourse-mod/screenshots/131_pinned_post_in_stream_badge.png differ diff --git a/discourse-mod/screenshots/132_pinned_post_jump_to_original.png b/discourse-mod/screenshots/132_pinned_post_jump_to_original.png new file mode 100644 index 0000000..58d73fa Binary files /dev/null and b/discourse-mod/screenshots/132_pinned_post_jump_to_original.png differ diff --git a/discourse-mod/screenshots/133_pinned_post_with_private_note.png b/discourse-mod/screenshots/133_pinned_post_with_private_note.png new file mode 100644 index 0000000..9617611 Binary files /dev/null and b/discourse-mod/screenshots/133_pinned_post_with_private_note.png differ diff --git a/discourse-mod/screenshots/134_post_admin_menu_while_pinned.png b/discourse-mod/screenshots/134_post_admin_menu_while_pinned.png new file mode 100644 index 0000000..6622e2f Binary files /dev/null and b/discourse-mod/screenshots/134_post_admin_menu_while_pinned.png differ diff --git a/discourse-mod/screenshots/135_pinned_plus_footer_user_view.png b/discourse-mod/screenshots/135_pinned_plus_footer_user_view.png new file mode 100644 index 0000000..08cb6a0 Binary files /dev/null and b/discourse-mod/screenshots/135_pinned_plus_footer_user_view.png differ diff --git a/discourse-mod/screenshots/136_approval_checkbox_unchecked.png b/discourse-mod/screenshots/136_approval_checkbox_unchecked.png new file mode 100644 index 0000000..26e8869 Binary files /dev/null and b/discourse-mod/screenshots/136_approval_checkbox_unchecked.png differ diff --git a/discourse-mod/screenshots/137_approval_checkbox_ticked.png b/discourse-mod/screenshots/137_approval_checkbox_ticked.png new file mode 100644 index 0000000..de9776e Binary files /dev/null and b/discourse-mod/screenshots/137_approval_checkbox_ticked.png differ diff --git a/discourse-mod/screenshots/138_approval_checkbox_untoggled.png b/discourse-mod/screenshots/138_approval_checkbox_untoggled.png new file mode 100644 index 0000000..84e911e Binary files /dev/null and b/discourse-mod/screenshots/138_approval_checkbox_untoggled.png differ diff --git a/discourse-mod/screenshots/139_approval_checkbox_persisted.png b/discourse-mod/screenshots/139_approval_checkbox_persisted.png new file mode 100644 index 0000000..0a8c987 Binary files /dev/null and b/discourse-mod/screenshots/139_approval_checkbox_persisted.png differ diff --git a/discourse-mod/screenshots/13_footer_removed_after_clearing.png b/discourse-mod/screenshots/13_footer_removed_after_clearing.png new file mode 100644 index 0000000..eab85f7 Binary files /dev/null and b/discourse-mod/screenshots/13_footer_removed_after_clearing.png differ diff --git a/discourse-mod/screenshots/140_approval_with_messages_filled.png b/discourse-mod/screenshots/140_approval_with_messages_filled.png new file mode 100644 index 0000000..81de199 Binary files /dev/null and b/discourse-mod/screenshots/140_approval_with_messages_filled.png differ diff --git a/discourse-mod/screenshots/141_approval_topic_view_regular_user.png b/discourse-mod/screenshots/141_approval_topic_view_regular_user.png new file mode 100644 index 0000000..c8f3478 Binary files /dev/null and b/discourse-mod/screenshots/141_approval_topic_view_regular_user.png differ diff --git a/discourse-mod/screenshots/142_private_note_position_top.png b/discourse-mod/screenshots/142_private_note_position_top.png new file mode 100644 index 0000000..12629e5 Binary files /dev/null and b/discourse-mod/screenshots/142_private_note_position_top.png differ diff --git a/discourse-mod/screenshots/143_private_note_position_bottom.png b/discourse-mod/screenshots/143_private_note_position_bottom.png new file mode 100644 index 0000000..cd632bb Binary files /dev/null and b/discourse-mod/screenshots/143_private_note_position_bottom.png differ diff --git a/discourse-mod/screenshots/144_private_note_three_replies_thread.png b/discourse-mod/screenshots/144_private_note_three_replies_thread.png new file mode 100644 index 0000000..e89286d Binary files /dev/null and b/discourse-mod/screenshots/144_private_note_three_replies_thread.png differ diff --git a/discourse-mod/screenshots/145_private_note_reply_edit_delete_affordances.png b/discourse-mod/screenshots/145_private_note_reply_edit_delete_affordances.png new file mode 100644 index 0000000..3c27612 Binary files /dev/null and b/discourse-mod/screenshots/145_private_note_reply_edit_delete_affordances.png differ diff --git a/discourse-mod/screenshots/146_private_note_reply_composer_drafting.png b/discourse-mod/screenshots/146_private_note_reply_composer_drafting.png new file mode 100644 index 0000000..7229028 Binary files /dev/null and b/discourse-mod/screenshots/146_private_note_reply_composer_drafting.png differ diff --git a/discourse-mod/screenshots/147_private_note_user_no_node.png b/discourse-mod/screenshots/147_private_note_user_no_node.png new file mode 100644 index 0000000..c8f3478 Binary files /dev/null and b/discourse-mod/screenshots/147_private_note_user_no_node.png differ diff --git a/discourse-mod/screenshots/148_private_note_non_staff_view.png b/discourse-mod/screenshots/148_private_note_non_staff_view.png new file mode 100644 index 0000000..fd440df Binary files /dev/null and b/discourse-mod/screenshots/148_private_note_non_staff_view.png differ diff --git a/discourse-mod/screenshots/149_user_menu_shield_tab_with_unread.png b/discourse-mod/screenshots/149_user_menu_shield_tab_with_unread.png new file mode 100644 index 0000000..163e53f Binary files /dev/null and b/discourse-mod/screenshots/149_user_menu_shield_tab_with_unread.png differ diff --git a/discourse-mod/screenshots/14_user_views_topic.png b/discourse-mod/screenshots/14_user_views_topic.png new file mode 100644 index 0000000..9cc2f62 Binary files /dev/null and b/discourse-mod/screenshots/14_user_views_topic.png differ diff --git a/discourse-mod/screenshots/150_notes_panel_multiple_entries.png b/discourse-mod/screenshots/150_notes_panel_multiple_entries.png new file mode 100644 index 0000000..a7c60be Binary files /dev/null and b/discourse-mod/screenshots/150_notes_panel_multiple_entries.png differ diff --git a/discourse-mod/screenshots/151_notes_panel_single_entry.png b/discourse-mod/screenshots/151_notes_panel_single_entry.png new file mode 100644 index 0000000..cc48a08 Binary files /dev/null and b/discourse-mod/screenshots/151_notes_panel_single_entry.png differ diff --git a/discourse-mod/screenshots/152_notes_panel_after_seen.png b/discourse-mod/screenshots/152_notes_panel_after_seen.png new file mode 100644 index 0000000..f9bf9b0 Binary files /dev/null and b/discourse-mod/screenshots/152_notes_panel_after_seen.png differ diff --git a/discourse-mod/screenshots/153_notes_panel_empty_state.png b/discourse-mod/screenshots/153_notes_panel_empty_state.png new file mode 100644 index 0000000..31e2a44 Binary files /dev/null and b/discourse-mod/screenshots/153_notes_panel_empty_state.png differ diff --git a/discourse-mod/screenshots/154_notes_panel_link_navigated.png b/discourse-mod/screenshots/154_notes_panel_link_navigated.png new file mode 100644 index 0000000..59c8e9d Binary files /dev/null and b/discourse-mod/screenshots/154_notes_panel_link_navigated.png differ diff --git a/discourse-mod/screenshots/155_checklist_editor_inactive_notice.png b/discourse-mod/screenshots/155_checklist_editor_inactive_notice.png new file mode 100644 index 0000000..536a129 Binary files /dev/null and b/discourse-mod/screenshots/155_checklist_editor_inactive_notice.png differ diff --git a/discourse-mod/screenshots/156_checklist_editor_custom_button_label.png b/discourse-mod/screenshots/156_checklist_editor_custom_button_label.png new file mode 100644 index 0000000..6268fa8 Binary files /dev/null and b/discourse-mod/screenshots/156_checklist_editor_custom_button_label.png differ diff --git a/discourse-mod/screenshots/157_checklist_editor_audience_tl1.png b/discourse-mod/screenshots/157_checklist_editor_audience_tl1.png new file mode 100644 index 0000000..61e4eac Binary files /dev/null and b/discourse-mod/screenshots/157_checklist_editor_audience_tl1.png differ diff --git a/discourse-mod/screenshots/158_checklist_user_modal_last_updated.png b/discourse-mod/screenshots/158_checklist_user_modal_last_updated.png new file mode 100644 index 0000000..2c0a8ee Binary files /dev/null and b/discourse-mod/screenshots/158_checklist_user_modal_last_updated.png differ diff --git a/discourse-mod/screenshots/159_checklist_user_modal_custom_button.png b/discourse-mod/screenshots/159_checklist_user_modal_custom_button.png new file mode 100644 index 0000000..c7ea602 Binary files /dev/null and b/discourse-mod/screenshots/159_checklist_user_modal_custom_button.png differ diff --git a/discourse-mod/screenshots/15_user_reply_composer.png b/discourse-mod/screenshots/15_user_reply_composer.png new file mode 100644 index 0000000..ad3cf1e Binary files /dev/null and b/discourse-mod/screenshots/15_user_reply_composer.png differ diff --git a/discourse-mod/screenshots/160_checklist_audit_log_many_entries.png b/discourse-mod/screenshots/160_checklist_audit_log_many_entries.png new file mode 100644 index 0000000..9d38e3b Binary files /dev/null and b/discourse-mod/screenshots/160_checklist_audit_log_many_entries.png differ diff --git a/discourse-mod/screenshots/161_targeted_checklist_listed.png b/discourse-mod/screenshots/161_targeted_checklist_listed.png new file mode 100644 index 0000000..590cf47 Binary files /dev/null and b/discourse-mod/screenshots/161_targeted_checklist_listed.png differ diff --git a/discourse-mod/screenshots/162_checklist_tl2_user_prompted.png b/discourse-mod/screenshots/162_checklist_tl2_user_prompted.png new file mode 100644 index 0000000..2a3cd0d Binary files /dev/null and b/discourse-mod/screenshots/162_checklist_tl2_user_prompted.png differ diff --git a/discourse-mod/screenshots/163_whisper_banner_one_target.png b/discourse-mod/screenshots/163_whisper_banner_one_target.png new file mode 100644 index 0000000..9c75577 Binary files /dev/null and b/discourse-mod/screenshots/163_whisper_banner_one_target.png differ diff --git a/discourse-mod/screenshots/164_whisper_banner_three_targets.png b/discourse-mod/screenshots/164_whisper_banner_three_targets.png new file mode 100644 index 0000000..7eba280 Binary files /dev/null and b/discourse-mod/screenshots/164_whisper_banner_three_targets.png differ diff --git a/discourse-mod/screenshots/165_whisper_banner_staff_only.png b/discourse-mod/screenshots/165_whisper_banner_staff_only.png new file mode 100644 index 0000000..8eabb01 Binary files /dev/null and b/discourse-mod/screenshots/165_whisper_banner_staff_only.png differ diff --git a/discourse-mod/screenshots/166_whisper_banner_group_target.png b/discourse-mod/screenshots/166_whisper_banner_group_target.png new file mode 100644 index 0000000..c236d29 Binary files /dev/null and b/discourse-mod/screenshots/166_whisper_banner_group_target.png differ diff --git a/discourse-mod/screenshots/167_armed_whisper_pill_single_user.png b/discourse-mod/screenshots/167_armed_whisper_pill_single_user.png new file mode 100644 index 0000000..4867c7c Binary files /dev/null and b/discourse-mod/screenshots/167_armed_whisper_pill_single_user.png differ diff --git a/discourse-mod/screenshots/168_whisper_add_participant_modal_user_chosen.png b/discourse-mod/screenshots/168_whisper_add_participant_modal_user_chosen.png new file mode 100644 index 0000000..7aeb5b4 Binary files /dev/null and b/discourse-mod/screenshots/168_whisper_add_participant_modal_user_chosen.png differ diff --git a/discourse-mod/screenshots/169_whisper_non_participant_view.png b/discourse-mod/screenshots/169_whisper_non_participant_view.png new file mode 100644 index 0000000..4981d68 Binary files /dev/null and b/discourse-mod/screenshots/169_whisper_non_participant_view.png differ diff --git a/discourse-mod/screenshots/16_reply_prompt_dialog.png b/discourse-mod/screenshots/16_reply_prompt_dialog.png new file mode 100644 index 0000000..18858ac Binary files /dev/null and b/discourse-mod/screenshots/16_reply_prompt_dialog.png differ diff --git a/discourse-mod/screenshots/170_whisper_recipient_full_view.png b/discourse-mod/screenshots/170_whisper_recipient_full_view.png new file mode 100644 index 0000000..c753f10 Binary files /dev/null and b/discourse-mod/screenshots/170_whisper_recipient_full_view.png differ diff --git a/discourse-mod/screenshots/171_topic_page_moderator.png b/discourse-mod/screenshots/171_topic_page_moderator.png new file mode 100644 index 0000000..ad714d4 Binary files /dev/null and b/discourse-mod/screenshots/171_topic_page_moderator.png differ diff --git a/discourse-mod/screenshots/172_topic_admin_menu_with_prompt_checklist.png b/discourse-mod/screenshots/172_topic_admin_menu_with_prompt_checklist.png new file mode 100644 index 0000000..2f34f5a Binary files /dev/null and b/discourse-mod/screenshots/172_topic_admin_menu_with_prompt_checklist.png differ diff --git a/discourse-mod/screenshots/173_topic_prompt_checklist_modal_empty.png b/discourse-mod/screenshots/173_topic_prompt_checklist_modal_empty.png new file mode 100644 index 0000000..d284ce8 Binary files /dev/null and b/discourse-mod/screenshots/173_topic_prompt_checklist_modal_empty.png differ diff --git a/discourse-mod/screenshots/174_topic_prompt_checklist_modal_filled.png b/discourse-mod/screenshots/174_topic_prompt_checklist_modal_filled.png new file mode 100644 index 0000000..ad8aa40 Binary files /dev/null and b/discourse-mod/screenshots/174_topic_prompt_checklist_modal_filled.png differ diff --git a/discourse-mod/screenshots/175_topic_prompt_checklist_saved.png b/discourse-mod/screenshots/175_topic_prompt_checklist_saved.png new file mode 100644 index 0000000..aea7253 Binary files /dev/null and b/discourse-mod/screenshots/175_topic_prompt_checklist_saved.png differ diff --git a/discourse-mod/screenshots/176_topic_prompt_checklist_user_prompted.png b/discourse-mod/screenshots/176_topic_prompt_checklist_user_prompted.png new file mode 100644 index 0000000..4e60e3b Binary files /dev/null and b/discourse-mod/screenshots/176_topic_prompt_checklist_user_prompted.png differ diff --git a/discourse-mod/screenshots/177_topic_prompt_checklist_user_all_checked.png b/discourse-mod/screenshots/177_topic_prompt_checklist_user_all_checked.png new file mode 100644 index 0000000..892a4ce Binary files /dev/null and b/discourse-mod/screenshots/177_topic_prompt_checklist_user_all_checked.png differ diff --git a/discourse-mod/screenshots/178_topic_prompt_checklist_user_reply_posted.png b/discourse-mod/screenshots/178_topic_prompt_checklist_user_reply_posted.png new file mode 100644 index 0000000..0be4b83 Binary files /dev/null and b/discourse-mod/screenshots/178_topic_prompt_checklist_user_reply_posted.png differ diff --git a/discourse-mod/screenshots/179_topic_prompt_checklist_user_second_reply_no_prompt.png b/discourse-mod/screenshots/179_topic_prompt_checklist_user_second_reply_no_prompt.png new file mode 100644 index 0000000..30bc2fd Binary files /dev/null and b/discourse-mod/screenshots/179_topic_prompt_checklist_user_second_reply_no_prompt.png differ diff --git a/discourse-mod/screenshots/17_reply_prompt_go_back.png b/discourse-mod/screenshots/17_reply_prompt_go_back.png new file mode 100644 index 0000000..8d1c87b Binary files /dev/null and b/discourse-mod/screenshots/17_reply_prompt_go_back.png differ diff --git a/discourse-mod/screenshots/180_topic_prompt_checklist_user_reprompt_after_bump.png b/discourse-mod/screenshots/180_topic_prompt_checklist_user_reprompt_after_bump.png new file mode 100644 index 0000000..3ea33c1 Binary files /dev/null and b/discourse-mod/screenshots/180_topic_prompt_checklist_user_reprompt_after_bump.png differ diff --git a/discourse-mod/screenshots/181_topic_prompt_statement_mode_user_prompted.png b/discourse-mod/screenshots/181_topic_prompt_statement_mode_user_prompted.png new file mode 100644 index 0000000..1f04ab0 Binary files /dev/null and b/discourse-mod/screenshots/181_topic_prompt_statement_mode_user_prompted.png differ diff --git a/discourse-mod/screenshots/182_topic_prompt_statement_mode_posted.png b/discourse-mod/screenshots/182_topic_prompt_statement_mode_posted.png new file mode 100644 index 0000000..0be4b83 Binary files /dev/null and b/discourse-mod/screenshots/182_topic_prompt_statement_mode_posted.png differ diff --git a/discourse-mod/screenshots/183_topic_prompt_every_reply_first_reply.png b/discourse-mod/screenshots/183_topic_prompt_every_reply_first_reply.png new file mode 100644 index 0000000..0be4b83 Binary files /dev/null and b/discourse-mod/screenshots/183_topic_prompt_every_reply_first_reply.png differ diff --git a/discourse-mod/screenshots/184_topic_prompt_every_reply_second_reply_reprompted.png b/discourse-mod/screenshots/184_topic_prompt_every_reply_second_reply_reprompted.png new file mode 100644 index 0000000..36dacc8 Binary files /dev/null and b/discourse-mod/screenshots/184_topic_prompt_every_reply_second_reply_reprompted.png differ diff --git a/discourse-mod/screenshots/185_topic_prompt_max_tl_cap_skipped_for_higher_tl.png b/discourse-mod/screenshots/185_topic_prompt_max_tl_cap_skipped_for_higher_tl.png new file mode 100644 index 0000000..0be4b83 Binary files /dev/null and b/discourse-mod/screenshots/185_topic_prompt_max_tl_cap_skipped_for_higher_tl.png differ diff --git a/discourse-mod/screenshots/186_moderator_actions_modal_no_reply_prompt.png b/discourse-mod/screenshots/186_moderator_actions_modal_no_reply_prompt.png new file mode 100644 index 0000000..e6e85b9 Binary files /dev/null and b/discourse-mod/screenshots/186_moderator_actions_modal_no_reply_prompt.png differ diff --git a/discourse-mod/screenshots/187_topic_prompt_statement_editor_saved.png b/discourse-mod/screenshots/187_topic_prompt_statement_editor_saved.png new file mode 100644 index 0000000..aea7253 Binary files /dev/null and b/discourse-mod/screenshots/187_topic_prompt_statement_editor_saved.png differ diff --git a/discourse-mod/screenshots/188_topic_prompt_statement_editor_then_user_prompted.png b/discourse-mod/screenshots/188_topic_prompt_statement_editor_then_user_prompted.png new file mode 100644 index 0000000..b536182 Binary files /dev/null and b/discourse-mod/screenshots/188_topic_prompt_statement_editor_then_user_prompted.png differ diff --git a/discourse-mod/screenshots/189_topic_prompt_statement_editor_then_user_posted.png b/discourse-mod/screenshots/189_topic_prompt_statement_editor_then_user_posted.png new file mode 100644 index 0000000..0be4b83 Binary files /dev/null and b/discourse-mod/screenshots/189_topic_prompt_statement_editor_then_user_posted.png differ diff --git a/discourse-mod/screenshots/18_reply_prompt_post_anyway.png b/discourse-mod/screenshots/18_reply_prompt_post_anyway.png new file mode 100644 index 0000000..46a99c4 Binary files /dev/null and b/discourse-mod/screenshots/18_reply_prompt_post_anyway.png differ diff --git a/discourse-mod/screenshots/190_mod_note_header_pip_visible.png b/discourse-mod/screenshots/190_mod_note_header_pip_visible.png new file mode 100644 index 0000000..6b9e75d Binary files /dev/null and b/discourse-mod/screenshots/190_mod_note_header_pip_visible.png differ diff --git a/discourse-mod/screenshots/191_mod_note_browser_title_prefix.png b/discourse-mod/screenshots/191_mod_note_browser_title_prefix.png new file mode 100644 index 0000000..df944e8 Binary files /dev/null and b/discourse-mod/screenshots/191_mod_note_browser_title_prefix.png differ diff --git a/discourse-mod/screenshots/192_mod_note_header_indicators_cleared_after_seen.png b/discourse-mod/screenshots/192_mod_note_header_indicators_cleared_after_seen.png new file mode 100644 index 0000000..114b7d2 Binary files /dev/null and b/discourse-mod/screenshots/192_mod_note_header_indicators_cleared_after_seen.png differ diff --git a/discourse-mod/screenshots/193_avatar_badge_no_unread.png b/discourse-mod/screenshots/193_avatar_badge_no_unread.png new file mode 100644 index 0000000..24c79ab Binary files /dev/null and b/discourse-mod/screenshots/193_avatar_badge_no_unread.png differ diff --git a/discourse-mod/screenshots/194_avatar_badge_one_unread.png b/discourse-mod/screenshots/194_avatar_badge_one_unread.png new file mode 100644 index 0000000..906a1d0 Binary files /dev/null and b/discourse-mod/screenshots/194_avatar_badge_one_unread.png differ diff --git a/discourse-mod/screenshots/195_avatar_badge_five_unread.png b/discourse-mod/screenshots/195_avatar_badge_five_unread.png new file mode 100644 index 0000000..5154d85 Binary files /dev/null and b/discourse-mod/screenshots/195_avatar_badge_five_unread.png differ diff --git a/discourse-mod/screenshots/196_avatar_badge_nine_plus_overflow.png b/discourse-mod/screenshots/196_avatar_badge_nine_plus_overflow.png new file mode 100644 index 0000000..81d502c Binary files /dev/null and b/discourse-mod/screenshots/196_avatar_badge_nine_plus_overflow.png differ diff --git a/discourse-mod/screenshots/197_avatar_badge_after_seen.png b/discourse-mod/screenshots/197_avatar_badge_after_seen.png new file mode 100644 index 0000000..b2ae7ff Binary files /dev/null and b/discourse-mod/screenshots/197_avatar_badge_after_seen.png differ diff --git a/discourse-mod/screenshots/19_regular_user_no_mod_button.png b/discourse-mod/screenshots/19_regular_user_no_mod_button.png new file mode 100644 index 0000000..9cc2f62 Binary files /dev/null and b/discourse-mod/screenshots/19_regular_user_no_mod_button.png differ diff --git a/discourse-mod/screenshots/20_footer_visible_to_user.png b/discourse-mod/screenshots/20_footer_visible_to_user.png new file mode 100644 index 0000000..d7281be Binary files /dev/null and b/discourse-mod/screenshots/20_footer_visible_to_user.png differ diff --git a/discourse-mod/screenshots/21_category_settings_prompt_field.png b/discourse-mod/screenshots/21_category_settings_prompt_field.png new file mode 100644 index 0000000..dde887c Binary files /dev/null and b/discourse-mod/screenshots/21_category_settings_prompt_field.png differ diff --git a/discourse-mod/screenshots/22_category_prompt_filled.png b/discourse-mod/screenshots/22_category_prompt_filled.png new file mode 100644 index 0000000..22a593e Binary files /dev/null and b/discourse-mod/screenshots/22_category_prompt_filled.png differ diff --git a/discourse-mod/screenshots/23_category_prompt_saved.png b/discourse-mod/screenshots/23_category_prompt_saved.png new file mode 100644 index 0000000..1adea5e Binary files /dev/null and b/discourse-mod/screenshots/23_category_prompt_saved.png differ diff --git a/discourse-mod/screenshots/25_post_admin_menu_pin_option.png b/discourse-mod/screenshots/25_post_admin_menu_pin_option.png new file mode 100644 index 0000000..a8a6114 Binary files /dev/null and b/discourse-mod/screenshots/25_post_admin_menu_pin_option.png differ diff --git a/discourse-mod/screenshots/26_post_pinned_to_bottom.png b/discourse-mod/screenshots/26_post_pinned_to_bottom.png new file mode 100644 index 0000000..02bf8d5 Binary files /dev/null and b/discourse-mod/screenshots/26_post_pinned_to_bottom.png differ diff --git a/discourse-mod/screenshots/27_footer_message_and_pinned_post_together.png b/discourse-mod/screenshots/27_footer_message_and_pinned_post_together.png new file mode 100644 index 0000000..3a3eeca Binary files /dev/null and b/discourse-mod/screenshots/27_footer_message_and_pinned_post_together.png differ diff --git a/discourse-mod/screenshots/27_new_topic_composer_in_category.png b/discourse-mod/screenshots/27_new_topic_composer_in_category.png new file mode 100644 index 0000000..626bfd7 Binary files /dev/null and b/discourse-mod/screenshots/27_new_topic_composer_in_category.png differ diff --git a/discourse-mod/screenshots/28_new_topic_prompt_dialog.png b/discourse-mod/screenshots/28_new_topic_prompt_dialog.png new file mode 100644 index 0000000..9e39e6a Binary files /dev/null and b/discourse-mod/screenshots/28_new_topic_prompt_dialog.png differ diff --git a/discourse-mod/screenshots/28_pinned_post_before_unpin.png b/discourse-mod/screenshots/28_pinned_post_before_unpin.png new file mode 100644 index 0000000..210e4ab Binary files /dev/null and b/discourse-mod/screenshots/28_pinned_post_before_unpin.png differ diff --git a/discourse-mod/screenshots/29_new_topic_prompt_go_back.png b/discourse-mod/screenshots/29_new_topic_prompt_go_back.png new file mode 100644 index 0000000..71585b8 Binary files /dev/null and b/discourse-mod/screenshots/29_new_topic_prompt_go_back.png differ diff --git a/discourse-mod/screenshots/29_pinned_post_after_unpin.png b/discourse-mod/screenshots/29_pinned_post_after_unpin.png new file mode 100644 index 0000000..5c21ed3 Binary files /dev/null and b/discourse-mod/screenshots/29_pinned_post_after_unpin.png differ diff --git a/discourse-mod/screenshots/30_new_topic_posted_after_confirm.png b/discourse-mod/screenshots/30_new_topic_posted_after_confirm.png new file mode 100644 index 0000000..225cd3b Binary files /dev/null and b/discourse-mod/screenshots/30_new_topic_posted_after_confirm.png differ diff --git a/discourse-mod/screenshots/31_no_prompt_plain_category.png b/discourse-mod/screenshots/31_no_prompt_plain_category.png new file mode 100644 index 0000000..7ee6a22 Binary files /dev/null and b/discourse-mod/screenshots/31_no_prompt_plain_category.png differ diff --git a/discourse-mod/screenshots/33_reply_no_prompt.png b/discourse-mod/screenshots/33_reply_no_prompt.png new file mode 100644 index 0000000..be45029 Binary files /dev/null and b/discourse-mod/screenshots/33_reply_no_prompt.png differ diff --git a/discourse-mod/screenshots/34_reply_prompt_dialog.png b/discourse-mod/screenshots/34_reply_prompt_dialog.png new file mode 100644 index 0000000..b0b93fc Binary files /dev/null and b/discourse-mod/screenshots/34_reply_prompt_dialog.png differ diff --git a/discourse-mod/screenshots/35_reply_posted_after_confirm.png b/discourse-mod/screenshots/35_reply_posted_after_confirm.png new file mode 100644 index 0000000..692783f Binary files /dev/null and b/discourse-mod/screenshots/35_reply_posted_after_confirm.png differ diff --git a/discourse-mod/screenshots/36_footer_html_rendered.png b/discourse-mod/screenshots/36_footer_html_rendered.png new file mode 100644 index 0000000..fd961ff Binary files /dev/null and b/discourse-mod/screenshots/36_footer_html_rendered.png differ diff --git a/discourse-mod/screenshots/37_footer_on_closed_topic.png b/discourse-mod/screenshots/37_footer_on_closed_topic.png new file mode 100644 index 0000000..b15d749 Binary files /dev/null and b/discourse-mod/screenshots/37_footer_on_closed_topic.png differ diff --git a/discourse-mod/screenshots/38_mod_messages_require_approval_checked.png b/discourse-mod/screenshots/38_mod_messages_require_approval_checked.png new file mode 100644 index 0000000..3f384dd Binary files /dev/null and b/discourse-mod/screenshots/38_mod_messages_require_approval_checked.png differ diff --git a/discourse-mod/screenshots/39_pinned_post_as_bottom_post.png b/discourse-mod/screenshots/39_pinned_post_as_bottom_post.png new file mode 100644 index 0000000..6fa9999 Binary files /dev/null and b/discourse-mod/screenshots/39_pinned_post_as_bottom_post.png differ diff --git a/discourse-mod/screenshots/40_private_note_staff_view.png b/discourse-mod/screenshots/40_private_note_staff_view.png new file mode 100644 index 0000000..5b468d2 Binary files /dev/null and b/discourse-mod/screenshots/40_private_note_staff_view.png differ diff --git a/discourse-mod/screenshots/41_private_note_hidden_from_user.png b/discourse-mod/screenshots/41_private_note_hidden_from_user.png new file mode 100644 index 0000000..d7281be Binary files /dev/null and b/discourse-mod/screenshots/41_private_note_hidden_from_user.png differ diff --git a/discourse-mod/screenshots/42_pinned_last_post.png b/discourse-mod/screenshots/42_pinned_last_post.png new file mode 100644 index 0000000..8bf4eb9 Binary files /dev/null and b/discourse-mod/screenshots/42_pinned_last_post.png differ diff --git a/discourse-mod/screenshots/43_pinned_second_to_last.png b/discourse-mod/screenshots/43_pinned_second_to_last.png new file mode 100644 index 0000000..583cee6 Binary files /dev/null and b/discourse-mod/screenshots/43_pinned_second_to_last.png differ diff --git a/discourse-mod/screenshots/44_pinned_third_to_last.png b/discourse-mod/screenshots/44_pinned_third_to_last.png new file mode 100644 index 0000000..e79f799 Binary files /dev/null and b/discourse-mod/screenshots/44_pinned_third_to_last.png differ diff --git a/discourse-mod/screenshots/45_pinned_tenth_to_last.png b/discourse-mod/screenshots/45_pinned_tenth_to_last.png new file mode 100644 index 0000000..6fa9999 Binary files /dev/null and b/discourse-mod/screenshots/45_pinned_tenth_to_last.png differ diff --git a/discourse-mod/screenshots/46_private_note_with_timestamp_and_reply_button.png b/discourse-mod/screenshots/46_private_note_with_timestamp_and_reply_button.png new file mode 100644 index 0000000..5a3f86c Binary files /dev/null and b/discourse-mod/screenshots/46_private_note_with_timestamp_and_reply_button.png differ diff --git a/discourse-mod/screenshots/47_private_note_reply_box.png b/discourse-mod/screenshots/47_private_note_reply_box.png new file mode 100644 index 0000000..f3cc6fe Binary files /dev/null and b/discourse-mod/screenshots/47_private_note_reply_box.png differ diff --git a/discourse-mod/screenshots/48_private_note_reply_added.png b/discourse-mod/screenshots/48_private_note_reply_added.png new file mode 100644 index 0000000..a1c0e26 Binary files /dev/null and b/discourse-mod/screenshots/48_private_note_reply_added.png differ diff --git a/discourse-mod/screenshots/49_user_menu_shield_tab.png b/discourse-mod/screenshots/49_user_menu_shield_tab.png new file mode 100644 index 0000000..1727b9f Binary files /dev/null and b/discourse-mod/screenshots/49_user_menu_shield_tab.png differ diff --git a/discourse-mod/screenshots/50_moderator_notes_tab_panel.png b/discourse-mod/screenshots/50_moderator_notes_tab_panel.png new file mode 100644 index 0000000..8655d0a Binary files /dev/null and b/discourse-mod/screenshots/50_moderator_notes_tab_panel.png differ diff --git a/discourse-mod/screenshots/51_checklist_editor_empty.png b/discourse-mod/screenshots/51_checklist_editor_empty.png new file mode 100644 index 0000000..82c0916 Binary files /dev/null and b/discourse-mod/screenshots/51_checklist_editor_empty.png differ diff --git a/discourse-mod/screenshots/52_checklist_editor_filled.png b/discourse-mod/screenshots/52_checklist_editor_filled.png new file mode 100644 index 0000000..977dd1a Binary files /dev/null and b/discourse-mod/screenshots/52_checklist_editor_filled.png differ diff --git a/discourse-mod/screenshots/53_checklist_editor_saved.png b/discourse-mod/screenshots/53_checklist_editor_saved.png new file mode 100644 index 0000000..cadc697 Binary files /dev/null and b/discourse-mod/screenshots/53_checklist_editor_saved.png differ diff --git a/discourse-mod/screenshots/54_tl0_checklist_modal.png b/discourse-mod/screenshots/54_tl0_checklist_modal.png new file mode 100644 index 0000000..5577147 Binary files /dev/null and b/discourse-mod/screenshots/54_tl0_checklist_modal.png differ diff --git a/discourse-mod/screenshots/55_tl0_modal_all_checked.png b/discourse-mod/screenshots/55_tl0_modal_all_checked.png new file mode 100644 index 0000000..2c26f1b Binary files /dev/null and b/discourse-mod/screenshots/55_tl0_modal_all_checked.png differ diff --git a/discourse-mod/screenshots/56_tl0_reply_posted_after_accept.png b/discourse-mod/screenshots/56_tl0_reply_posted_after_accept.png new file mode 100644 index 0000000..9f71618 Binary files /dev/null and b/discourse-mod/screenshots/56_tl0_reply_posted_after_accept.png differ diff --git a/discourse-mod/screenshots/57_tl0_second_post_no_prompt.png b/discourse-mod/screenshots/57_tl0_second_post_no_prompt.png new file mode 100644 index 0000000..da47576 Binary files /dev/null and b/discourse-mod/screenshots/57_tl0_second_post_no_prompt.png differ diff --git a/discourse-mod/screenshots/58_tl1_checklist_modal.png b/discourse-mod/screenshots/58_tl1_checklist_modal.png new file mode 100644 index 0000000..22a0238 Binary files /dev/null and b/discourse-mod/screenshots/58_tl1_checklist_modal.png differ diff --git a/discourse-mod/screenshots/59_checklist_version_bumped.png b/discourse-mod/screenshots/59_checklist_version_bumped.png new file mode 100644 index 0000000..b067116 Binary files /dev/null and b/discourse-mod/screenshots/59_checklist_version_bumped.png differ diff --git a/discourse-mod/screenshots/60_reprompt_after_version_bump.png b/discourse-mod/screenshots/60_reprompt_after_version_bump.png new file mode 100644 index 0000000..22a0238 Binary files /dev/null and b/discourse-mod/screenshots/60_reprompt_after_version_bump.png differ diff --git a/discourse-mod/screenshots/61_checklist_acceptance_log.png b/discourse-mod/screenshots/61_checklist_acceptance_log.png new file mode 100644 index 0000000..6586923 Binary files /dev/null and b/discourse-mod/screenshots/61_checklist_acceptance_log.png differ diff --git a/discourse-mod/screenshots/62_composer_whisper_button.png b/discourse-mod/screenshots/62_composer_whisper_button.png new file mode 100644 index 0000000..dee2b16 Binary files /dev/null and b/discourse-mod/screenshots/62_composer_whisper_button.png differ diff --git a/discourse-mod/screenshots/63_whisper_target_modal_empty.png b/discourse-mod/screenshots/63_whisper_target_modal_empty.png new file mode 100644 index 0000000..e480dc9 Binary files /dev/null and b/discourse-mod/screenshots/63_whisper_target_modal_empty.png differ diff --git a/discourse-mod/screenshots/64_whisper_target_modal_users_selected.png b/discourse-mod/screenshots/64_whisper_target_modal_users_selected.png new file mode 100644 index 0000000..2679724 Binary files /dev/null and b/discourse-mod/screenshots/64_whisper_target_modal_users_selected.png differ diff --git a/discourse-mod/screenshots/65_whisper_armed_pill.png b/discourse-mod/screenshots/65_whisper_armed_pill.png new file mode 100644 index 0000000..5b7c342 Binary files /dev/null and b/discourse-mod/screenshots/65_whisper_armed_pill.png differ diff --git a/discourse-mod/screenshots/65b_staff_only_whisper_armed.png b/discourse-mod/screenshots/65b_staff_only_whisper_armed.png new file mode 100644 index 0000000..961d152 Binary files /dev/null and b/discourse-mod/screenshots/65b_staff_only_whisper_armed.png differ diff --git a/discourse-mod/screenshots/65c_staff_only_whisper_posted_banner.png b/discourse-mod/screenshots/65c_staff_only_whisper_posted_banner.png new file mode 100644 index 0000000..3aee28d Binary files /dev/null and b/discourse-mod/screenshots/65c_staff_only_whisper_posted_banner.png differ diff --git a/discourse-mod/screenshots/66_staff_whisper_posted_banner.png b/discourse-mod/screenshots/66_staff_whisper_posted_banner.png new file mode 100644 index 0000000..3c83182 Binary files /dev/null and b/discourse-mod/screenshots/66_staff_whisper_posted_banner.png differ diff --git a/discourse-mod/screenshots/67_recipient_sees_whisper.png b/discourse-mod/screenshots/67_recipient_sees_whisper.png new file mode 100644 index 0000000..e702226 Binary files /dev/null and b/discourse-mod/screenshots/67_recipient_sees_whisper.png differ diff --git a/discourse-mod/screenshots/68_stranger_does_not_see_whisper.png b/discourse-mod/screenshots/68_stranger_does_not_see_whisper.png new file mode 100644 index 0000000..315f44a Binary files /dev/null and b/discourse-mod/screenshots/68_stranger_does_not_see_whisper.png differ diff --git a/discourse-mod/screenshots/69_staff_oversight.png b/discourse-mod/screenshots/69_staff_oversight.png new file mode 100644 index 0000000..6f94ed3 Binary files /dev/null and b/discourse-mod/screenshots/69_staff_oversight.png differ diff --git a/discourse-mod/screenshots/70_participant_whisper_back_armed.png b/discourse-mod/screenshots/70_participant_whisper_back_armed.png new file mode 100644 index 0000000..109eb4f Binary files /dev/null and b/discourse-mod/screenshots/70_participant_whisper_back_armed.png differ diff --git a/discourse-mod/screenshots/71_whisper_back_banner.png b/discourse-mod/screenshots/71_whisper_back_banner.png new file mode 100644 index 0000000..fcc13d1 Binary files /dev/null and b/discourse-mod/screenshots/71_whisper_back_banner.png differ diff --git a/discourse-mod/screenshots/72_non_participant_no_op.png b/discourse-mod/screenshots/72_non_participant_no_op.png new file mode 100644 index 0000000..742518f Binary files /dev/null and b/discourse-mod/screenshots/72_non_participant_no_op.png differ diff --git a/discourse-mod/screenshots/73_site_setting_page.png b/discourse-mod/screenshots/73_site_setting_page.png new file mode 100644 index 0000000..adcbc99 Binary files /dev/null and b/discourse-mod/screenshots/73_site_setting_page.png differ diff --git a/discourse-mod/screenshots/74_plugin_disabled_visible_to_all.png b/discourse-mod/screenshots/74_plugin_disabled_visible_to_all.png new file mode 100644 index 0000000..3b79aae Binary files /dev/null and b/discourse-mod/screenshots/74_plugin_disabled_visible_to_all.png differ diff --git a/discourse-mod/screenshots/75_checklist_rows_reordered.png b/discourse-mod/screenshots/75_checklist_rows_reordered.png new file mode 100644 index 0000000..16b3e27 Binary files /dev/null and b/discourse-mod/screenshots/75_checklist_rows_reordered.png differ diff --git a/discourse-mod/screenshots/75_reply_to_whisper_auto_armed.png b/discourse-mod/screenshots/75_reply_to_whisper_auto_armed.png new file mode 100644 index 0000000..0dbf121 Binary files /dev/null and b/discourse-mod/screenshots/75_reply_to_whisper_auto_armed.png differ diff --git a/discourse-mod/screenshots/76_group_whisper_banner.png b/discourse-mod/screenshots/76_group_whisper_banner.png new file mode 100644 index 0000000..c39cf4b Binary files /dev/null and b/discourse-mod/screenshots/76_group_whisper_banner.png differ diff --git a/discourse-mod/screenshots/77_group_member_sees_whisper.png b/discourse-mod/screenshots/77_group_member_sees_whisper.png new file mode 100644 index 0000000..97acdec Binary files /dev/null and b/discourse-mod/screenshots/77_group_member_sees_whisper.png differ diff --git a/discourse-mod/screenshots/78_non_member_does_not_see_group_whisper.png b/discourse-mod/screenshots/78_non_member_does_not_see_group_whisper.png new file mode 100644 index 0000000..315f44a Binary files /dev/null and b/discourse-mod/screenshots/78_non_member_does_not_see_group_whisper.png differ diff --git a/discourse-mod/screenshots/79_whisper_post_admin_menu.png b/discourse-mod/screenshots/79_whisper_post_admin_menu.png new file mode 100644 index 0000000..f87a3ac Binary files /dev/null and b/discourse-mod/screenshots/79_whisper_post_admin_menu.png differ diff --git a/discourse-mod/screenshots/80_mod_messages_modal_sections.png b/discourse-mod/screenshots/80_mod_messages_modal_sections.png new file mode 100644 index 0000000..33e5d7b Binary files /dev/null and b/discourse-mod/screenshots/80_mod_messages_modal_sections.png differ diff --git a/discourse-mod/screenshots/80_whisper_add_participant_modal.png b/discourse-mod/screenshots/80_whisper_add_participant_modal.png new file mode 100644 index 0000000..5454aab Binary files /dev/null and b/discourse-mod/screenshots/80_whisper_add_participant_modal.png differ diff --git a/discourse-mod/screenshots/81_whisper_participant_added.png b/discourse-mod/screenshots/81_whisper_participant_added.png new file mode 100644 index 0000000..3a714c3 Binary files /dev/null and b/discourse-mod/screenshots/81_whisper_participant_added.png differ diff --git a/discourse-mod/screenshots/82_mod_messages_save_toast.png b/discourse-mod/screenshots/82_mod_messages_save_toast.png new file mode 100644 index 0000000..eab85f7 Binary files /dev/null and b/discourse-mod/screenshots/82_mod_messages_save_toast.png differ diff --git a/discourse-mod/screenshots/83_category_prompt_live_preview.png b/discourse-mod/screenshots/83_category_prompt_live_preview.png new file mode 100644 index 0000000..c673c24 Binary files /dev/null and b/discourse-mod/screenshots/83_category_prompt_live_preview.png differ diff --git a/discourse-mod/screenshots/84_category_prompt_audience_dropdown.png b/discourse-mod/screenshots/84_category_prompt_audience_dropdown.png new file mode 100644 index 0000000..0796ae2 Binary files /dev/null and b/discourse-mod/screenshots/84_category_prompt_audience_dropdown.png differ diff --git a/discourse-mod/screenshots/88_precheck_dialog_clickable_link.png b/discourse-mod/screenshots/88_precheck_dialog_clickable_link.png new file mode 100644 index 0000000..92baa70 Binary files /dev/null and b/discourse-mod/screenshots/88_precheck_dialog_clickable_link.png differ diff --git a/discourse-mod/screenshots/89_messages_on_first_topic.png b/discourse-mod/screenshots/89_messages_on_first_topic.png new file mode 100644 index 0000000..0d94b22 Binary files /dev/null and b/discourse-mod/screenshots/89_messages_on_first_topic.png differ diff --git a/discourse-mod/screenshots/90_no_stale_messages_on_second_topic.png b/discourse-mod/screenshots/90_no_stale_messages_on_second_topic.png new file mode 100644 index 0000000..a5414ce Binary files /dev/null and b/discourse-mod/screenshots/90_no_stale_messages_on_second_topic.png differ diff --git a/discourse-mod/screenshots/91_messages_restored_on_first_topic.png b/discourse-mod/screenshots/91_messages_restored_on_first_topic.png new file mode 100644 index 0000000..72a1d08 Binary files /dev/null and b/discourse-mod/screenshots/91_messages_restored_on_first_topic.png differ diff --git a/discourse-mod/screenshots/92_private_note_reply_editing (1).png b/discourse-mod/screenshots/92_private_note_reply_editing (1).png new file mode 100644 index 0000000..84085ee Binary files /dev/null and b/discourse-mod/screenshots/92_private_note_reply_editing (1).png differ diff --git a/discourse-mod/screenshots/92_private_note_reply_editing.png b/discourse-mod/screenshots/92_private_note_reply_editing.png new file mode 100644 index 0000000..84085ee Binary files /dev/null and b/discourse-mod/screenshots/92_private_note_reply_editing.png differ diff --git a/discourse-mod/screenshots/93_private_note_reply_edited.png b/discourse-mod/screenshots/93_private_note_reply_edited.png new file mode 100644 index 0000000..3b2f942 Binary files /dev/null and b/discourse-mod/screenshots/93_private_note_reply_edited.png differ diff --git a/discourse-mod/screenshots/94_private_note_reply_deleted.png b/discourse-mod/screenshots/94_private_note_reply_deleted.png new file mode 100644 index 0000000..5a3f86c Binary files /dev/null and b/discourse-mod/screenshots/94_private_note_reply_deleted.png differ diff --git a/discourse-mod/screenshots/95_mod_note_notification_in_user_menu.png b/discourse-mod/screenshots/95_mod_note_notification_in_user_menu.png new file mode 100644 index 0000000..bde302e Binary files /dev/null and b/discourse-mod/screenshots/95_mod_note_notification_in_user_menu.png differ diff --git a/discourse-mod/screenshots/96_mod_note_notification_opened.png b/discourse-mod/screenshots/96_mod_note_notification_opened.png new file mode 100644 index 0000000..c216ff4 Binary files /dev/null and b/discourse-mod/screenshots/96_mod_note_notification_opened.png differ diff --git a/discourse-mod/screenshots/97_checklist_log_before_reaccept (1).png b/discourse-mod/screenshots/97_checklist_log_before_reaccept (1).png new file mode 100644 index 0000000..afb80be Binary files /dev/null and b/discourse-mod/screenshots/97_checklist_log_before_reaccept (1).png differ diff --git a/discourse-mod/screenshots/97_checklist_log_before_reaccept.png b/discourse-mod/screenshots/97_checklist_log_before_reaccept.png new file mode 100644 index 0000000..afb80be Binary files /dev/null and b/discourse-mod/screenshots/97_checklist_log_before_reaccept.png differ diff --git a/discourse-mod/screenshots/98_checklist_require_reaccept.png b/discourse-mod/screenshots/98_checklist_require_reaccept.png new file mode 100644 index 0000000..bc37489 Binary files /dev/null and b/discourse-mod/screenshots/98_checklist_require_reaccept.png differ diff --git a/discourse-mod/screenshots/99_targeted_checklist_filled.png b/discourse-mod/screenshots/99_targeted_checklist_filled.png new file mode 100644 index 0000000..45ee0b4 Binary files /dev/null and b/discourse-mod/screenshots/99_targeted_checklist_filled.png differ diff --git a/discourse-mod/spec/lib/guardian_extensions_spec.rb b/discourse-mod/spec/lib/guardian_extensions_spec.rb new file mode 100644 index 0000000..4d064dc --- /dev/null +++ b/discourse-mod/spec/lib/guardian_extensions_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DiscourseModCategories::GuardianExtensions do + fab!(:moderator) + fab!(:user) + fab!(:admin) + fab!(:category) + + before { SiteSetting.mod_categories_enabled = true } + + describe "#can_create_category?" do + it "allows moderators to create categories" do + expect(Guardian.new(moderator).can_create_category?).to eq(true) + end + + it "allows moderators to create subcategories under any category" do + expect(Guardian.new(moderator).can_create_category?(category)).to eq(true) + end + + it "still allows admins" do + expect(Guardian.new(admin).can_create_category?).to eq(true) + end + + it "does not allow regular users" do + expect(Guardian.new(user).can_create_category?).to eq(false) + end + + it "does not allow anonymous users" do + expect(Guardian.new.can_create_category?).to eq(false) + end + + it "does not allow when plugin is disabled" do + SiteSetting.mod_categories_enabled = false + expect(Guardian.new(moderator).can_create_category?).to eq(false) + end + end + + describe "#can_edit_category?" do + it "allows moderators to edit categories" do + expect(Guardian.new(moderator).can_edit_category?(category)).to eq(true) + end + + it "does not allow regular users" do + expect(Guardian.new(user).can_edit_category?(category)).to eq(false) + end + + it "does not allow when plugin is disabled" do + SiteSetting.mod_categories_enabled = false + expect(Guardian.new(moderator).can_edit_category?(category)).to eq(false) + end + end + + describe "#can_edit_serialized_category?" do + it "allows moderators" do + expect( + Guardian.new(moderator).can_edit_serialized_category?( + category_id: category.id, + read_restricted: false, + ), + ).to eq(true) + end + + it "does not allow regular users" do + expect( + Guardian.new(user).can_edit_serialized_category?( + category_id: category.id, + read_restricted: false, + ), + ).to eq(false) + end + + it "does not allow when plugin is disabled" do + SiteSetting.mod_categories_enabled = false + expect( + Guardian.new(moderator).can_edit_serialized_category?( + category_id: category.id, + read_restricted: false, + ), + ).to eq(false) + end + end + + describe "#can_delete_category?" do + fab!(:empty_category, :category) + + it "allows moderators to delete an empty category" do + expect(Guardian.new(moderator).can_delete_category?(empty_category)).to eq(true) + end + + it "does not allow deleting categories with topics" do + Fabricate(:topic, category: empty_category) + empty_category.reload + expect(Guardian.new(moderator).can_delete_category?(empty_category)).to eq(false) + end + + it "does not allow deleting categories with subcategories" do + Fabricate(:category, parent_category: empty_category) + empty_category.reload + expect(Guardian.new(moderator).can_delete_category?(empty_category)).to eq(false) + end + + it "does not allow deleting the uncategorized category" do + uncategorized = Category.find(SiteSetting.uncategorized_category_id) + expect(Guardian.new(moderator).can_delete_category?(uncategorized)).to eq(false) + end + + it "does not allow regular users" do + expect(Guardian.new(user).can_delete_category?(empty_category)).to eq(false) + end + + it "does not allow when plugin is disabled" do + SiteSetting.mod_categories_enabled = false + expect(Guardian.new(moderator).can_delete_category?(empty_category)).to eq(false) + end + end +end diff --git a/discourse-mod/spec/lib/reply_approval_spec.rb b/discourse-mod/spec/lib/reply_approval_spec.rb new file mode 100644 index 0000000..6cb1dfe --- /dev/null +++ b/discourse-mod/spec/lib/reply_approval_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Verifies the per-topic "require approval for replies" feature: when a +# moderator flags a topic, replies route to the review queue via the +# NewPostManager handler; staff still post directly, and new topics are +# unaffected. +RSpec.describe "Per-topic reply approval" do + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category) } + fab!(:op) { Fabricate(:post, topic: topic) } + fab!(:replier) { Fabricate(:user, trust_level: TrustLevel[2]) } + fab!(:moderator) + + before { SiteSetting.mod_categories_enabled = true } + + def reply_as(user) + NewPostManager.new( + user, + raw: "This is a reply that is comfortably long enough to validate.", + topic_id: topic.id, + ).perform + end + + def require_approval! + topic.custom_fields["mod_topic_require_reply_approval"] = true + topic.save_custom_fields(true) + end + + it "enqueues a reply for review when the topic requires approval" do + require_approval! + + result = reply_as(replier) + + expect(result.action).to eq(:enqueued) + expect(ReviewableQueuedPost.where(topic_id: topic.id).count).to eq(1) + end + + it "does not enqueue replies when the topic does not require approval" do + result = reply_as(replier) + + expect(result.action).not_to eq(:enqueued) + expect(ReviewableQueuedPost.where(topic_id: topic.id)).to be_empty + end + + it "lets staff reply directly even when approval is required" do + require_approval! + + result = reply_as(moderator) + + expect(result.action).not_to eq(:enqueued) + end + + it "does not affect new topics" do + require_approval! + + result = + NewPostManager.new( + replier, + raw: "A brand new topic body that is long enough to validate.", + title: "A brand new topic title goes here", + category: category.id, + ).perform + + expect(result.action).not_to eq(:enqueued) + end +end diff --git a/discourse-mod/spec/lib/whisper_guardian_spec.rb b/discourse-mod/spec/lib/whisper_guardian_spec.rb new file mode 100644 index 0000000..89b8e24 --- /dev/null +++ b/discourse-mod/spec/lib/whisper_guardian_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Guardian coverage for the moderator whisper feature: who can see a whisper +# post, and who may whisper in a topic. +RSpec.describe "Whisper Guardian" do + fab!(:admin) + fab!(:moderator) + fab!(:author, :user) + fab!(:target, :user) + fab!(:participant, :user) + fab!(:stranger, :user) + fab!(:group_member, :user) + fab!(:topic) + fab!(:post) { Fabricate(:post, topic: topic, user: author) } + fab!(:whisper_group) { Fabricate(:group, name: "whisper_squad") } + + let(:targets_field) { DiscourseModCategories::POST_WHISPER_TARGETS_FIELD } + let(:groups_field) do + DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD + end + let(:participants_field) do + DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD + end + + before do + SiteSetting.mod_categories_enabled = true + SiteSetting.mod_whisper_enabled = true + end + + def make_whisper(target_ids, group_ids = []) + post.custom_fields[targets_field] = target_ids + post.custom_fields[groups_field] = group_ids + post.save_custom_fields(true) + post.reload + end + + def add_participants(ids) + topic.custom_fields[participants_field] = ids + topic.save_custom_fields(true) + topic.reload + end + + describe "#can_see_post?" do + it "is unaffected when the post is not a whisper" do + expect(Guardian.new(stranger).can_see_post?(post)).to eq(true) + end + + context "with a targeted whisper" do + before do + make_whisper([target.id]) + add_participants([target.id]) + end + + it "lets the author see it" do + expect(Guardian.new(author).can_see_post?(post)).to eq(true) + end + + it "lets a target see it" do + expect(Guardian.new(target).can_see_post?(post)).to eq(true) + end + + it "lets staff see it for oversight" do + expect(Guardian.new(admin).can_see_post?(post)).to eq(true) + expect(Guardian.new(moderator).can_see_post?(post)).to eq(true) + end + + it "hides it from a stranger" do + expect(Guardian.new(stranger).can_see_post?(post)).to eq(false) + end + + it "hides it from an anonymous viewer without raising" do + expect { Guardian.new.can_see_post?(post) }.not_to raise_error + expect(Guardian.new.can_see_post?(post)).to eq(false) + expect(Guardian.new(nil).can_see_post?(post)).to eq(false) + end + end + + context "with a group-targeted whisper" do + before do + whisper_group.add(group_member) + make_whisper([], [whisper_group.id]) + end + + it "lets a member of the target group see it" do + expect(Guardian.new(group_member).can_see_post?(post)).to eq(true) + end + + it "hides it from a non-member non-staff user" do + expect(Guardian.new(stranger).can_see_post?(post)).to eq(false) + end + + it "still lets staff see it for oversight" do + expect(Guardian.new(admin).can_see_post?(post)).to eq(true) + end + + it "lets the author see it" do + expect(Guardian.new(author).can_see_post?(post)).to eq(true) + end + end + + context "with a topic whisper participant" do + before do + make_whisper([target.id]) + add_participants([target.id, participant.id]) + end + + it "lets a cumulative topic participant see the whisper" do + expect(Guardian.new(participant).can_see_post?(post)).to eq(true) + end + end + + context "with a staff-only whisper-back (empty targets)" do + before { make_whisper([]) } + + it "is still treated as a whisper (key presence)" do + expect(Guardian.new(stranger).can_see_post?(post)).to eq(false) + end + + it "lets staff see it" do + expect(Guardian.new(admin).can_see_post?(post)).to eq(true) + end + + it "lets the author see it" do + expect(Guardian.new(author).can_see_post?(post)).to eq(true) + end + end + + context "when the feature is disabled" do + before do + SiteSetting.mod_whisper_enabled = false + make_whisper([target.id]) + end + + it "falls back to core visibility" do + expect(Guardian.new(stranger).can_see_post?(post)).to eq(true) + end + end + end + + describe "#can_whisper_in_topic?" do + it "lets staff whisper in any topic" do + expect(Guardian.new(admin).can_whisper_in_topic?(topic)).to eq(true) + expect(Guardian.new(moderator).can_whisper_in_topic?(topic)).to eq(true) + end + + it "denies a non-participant non-staff user" do + expect(Guardian.new(stranger).can_whisper_in_topic?(topic)).to eq(false) + end + + it "lets a recorded topic whisper participant whisper back" do + add_participants([participant.id]) + expect(Guardian.new(participant).can_whisper_in_topic?(topic)).to eq(true) + end + + it "denies anonymous viewers without raising" do + expect { Guardian.new.can_whisper_in_topic?(topic) }.not_to raise_error + expect(Guardian.new.can_whisper_in_topic?(topic)).to eq(false) + end + + it "denies everyone when the feature is disabled" do + SiteSetting.mod_whisper_enabled = false + add_participants([participant.id]) + expect(Guardian.new(admin).can_whisper_in_topic?(topic)).to eq(false) + expect(Guardian.new(participant).can_whisper_in_topic?(topic)).to eq( + false, + ) + end + end +end diff --git a/discourse-mod/spec/plugin_spec.rb b/discourse-mod/spec/plugin_spec.rb new file mode 100644 index 0000000..c9d2ced --- /dev/null +++ b/discourse-mod/spec/plugin_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Exercises the plugin's wiring in plugin.rb: the Guardian prepend is in place, +# the master switch flips correctly, and core privileges (admin) are unaffected +# regardless of plugin state. +RSpec.describe "DiscourseModCategories plugin.rb" do + fab!(:moderator) + fab!(:admin) + fab!(:user) + fab!(:category) + + describe "Guardian prepend" do + it "wires the GuardianExtensions module into Guardian" do + expect(Guardian.ancestors).to include(DiscourseModCategories::GuardianExtensions) + end + end + + describe "master switch (mod_categories_enabled)" do + context "when disabled (default)" do + before { SiteSetting.mod_categories_enabled = false } + + it "denies moderators" do + guardian = Guardian.new(moderator) + expect(guardian.can_create_category?).to eq(false) + expect(guardian.can_edit_category?(category)).to eq(false) + expect(guardian.can_delete_category?(category)).to eq(false) + end + + it "still allows admins" do + guardian = Guardian.new(admin) + expect(guardian.can_create_category?).to eq(true) + expect(guardian.can_edit_category?(category)).to eq(true) + end + + it "still denies regular users" do + guardian = Guardian.new(user) + expect(guardian.can_create_category?).to eq(false) + expect(guardian.can_edit_category?(category)).to eq(false) + expect(guardian.can_delete_category?(category)).to eq(false) + end + end + + context "when enabled" do + before { SiteSetting.mod_categories_enabled = true } + + it "grants moderators category create/edit/delete" do + empty_category = Fabricate(:category) + guardian = Guardian.new(moderator) + expect(guardian.can_create_category?).to eq(true) + expect(guardian.can_edit_category?(empty_category)).to eq(true) + expect(guardian.can_delete_category?(empty_category)).to eq(true) + end + + it "still denies regular users" do + guardian = Guardian.new(user) + expect(guardian.can_create_category?).to eq(false) + expect(guardian.can_edit_category?(category)).to eq(false) + expect(guardian.can_delete_category?(category)).to eq(false) + end + + it "does not change admin privileges" do + guardian = Guardian.new(admin) + expect(guardian.can_create_category?).to eq(true) + expect(guardian.can_edit_category?(category)).to eq(true) + end + end + end + + describe "settings registration" do + it "registers mod_categories_enabled with the correct default" do + expect(SiteSetting.defaults[:mod_categories_enabled]).to eq(false) + end + + it "registers precheck_new_topic_enabled defaulting to true" do + expect(SiteSetting.defaults[:precheck_new_topic_enabled]).to eq(true) + end + + it "registers topic_footer_message_enabled defaulting to true" do + expect(SiteSetting.defaults[:topic_footer_message_enabled]).to eq(true) + end + + it "registers topic_reply_prompt_enabled defaulting to true" do + expect(SiteSetting.defaults[:topic_reply_prompt_enabled]).to eq(true) + end + + it "exposes the feature toggles to the client" do + client_settings = SiteSetting.client_settings + expect(client_settings).to include(:precheck_new_topic_enabled) + expect(client_settings).to include(:topic_footer_message_enabled) + expect(client_settings).to include(:topic_reply_prompt_enabled) + end + end + + describe "moderator-messages Guardian" do + before { SiteSetting.mod_categories_enabled = true } + + it "lets moderators manage the moderator messages" do + expect(Guardian.new(moderator).can_manage_mod_messages?).to eq(true) + end + + it "lets admins manage the moderator messages" do + expect(Guardian.new(admin).can_manage_mod_messages?).to eq(true) + end + + it "does not let regular users manage the moderator messages" do + expect(Guardian.new(user).can_manage_mod_messages?).to eq(false) + end + + it "does not let anonymous users manage the moderator messages" do + expect(Guardian.new(nil).can_manage_mod_messages?).to eq(false) + end + + it "denies moderators when the plugin master switch is off" do + SiteSetting.mod_categories_enabled = false + expect(Guardian.new(moderator).can_manage_mod_messages?).to eq(false) + end + end +end diff --git a/discourse-mod/spec/requests/checklist_spec.rb b/discourse-mod/spec/requests/checklist_spec.rb new file mode 100644 index 0000000..f727ee7 --- /dev/null +++ b/discourse-mod/spec/requests/checklist_spec.rb @@ -0,0 +1,791 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Covers the forum-wide first-post checklist: staff read/edit it, users +# acknowledge it, and the current-user serializer only exposes it to a +# not-yet-trusted user who still owes an acknowledgement. +RSpec.describe "First-post checklist" do + fab!(:admin) + fab!(:moderator) + fab!(:user) { Fabricate(:user, trust_level: TrustLevel[1]) } + + let(:ns) { DiscourseModCategories::CHECKLIST_STORE_NAMESPACE } + let(:key) { DiscourseModCategories::CHECKLIST_STORE_KEY } + + before { SiteSetting.mod_categories_enabled = true } + + def serialized(target) + CurrentUserSerializer.new( + target, + scope: Guardian.new(target), + root: false, + ).as_json + end + + describe "GET /discourse-mod-categories/checklist" do + it "returns the current checklist to a moderator" do + PluginStore.set( + ns, + key, + { "version" => 3, "items" => [{ "label" => "Read the rules", "url" => "" }] }, + ) + sign_in(moderator) + + get "/discourse-mod-categories/checklist.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["version"]).to eq(3) + expect(response.parsed_body["items"].first["label"]).to eq("Read the rules") + end + + it "returns an empty checklist when none is set" do + sign_in(admin) + get "/discourse-mod-categories/checklist.json" + expect(response.status).to eq(200) + expect(response.parsed_body["version"]).to eq(0) + expect(response.parsed_body["items"]).to eq([]) + end + + it "forbids a regular user" do + sign_in(user) + get "/discourse-mod-categories/checklist.json" + expect(response.status).to eq(403) + end + end + + describe "PUT /discourse-mod-categories/checklist" do + it "lets a moderator save the checklist and bumps the version" do + sign_in(moderator) + + put "/discourse-mod-categories/checklist.json", + params: { + items: [ + { label: "Read the guidelines", url: "https://example.com/rules" }, + { label: "Be kind", url: "" }, + ], + } + + expect(response.status).to eq(200) + expect(response.parsed_body["version"]).to eq(1) + expect(response.parsed_body["items"].size).to eq(2) + + put "/discourse-mod-categories/checklist.json", + params: { + items: [{ label: "One item", url: "" }], + } + expect(response.parsed_body["version"]).to eq(2) + end + + it "drops rows whose label is blank" do + sign_in(moderator) + + put "/discourse-mod-categories/checklist.json", + params: { + items: [ + { label: "Keep me", url: "" }, + { label: " ", url: "https://example.com" }, + { label: "", url: "" }, + ], + } + + expect(response.parsed_body["items"].map { |i| i["label"] }).to eq( + ["Keep me"], + ) + end + + it "forbids a regular user" do + sign_in(user) + put "/discourse-mod-categories/checklist.json", + params: { + items: [{ label: "x", url: "" }], + } + expect(response.status).to eq(403) + expect(DiscourseModCategories.checklist_config).to be_nil + end + + it "stores the trust-level cap and the accept-button label" do + sign_in(moderator) + + put "/discourse-mod-categories/checklist.json", + params: { + items: [{ label: "Agree", url: "" }], + max_tl: 1, + button_label: "I understand", + } + + expect(response.parsed_body["max_tl"]).to eq(1) + expect(response.parsed_body["button_label"]).to eq("I understand") + + config = DiscourseModCategories.checklist_config + expect(config["max_tl"]).to eq(1) + expect(config["button_label"]).to eq("I understand") + end + + it "defaults the trust-level cap to 2 and clamps out-of-range values" do + sign_in(moderator) + + put "/discourse-mod-categories/checklist.json", + params: { + items: [{ label: "x", url: "" }], + } + expect(response.parsed_body["max_tl"]).to eq(2) + + put "/discourse-mod-categories/checklist.json", + params: { + items: [{ label: "x", url: "" }], + max_tl: 9, + } + expect(response.parsed_body["max_tl"]).to eq(2) + + put "/discourse-mod-categories/checklist.json", + params: { + items: [{ label: "x", url: "" }], + max_tl: -1, + } + expect(response.parsed_body["max_tl"]).to eq(0) + end + end + + describe "GET /discourse-mod-categories/checklist/owed" do + it "returns the owed checklist for a TL0-TL2 user who has not accepted" do + PluginStore.set( + ns, + key, + { "version" => 1, "items" => [{ "label" => "Read the rules", "url" => "" }] }, + ) + sign_in(user) + + get "/discourse-mod-categories/checklist/owed.json" + + expect(response.status).to eq(200) + checklist = response.parsed_body["checklist"] + expect(checklist).to be_present + expect(checklist["kind"]).to eq("global") + expect(checklist["version"]).to eq(1) + expect(checklist["items"].first["label"]).to eq("Read the rules") + end + + it "returns null when the user owes nothing" do + PluginStore.set(ns, key, { "version" => 1, "items" => [{ "label" => "x" }] }) + user.upsert_custom_fields( + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD => 1, + ) + sign_in(user) + + get "/discourse-mod-categories/checklist/owed.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["checklist"]).to be_nil + end + + it "returns null for staff under the forum-wide checklist" do + PluginStore.set(ns, key, { "version" => 1, "items" => [{ "label" => "x" }] }) + sign_in(moderator) + + get "/discourse-mod-categories/checklist/owed.json" + + expect(response.parsed_body["checklist"]).to be_nil + end + + it "returns the new version after a mid-session version bump" do + PluginStore.set(ns, key, { "version" => 1, "items" => [{ "label" => "x" }] }) + user.upsert_custom_fields( + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD => 1, + ) + sign_in(user) + + get "/discourse-mod-categories/checklist/owed.json" + expect(response.parsed_body["checklist"]).to be_nil + + # Staff publish a newer version; the same session sees it. + PluginStore.set(ns, key, { "version" => 2, "items" => [{ "label" => "y" }] }) + + get "/discourse-mod-categories/checklist/owed.json" + expect(response.parsed_body["checklist"]["version"]).to eq(2) + end + + it "returns a targeted checklist for a targeted user" do + PluginStore.set( + ns, + DiscourseModCategories::TARGETED_CHECKLISTS_KEY, + [ + { + "id" => "abc123", + "name" => "Mods", + "user_ids" => [user.id], + "items" => [{ "label" => "Read this", "url" => "" }], + "version" => 1, + "button_label" => "OK", + }, + ], + ) + sign_in(user) + + get "/discourse-mod-categories/checklist/owed.json" + + checklist = response.parsed_body["checklist"] + expect(checklist["kind"]).to eq("targeted") + expect(checklist["id"]).to eq("abc123") + end + + it "requires login" do + get "/discourse-mod-categories/checklist/owed.json" + expect(response.status).to eq(403) + end + end + + describe "checklist updated_at timestamp" do + it "is stored and surfaced when staff save the forum-wide checklist" do + sign_in(moderator) + + put "/discourse-mod-categories/checklist.json", + params: { + items: [{ label: "Agree", url: "" }], + } + expect(response.parsed_body["updated_at"]).to be_present + first = DiscourseModCategories.checklist_config["updated_at"] + expect(first).to be_present + + put "/discourse-mod-categories/checklist.json", + params: { + items: [{ label: "Agree again", url: "" }], + } + second = DiscourseModCategories.checklist_config["updated_at"] + expect(second).to be_present + expect(second >= first).to eq(true) + end + + it "is exposed in the owed-checklist payload" do + PluginStore.set( + ns, + key, + { + "version" => 1, + "updated_at" => "2026-01-02T03:04:05Z", + "items" => [{ "label" => "x", "url" => "" }], + }, + ) + sign_in(user) + + get "/discourse-mod-categories/checklist/owed.json" + expect(response.parsed_body["checklist"]["updated_at"]).to eq( + "2026-01-02T03:04:05Z", + ) + end + + it "is stored when staff create and update a targeted checklist" do + sign_in(moderator) + + post "/discourse-mod-categories/checklist/targeted.json", + params: { + name: "App uploaders", + user_ids: [user.id], + items: [{ label: "x", url: "" }], + } + created = response.parsed_body["targeted"].first + expect(created["updated_at"]).to be_present + id = created["id"] + + put "/discourse-mod-categories/checklist/targeted/#{id}.json", + params: { + name: "Renamed", + user_ids: [user.id], + items: [{ label: "y", url: "" }], + } + updated = response.parsed_body["targeted"].first + expect(updated["updated_at"]).to be_present + expect(updated["updated_at"] >= created["updated_at"]).to eq(true) + end + end + + describe "POST /discourse-mod-categories/checklist/accept" do + it "records the accepted version on the user" do + PluginStore.set(ns, key, { "version" => 2, "items" => [{ "label" => "x" }] }) + sign_in(user) + + post "/discourse-mod-categories/checklist/accept.json", + params: { + version: 2, + } + + expect(response.status).to eq(200) + expect( + user.reload.custom_fields[ + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + ], + ).to eq(2) + end + + it "clamps the accepted version to the published version" do + PluginStore.set(ns, key, { "version" => 2, "items" => [{ "label" => "x" }] }) + sign_in(user) + + post "/discourse-mod-categories/checklist/accept.json", + params: { + version: 99, + } + + expect( + user.reload.custom_fields[ + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + ], + ).to eq(2) + end + end + + describe "acceptance audit log" do + let(:log_key) { DiscourseModCategories::CHECKLIST_LOG_KEY } + + it "appends an entry each time a user accepts" do + PluginStore.set(ns, key, { "version" => 1, "items" => [{ "label" => "x" }] }) + sign_in(user) + + post "/discourse-mod-categories/checklist/accept.json", + params: { + version: 1, + } + + entries = PluginStore.get(ns, log_key) + expect(entries.size).to eq(1) + expect(entries.first["user_id"]).to eq(user.id) + expect(entries.first["version"]).to eq(1) + expect(entries.first["at"]).to be_present + end + + it "is returned by the show endpoint, newest first, with usernames" do + PluginStore.set(ns, key, { "version" => 2, "items" => [{ "label" => "x" }] }) + other = Fabricate(:user, trust_level: TrustLevel[0]) + + sign_in(user) + post "/discourse-mod-categories/checklist/accept.json", params: { version: 2 } + sign_in(other) + post "/discourse-mod-categories/checklist/accept.json", params: { version: 2 } + + sign_in(moderator) + get "/discourse-mod-categories/checklist.json" + + log = response.parsed_body["log"] + expect(log.size).to eq(2) + expect(log.first["username"]).to eq(other.username) + expect(log.last["username"]).to eq(user.username) + expect(log.first["version"]).to eq(2) + end + end + + describe "current-user serializer" do + before do + PluginStore.set( + ns, + key, + { "version" => 1, "items" => [{ "label" => "Read the rules", "url" => "" }] }, + ) + end + + it "exposes the checklist to a TL0-TL2 user who has not accepted it" do + [TrustLevel[0], TrustLevel[1], TrustLevel[2]].each do |tl| + target = Fabricate(:user, trust_level: tl) + checklist = serialized(target)[:mod_first_post_checklist] + expect(checklist).to be_present + expect(checklist[:version]).to eq(1) + end + end + + it "does not expose the checklist to a TL3 user" do + target = Fabricate(:user, trust_level: TrustLevel[3]) + expect(serialized(target)[:mod_first_post_checklist]).to be_nil + end + + it "does not expose the checklist to staff" do + expect(serialized(moderator)[:mod_first_post_checklist]).to be_nil + expect(serialized(admin)[:mod_first_post_checklist]).to be_nil + end + + it "stops exposing the checklist once the user has accepted it" do + user.custom_fields[ + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + ] = 1 + user.save_custom_fields(true) + expect(serialized(user)[:mod_first_post_checklist]).to be_nil + end + + it "exposes the checklist again after a new version is published" do + user.custom_fields[ + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + ] = 1 + user.save_custom_fields(true) + PluginStore.set( + ns, + key, + { "version" => 2, "items" => [{ "label" => "Updated rule", "url" => "" }] }, + ) + expect(serialized(user)[:mod_first_post_checklist][:version]).to eq(2) + end + + it "exposes nothing when the checklist has no items" do + PluginStore.set(ns, key, { "version" => 5, "items" => [] }) + expect(serialized(user)[:mod_first_post_checklist]).to be_nil + end + + it "honours a trust-level cap below the default" do + PluginStore.set( + ns, + key, + { + "version" => 1, + "max_tl" => 1, + "items" => [{ "label" => "Agree", "url" => "" }], + }, + ) + tl1 = Fabricate(:user, trust_level: TrustLevel[1]) + tl2 = Fabricate(:user, trust_level: TrustLevel[2]) + + expect(serialized(tl1)[:mod_first_post_checklist]).to be_present + expect(serialized(tl2)[:mod_first_post_checklist]).to be_nil + end + + it "exposes the configured accept-button label" do + PluginStore.set( + ns, + key, + { + "version" => 1, + "button_label" => "I understand", + "items" => [{ "label" => "Agree", "url" => "" }], + }, + ) + checklist = serialized(user)[:mod_first_post_checklist] + expect(checklist[:button_label]).to eq("I understand") + end + + it "tags the forum-wide checklist with kind 'global'" do + expect(serialized(user)[:mod_first_post_checklist][:kind]).to eq("global") + end + + it "exposes the stored updated_at timestamp" do + PluginStore.set( + ns, + key, + { + "version" => 1, + "updated_at" => "2026-01-02T03:04:05Z", + "items" => [{ "label" => "Agree", "url" => "" }], + }, + ) + checklist = serialized(user)[:mod_first_post_checklist] + expect(checklist[:updated_at]).to eq("2026-01-02T03:04:05Z") + end + end + + describe "POST /discourse-mod-categories/checklist/require-reaccept" do + it "lets a moderator reset a user's accepted version to 0" do + user.upsert_custom_fields( + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD => 5, + ) + sign_in(moderator) + + post "/discourse-mod-categories/checklist/require-reaccept.json", + params: { + username: user.username, + } + + expect(response.status).to eq(200) + expect( + user.reload.custom_fields[ + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + ], + ).to eq(0) + end + + it "accepts a user_id as well" do + user.upsert_custom_fields( + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD => 3, + ) + sign_in(admin) + + post "/discourse-mod-categories/checklist/require-reaccept.json", + params: { + user_id: user.id, + } + + expect(response.status).to eq(200) + expect( + user.reload.custom_fields[ + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + ], + ).to eq(0) + end + + it "forbids a regular user" do + sign_in(user) + post "/discourse-mod-categories/checklist/require-reaccept.json", + params: { + username: moderator.username, + } + expect(response.status).to eq(403) + end + + it "404s for an unknown user" do + sign_in(moderator) + post "/discourse-mod-categories/checklist/require-reaccept.json", + params: { + username: "nobody-here", + } + expect(response.status).to eq(404) + end + end + + describe "targeted checklists" do + let(:targeted_key) { DiscourseModCategories::TARGETED_CHECKLISTS_KEY } + let(:targeted_field) do + DiscourseModCategories::USER_TARGETED_CHECKLIST_FIELD + end + + it "lets a moderator create a targeted checklist starting at version 1" do + sign_in(moderator) + + post "/discourse-mod-categories/checklist/targeted.json", + params: { + name: "App uploaders", + user_ids: [user.id], + button_label: "I agree", + items: [{ label: "Read the app rules", url: "" }], + } + + expect(response.status).to eq(200) + list = response.parsed_body["targeted"] + expect(list.size).to eq(1) + expect(list.first["name"]).to eq("App uploaders") + expect(list.first["version"]).to eq(1) + expect(list.first["user_ids"]).to eq([user.id]) + expect(list.first["id"]).to be_present + end + + it "lets a moderator update a targeted checklist and bumps its version" do + sign_in(moderator) + post "/discourse-mod-categories/checklist/targeted.json", + params: { + name: "Original", + user_ids: [user.id], + items: [{ label: "x", url: "" }], + } + id = response.parsed_body["targeted"].first["id"] + + put "/discourse-mod-categories/checklist/targeted/#{id}.json", + params: { + name: "Renamed", + user_ids: [user.id], + items: [{ label: "y", url: "" }], + } + + expect(response.status).to eq(200) + checklist = response.parsed_body["targeted"].first + expect(checklist["name"]).to eq("Renamed") + expect(checklist["version"]).to eq(2) + end + + it "lets a moderator delete a targeted checklist" do + sign_in(moderator) + post "/discourse-mod-categories/checklist/targeted.json", + params: { + name: "Doomed", + user_ids: [user.id], + items: [{ label: "x", url: "" }], + } + id = response.parsed_body["targeted"].first["id"] + + delete "/discourse-mod-categories/checklist/targeted/#{id}.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["targeted"]).to eq([]) + end + + it "drops user ids that are not real users" do + sign_in(moderator) + post "/discourse-mod-categories/checklist/targeted.json", + params: { + name: "Filtered", + user_ids: [user.id, 999_999], + items: [{ label: "x", url: "" }], + } + expect(response.parsed_body["targeted"].first["user_ids"]).to eq( + [user.id], + ) + end + + it "forbids a regular user from the management endpoints" do + sign_in(user) + + post "/discourse-mod-categories/checklist/targeted.json", + params: { + name: "Nope", + user_ids: [user.id], + items: [{ label: "x", url: "" }], + } + expect(response.status).to eq(403) + + put "/discourse-mod-categories/checklist/targeted/abc.json", + params: { + name: "Nope", + } + expect(response.status).to eq(403) + + delete "/discourse-mod-categories/checklist/targeted/abc.json" + expect(response.status).to eq(403) + end + + it "returns a targeted checklist to a targeted staff member" do + PluginStore.set( + ns, + targeted_key, + [ + { + "id" => "abc123", + "name" => "Mods", + "user_ids" => [moderator.id], + "items" => [{ "label" => "Read this", "url" => "" }], + "version" => 1, + "button_label" => "OK", + }, + ], + ) + + checklist = serialized(moderator)[:mod_first_post_checklist] + expect(checklist).to be_present + expect(checklist[:kind]).to eq("targeted") + expect(checklist[:id]).to eq("abc123") + end + + it "returns a targeted checklist to a targeted high-trust user" do + tl4 = Fabricate(:user, trust_level: TrustLevel[4]) + PluginStore.set( + ns, + targeted_key, + [ + { + "id" => "tl4check", + "name" => "Veterans", + "user_ids" => [tl4.id], + "items" => [{ "label" => "Read this", "url" => "" }], + "version" => 1, + "button_label" => "", + }, + ], + ) + + checklist = serialized(tl4)[:mod_first_post_checklist] + expect(checklist).to be_present + expect(checklist[:kind]).to eq("targeted") + end + + it "does not show a targeted checklist to a user not listed" do + PluginStore.set( + ns, + targeted_key, + [ + { + "id" => "abc123", + "name" => "Mods", + "user_ids" => [moderator.id], + "items" => [{ "label" => "Read this", "url" => "" }], + "version" => 1, + "button_label" => "", + }, + ], + ) + expect(serialized(user)[:mod_first_post_checklist]).to be_nil + end + + it "accept with kind 'targeted' records to the per-checklist map and clamps" do + PluginStore.set( + ns, + targeted_key, + [ + { + "id" => "abc123", + "name" => "Mods", + "user_ids" => [user.id], + "items" => [{ "label" => "x", "url" => "" }], + "version" => 2, + "button_label" => "", + }, + ], + ) + sign_in(user) + + post "/discourse-mod-categories/checklist/accept.json", + params: { + kind: "targeted", + id: "abc123", + version: 99, + } + + expect(response.status).to eq(200) + map = user.reload.custom_fields[targeted_field] + expect(map["abc123"]).to eq(2) + # The global field is untouched. + expect( + user.custom_fields[ + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + ], + ).to be_nil + end + + it "stops showing a targeted checklist once accepted, until bumped" do + PluginStore.set( + ns, + targeted_key, + [ + { + "id" => "abc123", + "name" => "Mods", + "user_ids" => [user.id], + "items" => [{ "label" => "x", "url" => "" }], + "version" => 1, + "button_label" => "", + }, + ], + ) + user.upsert_custom_fields(targeted_field => { "abc123" => 1 }) + expect(serialized(user)[:mod_first_post_checklist]).to be_nil + + PluginStore.set( + ns, + targeted_key, + [ + { + "id" => "abc123", + "name" => "Mods", + "user_ids" => [user.id], + "items" => [{ "label" => "x", "url" => "" }], + "version" => 2, + "button_label" => "", + }, + ], + ) + expect(serialized(user.reload)[:mod_first_post_checklist][:kind]).to eq( + "targeted", + ) + end + + it "shows the targeted list in the staff show endpoint" do + PluginStore.set( + ns, + targeted_key, + [ + { + "id" => "abc123", + "name" => "Mods", + "user_ids" => [user.id], + "items" => [{ "label" => "x", "url" => "" }], + "version" => 1, + "button_label" => "", + }, + ], + ) + sign_in(moderator) + get "/discourse-mod-categories/checklist.json" + expect(response.parsed_body["targeted"].first["id"]).to eq("abc123") + end + end +end diff --git a/discourse-mod/spec/requests/mod_messages_edge_cases_spec.rb b/discourse-mod/spec/requests/mod_messages_edge_cases_spec.rb new file mode 100644 index 0000000..8c393ed --- /dev/null +++ b/discourse-mod/spec/requests/mod_messages_edge_cases_spec.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Extra edge-case coverage for the moderator-messages endpoints — +# parameter coercion, idempotency, and audit-style behaviour. Complements +# spec/requests/mod_messages_spec.rb without duplicating its examples. +RSpec.describe "Moderator messages endpoints — edge cases" do + fab!(:admin) + fab!(: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 "PUT /discourse-mod-categories/topic/:topic_id (idempotency)" do + it "is idempotent for setting the same footer message twice" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { footer_message: "Same value." } + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { footer_message: "Same value." } + + expect(response.status).to eq(200) + expect(topic.reload.custom_fields["mod_topic_footer_message"]).to eq( + "Same value.", + ) + end + + it "leaves the footer message untouched when only the reply prompt changes" do + topic.custom_fields["mod_topic_footer_message"] = "Keep me." + topic.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { reply_prompt: "A new reply prompt." } + + expect(response.status).to eq(200) + expect(topic.reload.custom_fields["mod_topic_footer_message"]).to eq( + "Keep me.", + ) + expect(topic.custom_fields["mod_topic_reply_prompt"]).to eq( + "A new reply prompt.", + ) + end + + it "accepts whitespace-only inputs without crashing" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { footer_message: " ", reply_prompt: "\n\t" } + + expect(response.status).to eq(200) + end + end + + describe "approval-flag coercion" do + it "treats 'true' (string) as true" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { require_reply_approval: "true" } + + expect(response.status).to eq(200) + expect( + topic.reload.custom_fields["mod_topic_require_reply_approval"], + ).to eq(true) + end + + it "treats 'false' (string) as false" do + topic.custom_fields["mod_topic_require_reply_approval"] = true + topic.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { require_reply_approval: "false" } + + expect(response.status).to eq(200) + expect( + topic.reload.custom_fields["mod_topic_require_reply_approval"], + ).to eq(false) + end + end + + describe "trust-level cap edge cases" do + it "treats a string-form trust-level cap as the equivalent integer" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { reply_prompt: "x", reply_prompt_max_tl: "2" } + + expect(response.parsed_body["reply_prompt_max_tl"]).to eq(2) + end + + it "clamps an obviously-too-large new-topic trust-level cap" do + sign_in(moderator) + + put "/discourse-mod-categories/category/#{category.id}.json", + params: { + new_topic_prompt: "x", + new_topic_prompt_max_tl: 1_000_000, + } + + expect(response.parsed_body["new_topic_prompt_max_tl"]).to eq(4) + end + end + + describe "GET /discourse-mod-categories/notes-feed (ordering)" do + fab!(:older_topic) do + Fabricate( + :topic, + category: category, + title: "An older thread waiting on a moderator review", + ) + end + fab!(:newer_topic) do + Fabricate( + :topic, + category: category, + title: "A newer thread waiting on a moderator review", + ) + end + + before do + older_topic.custom_fields["mod_topic_private_note"] = "Older note." + older_topic.custom_fields["mod_topic_private_note_activity_at"] = + 3.days.ago.iso8601 + older_topic.save_custom_fields(true) + + newer_topic.custom_fields["mod_topic_private_note"] = "Newer note." + newer_topic.custom_fields["mod_topic_private_note_activity_at"] = + Time.zone.now.iso8601 + newer_topic.save_custom_fields(true) + end + + it "orders notes with the most recent activity first" do + sign_in(moderator) + + get "/discourse-mod-categories/notes-feed.json" + + ids = response.parsed_body["notes"].map { |n| n["topic_id"] } + newer_idx = ids.index(newer_topic.id) + older_idx = ids.index(older_topic.id) + expect(newer_idx).not_to be_nil + expect(older_idx).not_to be_nil + expect(newer_idx).to be < older_idx + end + + it "excludes topics that have no private note" do + sign_in(moderator) + + get "/discourse-mod-categories/notes-feed.json" + + ids = response.parsed_body["notes"].map { |n| n["topic_id"] } + expect(ids).not_to include(topic.id) + end + end + + describe "private-note position normalisation" do + %w[top bottom].each do |pos| + it "preserves '#{pos}' as the position" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { private_note: "n", private_note_position: pos } + + expect( + topic.reload.custom_fields["mod_topic_private_note_position"], + ).to eq(pos) + end + end + + [nil, "", "TOP", "Bottom", "side", "left"].each do |pos| + it "falls back to bottom for unrecognised position #{pos.inspect}" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { private_note: "n", private_note_position: pos } + + expect( + topic.reload.custom_fields["mod_topic_private_note_position"], + ).to eq("bottom") + end + end + end + + describe "note-reply identifiers" do + it "generates a unique id per reply" do + sign_in(moderator) + + 4.times do |i| + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { raw: "Reply #{i}." } + end + + ids = + topic.reload.custom_fields["mod_topic_private_note_replies"].map do |r| + r["id"] + end + + expect(ids.size).to eq(4) + expect(ids.uniq.size).to eq(4) + end + + it "records the moderator id and created_at for each reply" do + sign_in(moderator) + + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { raw: "Hello." } + + reply = topic.reload.custom_fields["mod_topic_private_note_replies"].last + expect(reply["user_id"]).to eq(moderator.id) + expect(reply["created_at"]).to be_present + end + end +end diff --git a/discourse-mod/spec/requests/mod_messages_spec.rb b/discourse-mod/spec/requests/mod_messages_spec.rb new file mode 100644 index 0000000..5c141a6 --- /dev/null +++ b/discourse-mod/spec/requests/mod_messages_spec.rb @@ -0,0 +1,869 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Verifies that only moderators and admins can set the plugin's +# moderator messages, and that regular/anonymous users are forbidden. +RSpec.describe "Moderator messages endpoints" do + fab!(:admin) + fab!(: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 "PUT /discourse-mod-categories/topic/:topic_id" do + it "lets a moderator set the footer message and reply prompt" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + footer_message: "Read the pinned guidelines.", + reply_prompt: "Is your reply on-topic?", + } + + expect(response.status).to eq(200) + expect(topic.reload.custom_fields["mod_topic_footer_message"]).to eq( + "Read the pinned guidelines.", + ) + expect(topic.custom_fields["mod_topic_reply_prompt"]).to eq( + "Is your reply on-topic?", + ) + end + + it "lets an admin set the messages" do + sign_in(admin) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + footer_message: "Set by an admin", + } + + expect(response.status).to eq(200) + expect(topic.reload.custom_fields["mod_topic_footer_message"]).to eq( + "Set by an admin", + ) + end + + it "forbids a regular user (only mods/admins may set it)" do + sign_in(user) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + footer_message: "should not be saved", + } + + expect(response.status).to eq(403) + expect(topic.reload.custom_fields["mod_topic_footer_message"]).to be_blank + end + + it "forbids an anonymous user" do + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + footer_message: "should not be saved", + } + + expect(response.status).to eq(403) + expect(topic.reload.custom_fields["mod_topic_footer_message"]).to be_blank + end + + it "returns 404 for a missing topic" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/0.json", + params: { + footer_message: "x", + } + + expect(response.status).to eq(404) + end + + it "updates only the fields that are provided" do + topic.custom_fields["mod_topic_reply_prompt"] = "existing prompt" + topic.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + footer_message: "new footer", + } + + expect(response.status).to eq(200) + expect(topic.reload.custom_fields["mod_topic_reply_prompt"]).to eq( + "existing prompt", + ) + end + + it "lets a moderator pin a post to the bottom" 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) + expect(topic.reload.custom_fields["mod_topic_pinned_post_id"]).to eq( + first_post.id, + ) + 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) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + pinned_post_id: "", + } + + expect(response.status).to eq(200) + expect( + topic.reload.custom_fields["mod_topic_pinned_post_id"], + ).to be_blank + end + + it "rejects a pinned_post_id that is not a post in this topic" do + other_post = Fabricate(:post) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + pinned_post_id: other_post.id, + } + + expect(response.status).to eq(400) + expect( + topic.reload.custom_fields["mod_topic_pinned_post_id"], + ).to be_blank + end + + it "forbids a regular user from pinning a post" do + sign_in(user) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + pinned_post_id: first_post.id, + } + + expect(response.status).to eq(403) + expect( + topic.reload.custom_fields["mod_topic_pinned_post_id"], + ).to be_blank + end + + it "accepts a long, unicode footer message" do + sign_in(moderator) + long_message = "проверка 🚀 wide content " * 200 + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + footer_message: long_message, + } + + expect(response.status).to eq(200) + expect(topic.reload.custom_fields["mod_topic_footer_message"]).to eq( + long_message, + ) + end + + it "clears the reply prompt when sent an empty string" do + topic.custom_fields["mod_topic_reply_prompt"] = "An old prompt" + topic.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + reply_prompt: "", + } + + expect(response.status).to eq(200) + expect(topic.reload.custom_fields["mod_topic_reply_prompt"]).to eq("") + end + + it "is a no-op when no recognised params are sent" do + topic.custom_fields["mod_topic_footer_message"] = "Keep me" + topic.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", params: {} + + expect(response.status).to eq(200) + expect(topic.reload.custom_fields["mod_topic_footer_message"]).to eq( + "Keep me", + ) + end + + it "pins, then unpins, leaving no pinned post" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + pinned_post_id: first_post.id, + } + expect(topic.reload.custom_fields["mod_topic_pinned_post_id"]).to eq( + first_post.id, + ) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + pinned_post_id: "", + } + expect( + topic.reload.custom_fields["mod_topic_pinned_post_id"], + ).to be_blank + end + + it "treats pinned_post_id 0 as unpin" 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: "0", + } + + expect(response.status).to eq(200) + expect( + topic.reload.custom_fields["mod_topic_pinned_post_id"], + ).to be_blank + end + + it "lets a moderator require reply approval for the topic" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + require_reply_approval: true, + } + + expect(response.status).to eq(200) + expect( + topic.reload.custom_fields["mod_topic_require_reply_approval"], + ).to eq(true) + end + + it "lets a moderator turn reply approval back off" do + topic.custom_fields["mod_topic_require_reply_approval"] = true + topic.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + require_reply_approval: false, + } + + expect(response.status).to eq(200) + expect( + topic.reload.custom_fields["mod_topic_require_reply_approval"], + ).to eq(false) + end + + it "forbids a regular user from changing reply approval" do + sign_in(user) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + require_reply_approval: true, + } + + expect(response.status).to eq(403) + end + + it "lets a moderator set a private note and its position" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + private_note: "For staff only", + private_note_position: "top", + } + + expect(response.status).to eq(200) + expect(topic.reload.custom_fields["mod_topic_private_note"]).to eq( + "For staff only", + ) + expect( + topic.custom_fields["mod_topic_private_note_position"], + ).to eq("top") + # The moderator who set the note is recorded as its author. + expect( + topic.custom_fields["mod_topic_private_note_user_id"], + ).to eq(moderator.id) + expect(response.parsed_body["private_note_author"]["username"]).to eq( + moderator.username, + ) + end + + it "falls back to bottom for an invalid note position" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + private_note: "note", + private_note_position: "sideways", + } + + expect(response.status).to eq(200) + expect( + topic.reload.custom_fields["mod_topic_private_note_position"], + ).to eq("bottom") + end + + it "forbids a regular user from setting a private note" do + sign_in(user) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + private_note: "should not be saved", + } + + expect(response.status).to eq(403) + expect( + topic.reload.custom_fields["mod_topic_private_note"], + ).to be_blank + end + end + + describe "PUT /discourse-mod-categories/category/:category_id" do + it "lets a moderator set the per-category new-topic prompt" do + sign_in(moderator) + + put "/discourse-mod-categories/category/#{category.id}.json", + params: { + new_topic_prompt: "Have you searched for an existing topic?", + } + + expect(response.status).to eq(200) + expect( + category.reload.custom_fields["mod_category_new_topic_prompt"], + ).to eq("Have you searched for an existing topic?") + end + + it "lets an admin set the per-category new-topic prompt" do + sign_in(admin) + + put "/discourse-mod-categories/category/#{category.id}.json", + params: { + new_topic_prompt: "Set by an admin", + } + + expect(response.status).to eq(200) + expect( + category.reload.custom_fields["mod_category_new_topic_prompt"], + ).to eq("Set by an admin") + end + + it "forbids a regular user (only mods/admins may set it)" do + sign_in(user) + + put "/discourse-mod-categories/category/#{category.id}.json", + params: { + new_topic_prompt: "should not be saved", + } + + expect(response.status).to eq(403) + expect( + category.reload.custom_fields["mod_category_new_topic_prompt"], + ).to be_blank + end + + it "forbids an anonymous user" do + put "/discourse-mod-categories/category/#{category.id}.json", + params: { + new_topic_prompt: "should not be saved", + } + + expect(response.status).to eq(403) + end + + it "returns 404 for a missing category" do + sign_in(moderator) + + put "/discourse-mod-categories/category/0.json", + params: { + new_topic_prompt: "x", + } + + expect(response.status).to eq(404) + end + + it "clears the new-topic prompt when sent an empty string" do + category.custom_fields["mod_category_new_topic_prompt"] = "An old prompt" + category.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/category/#{category.id}.json", + params: { + new_topic_prompt: "", + } + + expect(response.status).to eq(200) + expect( + category.reload.custom_fields["mod_category_new_topic_prompt"], + ).to eq("") + end + + it "stores the new-topic prompt trust-level cap" do + sign_in(moderator) + + put "/discourse-mod-categories/category/#{category.id}.json", + params: { + new_topic_prompt: "Read the guidelines", + new_topic_prompt_max_tl: 1, + } + + expect(response.status).to eq(200) + expect(response.parsed_body["new_topic_prompt_max_tl"]).to eq(1) + expect( + category.reload.custom_fields["mod_category_new_topic_prompt_max_tl"], + ).to eq(1) + end + + it "clamps an out-of-range new-topic trust-level cap to 0-4" do + sign_in(moderator) + + put "/discourse-mod-categories/category/#{category.id}.json", + params: { + new_topic_prompt: "x", + new_topic_prompt_max_tl: 9, + } + expect(response.parsed_body["new_topic_prompt_max_tl"]).to eq(4) + + put "/discourse-mod-categories/category/#{category.id}.json", + params: { + new_topic_prompt: "x", + new_topic_prompt_max_tl: -3, + } + expect(response.parsed_body["new_topic_prompt_max_tl"]).to eq(0) + end + end + + describe "reply-prompt trust-level cap" do + it "stores the reply-prompt trust-level cap on the topic" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + reply_prompt: "Is your reply on-topic?", + reply_prompt_max_tl: 1, + } + + expect(response.status).to eq(200) + expect(response.parsed_body["reply_prompt_max_tl"]).to eq(1) + expect( + topic.reload.custom_fields["mod_topic_reply_prompt_max_tl"], + ).to eq(1) + end + + it "clamps an out-of-range reply trust-level cap to 0-4" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + reply_prompt: "x", + reply_prompt_max_tl: 99, + } + expect(response.parsed_body["reply_prompt_max_tl"]).to eq(4) + end + + it "leaves the cap untouched when the param is absent" do + topic.custom_fields["mod_topic_reply_prompt_max_tl"] = 2 + topic.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + reply_prompt: "changed", + } + + expect( + topic.reload.custom_fields["mod_topic_reply_prompt_max_tl"], + ).to eq(2) + end + end + + describe "POST /discourse-mod-categories/topic/:topic_id/note-reply" do + it "lets a moderator add a reply to the note thread" do + sign_in(moderator) + + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "Following up on this note.", + } + + expect(response.status).to eq(200) + replies = + topic.reload.custom_fields["mod_topic_private_note_replies"] + expect(replies.last["raw"]).to eq("Following up on this note.") + expect(replies.last["user_id"]).to eq(moderator.id) + expect(replies.last["created_at"]).to be_present + end + + it "appends multiple replies in order" do + sign_in(moderator) + + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "First reply.", + } + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "Second reply.", + } + + replies = + topic.reload.custom_fields["mod_topic_private_note_replies"] + expect(replies.map { |r| r["raw"] }).to eq( + ["First reply.", "Second reply."], + ) + end + + it "returns the serialized replies with author info" do + sign_in(moderator) + + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "Hello team.", + } + + reply = response.parsed_body["replies"].last + expect(reply["raw"]).to eq("Hello team.") + expect(reply["author"]["username"]).to eq(moderator.username) + end + + it "rejects a blank reply" do + sign_in(moderator) + + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: " ", + } + + expect(response.status).to eq(400) + end + + it "forbids a regular user from replying to the note" do + sign_in(user) + + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "should not be saved", + } + + expect(response.status).to eq(403) + expect( + topic.reload.custom_fields["mod_topic_private_note_replies"], + ).to be_blank + end + end + + describe "editing and deleting note-thread entries" do + # Seeds the topic with a note plus two staff replies and returns their ids. + def seed_thread + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + private_note: "The note body.", + } + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "First reply.", + } + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "Second reply.", + } + topic.reload.custom_fields["mod_topic_private_note_replies"].map do |r| + r["id"] + end + end + + describe "PUT /discourse-mod-categories/topic/:topic_id/note-reply" do + it "lets a moderator edit a reply's raw" do + sign_in(moderator) + ids = seed_thread + + put "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + reply_id: ids.first, + raw: "Edited first reply.", + } + + expect(response.status).to eq(200) + replies = topic.reload.custom_fields["mod_topic_private_note_replies"] + edited = replies.find { |r| r["id"] == ids.first } + expect(edited["raw"]).to eq("Edited first reply.") + # The other reply is untouched. + other = replies.find { |r| r["id"] == ids.last } + expect(other["raw"]).to eq("Second reply.") + body = response.parsed_body + expect(body["replies"].first["raw"]).to eq("Edited first reply.") + expect(body["replies"].first["id"]).to eq(ids.first) + end + + it "rejects an edit with a blank raw" do + sign_in(moderator) + ids = seed_thread + + put "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + reply_id: ids.first, + raw: " ", + } + + expect(response.status).to eq(400) + end + + it "rejects an edit for an unknown reply_id" do + sign_in(moderator) + seed_thread + + put "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + reply_id: "deadbeefdeadbeef", + raw: "Nope.", + } + + expect(response.status).to eq(400) + end + + it "forbids a regular user from editing a reply" do + sign_in(moderator) + ids = seed_thread + + sign_in(user) + put "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + reply_id: ids.first, + raw: "should not save", + } + + expect(response.status).to eq(403) + replies = topic.reload.custom_fields["mod_topic_private_note_replies"] + expect(replies.find { |r| r["id"] == ids.first }["raw"]).to eq( + "First reply.", + ) + end + + it "forbids an anonymous user from editing a reply" do + sign_in(moderator) + ids = seed_thread + + delete "/session/#{moderator.username}.json" + put "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + reply_id: ids.first, + raw: "should not save", + } + + expect(response.status).to eq(403) + end + end + + describe "DELETE /discourse-mod-categories/topic/:topic_id/note-reply" do + it "lets a moderator delete a single reply, leaving the rest intact" do + sign_in(moderator) + ids = seed_thread + + delete "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + reply_id: ids.first, + } + + expect(response.status).to eq(200) + replies = topic.reload.custom_fields["mod_topic_private_note_replies"] + expect(replies.map { |r| r["id"] }).to eq([ids.last]) + expect(replies.first["raw"]).to eq("Second reply.") + end + + it "handles an unknown reply_id" do + sign_in(moderator) + seed_thread + + delete "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + reply_id: "deadbeefdeadbeef", + } + + expect(response.status).to eq(400) + end + + it "forbids a regular user from deleting a reply" do + sign_in(moderator) + ids = seed_thread + + sign_in(user) + delete "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + reply_id: ids.first, + } + + expect(response.status).to eq(403) + expect( + topic.reload.custom_fields["mod_topic_private_note_replies"].size, + ).to eq(2) + end + end + + describe "DELETE /discourse-mod-categories/topic/:topic_id/note" do + it "lets a moderator delete the note and its replies" do + sign_in(moderator) + seed_thread + + delete "/discourse-mod-categories/topic/#{topic.id}/note.json" + + expect(response.status).to eq(200) + topic.reload + expect(topic.custom_fields["mod_topic_private_note"]).to be_blank + expect( + topic.custom_fields["mod_topic_private_note_replies"], + ).to be_blank + expect(response.parsed_body["private_note"]).to eq("") + expect(response.parsed_body["replies"]).to eq([]) + end + + it "forbids a regular user from deleting the note" do + sign_in(moderator) + seed_thread + + sign_in(user) + delete "/discourse-mod-categories/topic/#{topic.id}/note.json" + + expect(response.status).to eq(403) + expect( + topic.reload.custom_fields["mod_topic_private_note"], + ).to eq("The note body.") + end + + it "forbids an anonymous user from deleting the note" do + sign_in(moderator) + seed_thread + + delete "/session/#{moderator.username}.json" + delete "/discourse-mod-categories/topic/#{topic.id}/note.json" + + expect(response.status).to eq(403) + end + end + end + + describe "GET /discourse-mod-categories/notes-feed" do + before do + topic.custom_fields["mod_topic_private_note"] = "A note to review." + topic.custom_fields["mod_topic_private_note_activity_at"] = Time + .zone + .now + .iso8601 + topic.save_custom_fields(true) + end + + it "lists topics that have a moderator note, for staff" do + sign_in(moderator) + + get "/discourse-mod-categories/notes-feed.json" + + expect(response.status).to eq(200) + notes = response.parsed_body["notes"] + expect(notes.map { |n| n["topic_id"] }).to include(topic.id) + expect(notes.first["note"]).to eq("A note to review.") + end + + it "forbids a regular user" do + sign_in(user) + + get "/discourse-mod-categories/notes-feed.json" + + expect(response.status).to eq(403) + end + end + + describe "POST /discourse-mod-categories/notes-feed/seen" do + it "records the staff member's seen timestamp" do + sign_in(moderator) + + post "/discourse-mod-categories/notes-feed/seen.json" + + expect(response.status).to eq(200) + expect( + moderator.reload.custom_fields["mod_notes_seen_at"], + ).to be_present + end + + it "forbids a regular user" do + sign_in(user) + + post "/discourse-mod-categories/notes-feed/seen.json" + + expect(response.status).to eq(403) + end + + it "links each note to the topic's last post" do + topic.custom_fields["mod_topic_private_note"] = "Review me." + topic.save_custom_fields(true) + sign_in(moderator) + + get "/discourse-mod-categories/notes-feed.json" + + url = response.parsed_body["notes"].first["url"] + expect(url).to match(%r{/#{topic.id}/\d+\z}) + end + end + + describe "moderator-note notifications" do + def custom_notifications(user) + Notification.where( + user_id: user.id, + notification_type: Notification.types[:custom], + topic_id: topic.id, + ) + end + + it "notifies other staff when a moderator sets a note" do + sign_in(moderator) + + expect { + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + private_note: "Heads up, staff.", + } + }.to change { custom_notifications(admin).count }.by(1) + end + + it "does not notify the moderator who set the note" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + private_note: "A note.", + } + + expect(custom_notifications(moderator).count).to eq(0) + end + + it "does not notify regular users" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + private_note: "A note.", + } + + expect(custom_notifications(user).count).to eq(0) + end + + it "notifies staff when a reply is added to the note" do + sign_in(moderator) + + expect { + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "Following up.", + } + }.to change { custom_notifications(admin).count }.by(1) + end + end +end diff --git a/discourse-mod/spec/requests/mod_note_header_indicators_spec.rb b/discourse-mod/spec/requests/mod_note_header_indicators_spec.rb new file mode 100644 index 0000000..8e9ea13 --- /dev/null +++ b/discourse-mod/spec/requests/mod_note_header_indicators_spec.rb @@ -0,0 +1,169 @@ +# 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) { Fabricate(: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 do |m| + m.channel.start_with?("/mod-note-unread-count/") + end + + 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 do + post "/discourse-mod-categories/notes-feed/seen.json" + end.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/discourse-mod/spec/requests/mod_note_notifications_spec.rb b/discourse-mod/spec/requests/mod_note_notifications_spec.rb new file mode 100644 index 0000000..a9b1880 --- /dev/null +++ b/discourse-mod/spec/requests/mod_note_notifications_spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Focused coverage for the moderator-note notification fan-out: when a +# moderator sets a private note or replies to the note thread, every +# *other* staff member gets a high-priority bell notification AND a live +# pop-up alert, carrying the data the frontend renderer needs to show +# clear text and link straight to the note. Regular users and the acting +# moderator are never notified. +RSpec.describe "Moderator-note notifications" do + fab!(:admin) + fab!(:moderator) + fab!(:other_moderator) { Fabricate(:moderator) } + fab!(:user) + fab!(:other_user) { Fabricate(:user) } + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category) } + fab!(:first_post) { Fabricate(:post, topic: topic) } + + before do + SiteSetting.mod_categories_enabled = true + # The live pop-up alert is only published to staff seen recently + # (Discourse's `allow_live_notifications?` gate), so mark them present. + [admin, moderator, other_moderator].each do |u| + u.update!(last_seen_at: Time.zone.now) + end + end + + def custom_notifications(target) + Notification.where( + user_id: target.id, + notification_type: Notification.types[:custom], + topic_id: topic.id, + ) + end + + def set_note(raw = "Heads up, staff.") + put "/discourse-mod-categories/topic/#{topic.id}.json", + params: { + private_note: raw, + } + end + + describe "setting a private note" do + it "notifies every other staff member" do + sign_in(moderator) + + set_note + + expect(custom_notifications(admin).count).to eq(1) + expect(custom_notifications(other_moderator).count).to eq(1) + end + + it "does not notify the moderator who set the note" do + sign_in(moderator) + + set_note + + expect(custom_notifications(moderator).count).to eq(0) + end + + it "does not notify regular users" do + sign_in(moderator) + + set_note + + expect(custom_notifications(user).count).to eq(0) + expect(custom_notifications(other_user).count).to eq(0) + end + + it "records the acting moderator as the notification's display username" do + sign_in(moderator) + + set_note + + data = JSON.parse(custom_notifications(admin).first.data) + expect(data["display_username"]).to eq(moderator.username) + expect(data["message"]).to eq( + "discourse_mod_categories.note_notification", + ) + end + + it "creates the notification as high priority so it pops up live" do + sign_in(moderator) + + set_note + + expect(custom_notifications(admin).first.high_priority).to eq(true) + end + + it "marks the notification data so the frontend renderer recognizes it" do + sign_in(moderator) + + set_note + + data = JSON.parse(custom_notifications(admin).first.data) + expect(data["mod_note"]).to eq(true) + expect(data["topic_title"]).to eq(topic.title) + expect(data["url"]).to eq( + "#{topic.relative_url}/#{topic.reload.highest_post_number}", + ) + end + + it "publishes a live pop-up alert to every other staff member" do + sign_in(moderator) + + messages = + MessageBus.track_publish { set_note } .select do |m| + m.channel.start_with?("/notification-alert/") + end + + alerted = messages.map(&:channel) + expect(alerted).to include("/notification-alert/#{admin.id}") + expect(alerted).to include("/notification-alert/#{other_moderator.id}") + expect(alerted).not_to include("/notification-alert/#{moderator.id}") + + payload = messages.first.data + expect(payload[:post_url]).to eq( + "#{topic.relative_url}/#{topic.reload.highest_post_number}", + ) + expect(payload[:translated_title]).to include(moderator.username) + expect(payload[:translated_title]).to include(topic.title) + end + + it "links the notification to the topic's last post" do + sign_in(moderator) + + set_note + + notification = custom_notifications(admin).first + expect(notification.topic_id).to eq(topic.id) + expect(notification.post_number).to eq(topic.reload.highest_post_number) + end + + it "notifies admins the same way when an admin sets the note" do + sign_in(admin) + + set_note + + expect(custom_notifications(moderator).count).to eq(1) + expect(custom_notifications(other_moderator).count).to eq(1) + expect(custom_notifications(admin).count).to eq(0) + end + + it "fans out a fresh notification each time the note is updated" do + sign_in(moderator) + + set_note("First note.") + set_note("Updated note.") + + expect(custom_notifications(admin).count).to eq(2) + end + end + + describe "replying to a note thread" do + it "notifies other staff when a reply is added" do + sign_in(moderator) + + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "Following up.", + } + + expect(custom_notifications(admin).count).to eq(1) + expect(custom_notifications(other_moderator).count).to eq(1) + end + + it "creates a high-priority notification and a live alert on a reply" do + sign_in(moderator) + + messages = + MessageBus.track_publish do + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "Following up.", + } + end + + expect(custom_notifications(admin).first.high_priority).to eq(true) + alerted = + messages + .map(&:channel) + .select { |c| c.start_with?("/notification-alert/") } + expect(alerted).to include("/notification-alert/#{admin.id}") + end + + it "does not notify the moderator who wrote the reply" do + sign_in(other_moderator) + + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "Following up.", + } + + expect(custom_notifications(other_moderator).count).to eq(0) + end + + it "does not notify regular users on a reply" do + sign_in(moderator) + + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: "Following up.", + } + + expect(custom_notifications(user).count).to eq(0) + end + + it "does not notify on a rejected blank reply" do + sign_in(moderator) + + expect { + post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json", + params: { + raw: " ", + } + }.not_to change { custom_notifications(admin).count } + end + end +end diff --git a/discourse-mod/spec/requests/mod_serialization_spec.rb b/discourse-mod/spec/requests/mod_serialization_spec.rb new file mode 100644 index 0000000..ac87f08 --- /dev/null +++ b/discourse-mod/spec/requests/mod_serialization_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Verifies the moderator-message custom fields are serialized so the +# frontend can read them: topic fields on the topic view, the category +# prompt on serialized categories. +RSpec.describe "Moderator messages serialization" do + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category) } + fab!(:post) { Fabricate(:post, topic: topic) } + fab!(:moderator) + fab!(:user) + + before { SiteSetting.mod_categories_enabled = true } + + it "exposes the topic footer message, reply prompt and pinned post id" do + topic.custom_fields["mod_topic_footer_message"] = "Footer here" + topic.custom_fields["mod_topic_reply_prompt"] = "Reply prompt here" + 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) + json = response.parsed_body + expect(json["mod_topic_footer_message"]).to eq("Footer here") + expect(json["mod_topic_reply_prompt"]).to eq("Reply prompt here") + expect(json["mod_topic_pinned_post_id"]).to eq(post.id) + end + + it "leaves the topic fields nil when nothing has been set" do + get "/t/#{topic.id}.json" + + expect(response.status).to eq(200) + json = response.parsed_body + 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 + end + + it "exposes the category new-topic prompt in the categories list" do + category.custom_fields["mod_category_new_topic_prompt"] = "Category prompt" + category.save_custom_fields(true) + + get "/categories.json" + + expect(response.status).to eq(200) + categories = response.parsed_body["category_list"]["categories"] + target = categories.find { |c| c["id"] == category.id } + expect(target["mod_category_new_topic_prompt"]).to eq("Category prompt") + end + + context "with a private moderator note set" do + before do + topic.custom_fields["mod_topic_private_note"] = "Staff eyes only" + topic.custom_fields["mod_topic_private_note_position"] = "top" + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.save_custom_fields(true) + end + + it "includes the note author (username and avatar) for staff" do + sign_in(moderator) + + get "/t/#{topic.id}.json" + + expect(response.status).to eq(200) + author = response.parsed_body["mod_topic_private_note_author"] + expect(author).to be_present + expect(author["username"]).to eq(moderator.username) + expect(author["avatar_template"]).to be_present + end + + it "hides the note author from regular users" do + sign_in(user) + + get "/t/#{topic.id}.json" + + expect(response.parsed_body).not_to have_key( + "mod_topic_private_note_author", + ) + end + + it "exposes the private note to staff" do + sign_in(moderator) + + get "/t/#{topic.id}.json" + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["mod_topic_private_note"]).to eq("Staff eyes only") + expect(json["mod_topic_private_note_position"]).to eq("top") + end + + it "hides the private note from regular users" do + sign_in(user) + + get "/t/#{topic.id}.json" + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json).not_to have_key("mod_topic_private_note") + expect(json).not_to have_key("mod_topic_private_note_position") + end + + it "hides the private note from anonymous users" do + get "/t/#{topic.id}.json" + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json).not_to have_key("mod_topic_private_note") + end + end + + describe "moderator-note unread count" do + 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) + sign_in(moderator) + + get "/session/current.json" + + expect(response.status).to eq(200) + count = + response.parsed_body["current_user"]["mod_note_unread_count"] + 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) + sign_in(moderator) + + get "/session/current.json" + + expect( + response.parsed_body["current_user"]["mod_note_unread_count"], + ).to eq(0) + end + end +end diff --git a/discourse-mod/spec/requests/moderator_category_management_spec.rb b/discourse-mod/spec/requests/moderator_category_management_spec.rb new file mode 100644 index 0000000..84d52c4 --- /dev/null +++ b/discourse-mod/spec/requests/moderator_category_management_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +# End-to-end coverage for the moderator category-management feature. Hits the +# actual /categories.json endpoints as a moderator and verifies the plugin's +# Guardian overrides correctly grant create/edit/delete permissions. +RSpec.describe "Category management for moderators", type: :request do + fab!(:moderator) + fab!(:user) + fab!(:category) + + before { SiteSetting.mod_categories_enabled = true } + + describe "POST /categories.json" do + it "lets a moderator create a top-level category" do + sign_in(moderator) + expect { + post "/categories.json", + params: { + name: "moderator-created-top-level", + color: "ff0000", + text_color: "ffffff", + } + }.to change { Category.count }.by(1) + expect(response.status).to eq(200) + end + + it "lets a moderator create a subcategory under any category" do + sign_in(moderator) + expect { + post "/categories.json", + params: { + name: "moderator-subcategory", + color: "00ff00", + text_color: "ffffff", + parent_category_id: category.id, + } + }.to change { Category.count }.by(1) + expect(response.status).to eq(200) + expect(Category.last.parent_category_id).to eq(category.id) + end + + it "blocks a regular user from creating a category" do + sign_in(user) + expect { + post "/categories.json", + params: { + name: "should-not-exist", + color: "0000ff", + text_color: "ffffff", + } + }.not_to change { Category.count } + expect(response.status).to eq(403) + end + end + + describe "PUT /categories/:id.json" do + it "lets a moderator edit any category" do + sign_in(moderator) + put "/categories/#{category.id}.json", + params: { + name: "renamed-by-moderator", + color: category.color, + text_color: category.text_color, + } + expect(response.status).to eq(200) + expect(category.reload.name).to eq("renamed-by-moderator") + end + + it "blocks a regular user from editing a category" do + sign_in(user) + original_name = category.name + put "/categories/#{category.id}.json", + params: { + name: "should-not-rename", + color: category.color, + text_color: category.text_color, + } + expect(response.status).to eq(403) + expect(category.reload.name).to eq(original_name) + end + end + + describe "DELETE /categories/:id.json" do + it "lets a moderator delete an empty category" do + sign_in(moderator) + empty = Fabricate(:category) + expect { delete "/categories/#{empty.id}.json" }.to change { + Category.where(id: empty.id).count + }.from(1).to(0) + expect(response.status).to eq(200) + end + + it "blocks a regular user from deleting a category" do + sign_in(user) + delete "/categories/#{category.id}.json" + expect(response.status).to eq(403) + expect(Category.where(id: category.id)).to exist + end + end +end diff --git a/discourse-mod/spec/requests/topic_prompt_checklist_spec.rb b/discourse-mod/spec/requests/topic_prompt_checklist_spec.rb new file mode 100644 index 0000000..799696c --- /dev/null +++ b/discourse-mod/spec/requests/topic_prompt_checklist_spec.rb @@ -0,0 +1,582 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Covers the per-topic prompt checklist: staff CRUD the checklist on the +# topic admin's "Prompt Checklist" entry, and the owed endpoint factors +# the per-topic checklist in when a topic_id is supplied. Acceptance is +# recorded per-topic-per-user with a version, and a staff edit bumps the +# version so previously-accepted users are re-prompted. +RSpec.describe "Per-topic prompt checklist" do + fab!(:admin) + fab!(:moderator) + fab!(:user) { Fabricate(:user, trust_level: TrustLevel[2]) } + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category) } + fab!(:other_topic) { Fabricate(:topic, category: category) } + + let(:topic_field) { DiscourseModCategories::TOPIC_PROMPT_CHECKLIST_FIELD } + let(:user_field) { DiscourseModCategories::USER_TOPIC_CHECKLIST_FIELD } + + before { SiteSetting.mod_categories_enabled = true } + + def get_checklist(t) + raw = t.reload.custom_fields[topic_field] + raw.is_a?(Hash) ? raw : nil + end + + describe "GET /topic/:topic_id/prompt-checklist" do + it "returns the current checklist to a moderator" do + topic.custom_fields[topic_field] = { + "version" => 2, + "items" => [{ "label" => "Read the rules", "url" => "" }], + "button_label" => "Agree", + "updated_at" => "2026-01-01T00:00:00Z", + } + topic.save_custom_fields(true) + sign_in(moderator) + + get "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["version"]).to eq(2) + expect(response.parsed_body["items"].first["label"]).to eq("Read the rules") + expect(response.parsed_body["button_label"]).to eq("Agree") + end + + it "returns an empty checklist when none is set" do + sign_in(admin) + get "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json" + expect(response.status).to eq(200) + expect(response.parsed_body["version"]).to eq(0) + expect(response.parsed_body["items"]).to eq([]) + end + + it "forbids a regular user" do + sign_in(user) + get "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json" + expect(response.status).to eq(403) + end + + it "404s for an unknown topic" do + sign_in(moderator) + get "/discourse-mod-categories/topic/999999/prompt-checklist.json" + expect(response.status).to eq(404) + end + end + + describe "PUT /topic/:topic_id/prompt-checklist" do + it "lets a moderator save the checklist and bumps the version each save" do + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json", + params: { + items: [ + { label: "Read the topic rules", url: "https://example.com" }, + { label: "Be kind", url: "" }, + ], + button_label: "I understand", + } + + expect(response.status).to eq(200) + expect(response.parsed_body["version"]).to eq(1) + expect(response.parsed_body["items"].size).to eq(2) + expect(response.parsed_body["button_label"]).to eq("I understand") + expect(response.parsed_body["updated_at"]).to be_present + + first_at = response.parsed_body["updated_at"] + + put "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json", + params: { items: [{ label: "One", url: "" }] } + expect(response.parsed_body["version"]).to eq(2) + expect(response.parsed_body["updated_at"]).to be_present + expect(response.parsed_body["updated_at"] >= first_at).to eq(true) + end + + it "drops rows whose label is blank" do + sign_in(moderator) + put "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json", + params: { + items: [ + { label: "Keep me", url: "" }, + { label: " ", url: "https://example.com" }, + { label: "", url: "" }, + ], + } + expect(response.parsed_body["items"].map { |i| i["label"] }).to eq( + ["Keep me"], + ) + end + + it "accepts the index-keyed-hash shape a browser form-encodes" do + sign_in(moderator) + put "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json", + params: { + items: { + "0" => { label: "First", url: "" }, + "1" => { label: "Second", url: "" }, + }, + } + expect(response.parsed_body["items"].map { |i| i["label"] }).to eq( + %w[First Second], + ) + end + + it "forbids a regular user" do + sign_in(user) + put "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json", + params: { items: [{ label: "x", url: "" }] } + expect(response.status).to eq(403) + expect(get_checklist(topic)).to be_nil + end + end + + describe "DELETE /topic/:topic_id/prompt-checklist" do + it "clears the checklist" do + topic.custom_fields[topic_field] = { + "version" => 1, + "items" => [{ "label" => "x", "url" => "" }], + } + topic.save_custom_fields(true) + sign_in(moderator) + + delete "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json" + + expect(response.status).to eq(200) + expect(response.parsed_body["version"]).to eq(0) + expect(response.parsed_body["items"]).to eq([]) + expect(get_checklist(topic)).to be_nil + end + + it "forbids a regular user" do + sign_in(user) + delete "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json" + expect(response.status).to eq(403) + end + end + + describe "GET /checklist/owed?topic_id=..." do + before do + topic.custom_fields[topic_field] = { + "version" => 1, + "items" => [{ "label" => "Confirm this topic's rule", "url" => "" }], + "button_label" => "Agree", + "updated_at" => "2026-01-01T00:00:00Z", + } + topic.save_custom_fields(true) + end + + it "returns the per-topic checklist for a user who has not accepted" do + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + + expect(response.status).to eq(200) + checklist = response.parsed_body["checklist"] + expect(checklist).to be_present + expect(checklist["kind"]).to eq("topic") + expect(checklist["id"]).to eq(topic.id) + expect(checklist["version"]).to eq(1) + expect(checklist["items"].first["label"]).to eq( + "Confirm this topic's rule", + ) + end + + it "returns null after the user accepts the current version" do + user.custom_fields[user_field] = { topic.id.to_s => 1 } + user.save_custom_fields(true) + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + expect(response.parsed_body["checklist"]).to be_nil + end + + it "returns the new payload after a version bump" do + user.custom_fields[user_field] = { topic.id.to_s => 1 } + user.save_custom_fields(true) + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + expect(response.parsed_body["checklist"]).to be_nil + + # Staff bump. + topic.custom_fields[topic_field] = { + "version" => 2, + "items" => [{ "label" => "Updated rule", "url" => "" }], + } + topic.save_custom_fields(true) + + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + expect(response.parsed_body["checklist"]["version"]).to eq(2) + end + + it "applies to staff too" do + sign_in(moderator) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + checklist = response.parsed_body["checklist"] + expect(checklist).to be_present + expect(checklist["kind"]).to eq("topic") + end + + it "ignores per-topic checklist when topic_id is absent" do + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json" + expect(response.parsed_body["checklist"]).to be_nil + end + + it "ignores the checklist for OTHER topics" do + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{other_topic.id}" + expect(response.parsed_body["checklist"]).to be_nil + end + + it "prefers a targeted checklist over the per-topic one" do + PluginStore.set( + DiscourseModCategories::CHECKLIST_STORE_NAMESPACE, + DiscourseModCategories::TARGETED_CHECKLISTS_KEY, + [ + { + "id" => "tgt1", + "name" => "Picked", + "user_ids" => [user.id], + "items" => [{ "label" => "Targeted item", "url" => "" }], + "version" => 1, + "button_label" => "", + }, + ], + ) + + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + checklist = response.parsed_body["checklist"] + expect(checklist["kind"]).to eq("targeted") + expect(checklist["id"]).to eq("tgt1") + end + + it "prefers the per-topic checklist over the global one" do + PluginStore.set( + DiscourseModCategories::CHECKLIST_STORE_NAMESPACE, + DiscourseModCategories::CHECKLIST_STORE_KEY, + { + "version" => 1, + "items" => [{ "label" => "Global", "url" => "" }], + }, + ) + + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + checklist = response.parsed_body["checklist"] + expect(checklist["kind"]).to eq("topic") + end + end + + describe "POST /checklist/accept with kind 'topic'" do + before do + topic.custom_fields[topic_field] = { + "version" => 2, + "items" => [{ "label" => "x", "url" => "" }], + } + topic.save_custom_fields(true) + end + + it "records the accepted version per-topic-per-user" do + sign_in(user) + post "/discourse-mod-categories/checklist/accept.json", + params: { kind: "topic", id: topic.id, version: 2 } + + expect(response.status).to eq(200) + map = user.reload.custom_fields[user_field] + expect(map[topic.id.to_s]).to eq(2) + # Other stores untouched. + expect( + user.custom_fields[ + DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + ], + ).to be_nil + end + + it "clamps the accepted version to the published version" do + sign_in(user) + post "/discourse-mod-categories/checklist/accept.json", + params: { kind: "topic", id: topic.id, version: 99 } + expect(user.reload.custom_fields[user_field][topic.id.to_s]).to eq(2) + end + + it "404s when the topic has no checklist" do + sign_in(user) + post "/discourse-mod-categories/checklist/accept.json", + params: { kind: "topic", id: other_topic.id, version: 1 } + expect(response.status).to eq(404) + end + end + + describe "topic_view serializer" do + it "exposes the per-topic checklist on the topic" do + topic.custom_fields[topic_field] = { + "version" => 1, + "items" => [{ "label" => "x", "url" => "" }], + "button_label" => "OK", + "updated_at" => "2026-01-01T00:00:00Z", + } + topic.save_custom_fields(true) + + sign_in(user) + get "/t/#{topic.slug}/#{topic.id}.json" + + expect(response.status).to eq(200) + checklist = response.parsed_body["mod_topic_prompt_checklist"] + expect(checklist).to be_present + expect(checklist["version"]).to eq(1) + expect(checklist["items"].first["label"]).to eq("x") + # New fields default sensibly when the topic predates them. + expect(checklist["mode"]).to eq("checklist") + expect(checklist["frequency"]).to eq("once") + expect(checklist["max_tl"]).to eq(4) + end + + it "exposes a statement-mode checklist with its statement text" do + topic.custom_fields[topic_field] = { + "version" => 3, + "mode" => "statement", + "statement" => "Please confirm you have read the rules.", + "items" => [], + "frequency" => "every_reply", + "max_tl" => 2, + "button_label" => "I confirm", + "updated_at" => "2026-01-01T00:00:00Z", + } + topic.save_custom_fields(true) + + sign_in(user) + get "/t/#{topic.slug}/#{topic.id}.json" + checklist = response.parsed_body["mod_topic_prompt_checklist"] + expect(checklist).to be_present + expect(checklist["mode"]).to eq("statement") + expect(checklist["statement"]).to eq( + "Please confirm you have read the rules.", + ) + expect(checklist["frequency"]).to eq("every_reply") + expect(checklist["max_tl"]).to eq(2) + end + + it "exposes null when no checklist is set" do + sign_in(user) + get "/t/#{topic.slug}/#{topic.id}.json" + expect(response.parsed_body["mod_topic_prompt_checklist"]).to be_nil + end + end + + describe "statement mode" do + it "stores mode/statement/frequency/max_tl on save and bumps the version" do + sign_in(moderator) + put "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json", + params: { + mode: "statement", + statement: "Read the rules then post.", + frequency: "every_reply", + max_tl: 1, + button_label: "I agree", + } + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["mode"]).to eq("statement") + expect(body["statement"]).to eq("Read the rules then post.") + expect(body["frequency"]).to eq("every_reply") + expect(body["max_tl"]).to eq(1) + expect(body["version"]).to eq(1) + + stored = topic.reload.custom_fields[topic_field] + expect(stored["mode"]).to eq("statement") + expect(stored["statement"]).to eq("Read the rules then post.") + expect(stored["frequency"]).to eq("every_reply") + expect(stored["max_tl"]).to eq(1) + end + + it "returns the statement payload on the owed endpoint with no checkboxes" do + topic.custom_fields[topic_field] = { + "version" => 1, + "mode" => "statement", + "statement" => "Confirm you have read.", + "items" => [], + "frequency" => "once", + "max_tl" => 4, + "button_label" => "OK", + } + topic.save_custom_fields(true) + + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + checklist = response.parsed_body["checklist"] + expect(checklist).to be_present + expect(checklist["kind"]).to eq("topic") + expect(checklist["mode"]).to eq("statement") + expect(checklist["statement"]).to eq("Confirm you have read.") + end + + it "treats a blank statement (statement mode) as inactive" do + topic.custom_fields[topic_field] = { + "version" => 1, + "mode" => "statement", + "statement" => " ", + "items" => [], + "frequency" => "once", + "max_tl" => 4, + } + topic.save_custom_fields(true) + + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + expect(response.parsed_body["checklist"]).to be_nil + end + + it "owed_checklist_for returns the full statement payload with no items" do + topic.custom_fields[topic_field] = { + "version" => 5, + "mode" => "statement", + "statement" => "Please confirm you have read.", + "items" => [], + "frequency" => "once", + "max_tl" => 4, + "button_label" => "I confirm", + "updated_at" => "2026-02-02T00:00:00Z", + } + topic.save_custom_fields(true) + + payload = + DiscourseModCategories.owed_checklist_for(user, topic_id: topic.id) + expect(payload).to be_present + expect(payload[:kind]).to eq("topic") + expect(payload[:id]).to eq(topic.id) + expect(payload[:version]).to eq(5) + expect(payload[:mode]).to eq("statement") + expect(payload[:statement]).to eq("Please confirm you have read.") + expect(payload[:items]).to eq([]) + expect(payload[:button_label]).to eq("I confirm") + expect(payload[:updated_at]).to eq("2026-02-02T00:00:00Z") + end + end + + describe "frequency: every_reply" do + it "always returns the per-topic checklist, even after acceptance" do + topic.custom_fields[topic_field] = { + "version" => 1, + "mode" => "checklist", + "items" => [{ "label" => "x", "url" => "" }], + "frequency" => "every_reply", + "max_tl" => 4, + } + topic.save_custom_fields(true) + user.custom_fields[user_field] = { topic.id.to_s => 1 } + user.save_custom_fields(true) + + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + checklist = response.parsed_body["checklist"] + expect(checklist).to be_present + expect(checklist["frequency"]).to eq("every_reply") + end + end + + describe "max_tl cap" do + before do + topic.custom_fields[topic_field] = { + "version" => 1, + "mode" => "checklist", + "items" => [{ "label" => "x", "url" => "" }], + "frequency" => "once", + "max_tl" => 1, + } + topic.save_custom_fields(true) + end + + it "filters out a TL2 non-staff user" do + sign_in(user) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + expect(response.parsed_body["checklist"]).to be_nil + end + + it "still shows the checklist to staff regardless of cap" do + sign_in(moderator) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + checklist = response.parsed_body["checklist"] + expect(checklist).to be_present + end + + it "still shows the checklist to a TL0 user when the cap is TL1" do + tl0 = Fabricate(:user, trust_level: TrustLevel[0]) + sign_in(tl0) + get "/discourse-mod-categories/checklist/owed.json?topic_id=#{topic.id}" + expect(response.parsed_body["checklist"]).to be_present + end + end + + describe "legacy reply-prompt migration" do + it "pre-fills the editor seed in statement mode from the legacy fields" do + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD + ] = "Legacy reply prompt text." + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_TL_FIELD + ] = 1 + topic.save_custom_fields(true) + sign_in(moderator) + + get "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json" + body = response.parsed_body + expect(body["mode"]).to eq("statement") + expect(body["statement"]).to eq("Legacy reply prompt text.") + expect(body["max_tl"]).to eq(1) + expect(body["from_legacy"]).to eq(true) + end + + it "saving the editor migrates: legacy fields cleared, new config wins" do + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD + ] = "Legacy reply prompt text." + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_TL_FIELD + ] = 1 + topic.save_custom_fields(true) + sign_in(moderator) + + put "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json", + params: { + mode: "statement", + statement: "Legacy reply prompt text.", + max_tl: 1, + } + + expect(response.status).to eq(200) + topic.reload + stored = topic.custom_fields[topic_field] + expect(stored["mode"]).to eq("statement") + expect(stored["statement"]).to eq("Legacy reply prompt text.") + expect( + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD + ], + ).to be_blank + expect( + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_TL_FIELD + ], + ).to be_blank + end + + it "does not flag from_legacy when the new config already exists" do + topic.custom_fields[topic_field] = { + "version" => 1, + "mode" => "checklist", + "items" => [{ "label" => "x", "url" => "" }], + "frequency" => "once", + "max_tl" => 4, + } + topic.custom_fields[ + DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD + ] = "Legacy still around." + topic.save_custom_fields(true) + sign_in(moderator) + + get "/discourse-mod-categories/topic/#{topic.id}/prompt-checklist.json" + expect(response.parsed_body["from_legacy"]).to eq(false) + expect(response.parsed_body["mode"]).to eq("checklist") + end + end +end diff --git a/discourse-mod/spec/requests/whisper_add_participant_spec.rb b/discourse-mod/spec/requests/whisper_add_participant_spec.rb new file mode 100644 index 0000000..fd9c83e --- /dev/null +++ b/discourse-mod/spec/requests/whisper_add_participant_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Exercises POST /discourse-mod-categories/topic/:topic_id/whisper-participant: +# staff add a user to a topic's whisper conversation, non-staff are forbidden, +# adding the same user twice does not duplicate, and an added user can then +# see an existing whisper post in that topic. +RSpec.describe "Whisper add participant" do + fab!(:admin) + fab!(:moderator) + fab!(:author, :user) + fab!(:newcomer, :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(:participants_field) do + DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD + end + let(:url) do + "/discourse-mod-categories/topic/#{topic.id}/whisper-participant.json" + end + + before do + SiteSetting.mod_categories_enabled = true + SiteSetting.mod_whisper_enabled = true + + whisper_post.custom_fields[targets_field] = [] + whisper_post.save_custom_fields(true) + end + + def participant_ids + Array(topic.reload.custom_fields[participants_field]).map(&:to_i) + end + + it "lets a moderator add a user to the whisper conversation" do + sign_in(moderator) + + post url, params: { username: newcomer.username } + + expect(response.status).to eq(200) + expect(response.parsed_body["participant_ids"]).to include(newcomer.id) + expect(participant_ids).to include(newcomer.id) + end + + it "lets an admin add a user by user_id" do + sign_in(admin) + + post url, params: { user_id: newcomer.id } + + expect(response.status).to eq(200) + expect(participant_ids).to include(newcomer.id) + end + + it "forbids a regular user" do + sign_in(stranger) + + post url, params: { username: newcomer.username } + + expect(response.status).to eq(403) + expect(participant_ids).not_to include(newcomer.id) + end + + it "forbids an anonymous user" do + post url, params: { username: newcomer.username } + + expect(response.status).to eq(403) + end + + it "does not duplicate when the same user is added twice" do + sign_in(moderator) + + post url, params: { username: newcomer.username } + expect(response.status).to eq(200) + post url, params: { username: newcomer.username } + expect(response.status).to eq(200) + + expect(participant_ids.count(newcomer.id)).to eq(1) + end + + it "404s for an unknown username" do + sign_in(moderator) + + post url, params: { username: "does-not-exist" } + + expect(response.status).to eq(400) + end + + it "404s when whispers are disabled" do + SiteSetting.mod_whisper_enabled = false + sign_in(moderator) + + post url, params: { username: newcomer.username } + + expect(response.status).to eq(404) + end + + it "lets the added user see an existing whisper post in the topic" do + expect(Guardian.new(newcomer).can_see_post?(whisper_post)).to eq(false) + + sign_in(moderator) + post url, params: { username: newcomer.username } + expect(response.status).to eq(200) + + expect( + Guardian.new(newcomer).can_see_post?(whisper_post.reload), + ).to eq(true) + + sign_in(newcomer) + get "/t/#{topic.id}.json" + expect(response.status).to eq(200) + ids = response.parsed_body["post_stream"]["posts"].map { |p| p["id"] } + expect(ids).to include(whisper_post.id) + end + + it "notifies the added user" do + sign_in(moderator) + + expect { + post url, params: { username: newcomer.username } + }.to change { + Notification.where(user_id: newcomer.id).count + }.by(1) + end +end diff --git a/discourse-mod/spec/requests/whisper_create_spec.rb b/discourse-mod/spec/requests/whisper_create_spec.rb new file mode 100644 index 0000000..daaafe9 --- /dev/null +++ b/discourse-mod/spec/requests/whisper_create_spec.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Exercises whisper post creation through POST /posts: staff targeted +# whispers, staff-only whispers, non-staff whisper-backs, and the +# non-participant gate. A whisper is created only when the explicit +# `mod_whisper` armed flag is sent — the target count never implies it. +RSpec.describe "Whisper creation" do + fab!(:admin) + fab!(:moderator) + fab!(:author, :user) + fab!(:target, :user) + fab!(:participant, :user) + fab!(:stranger, :user) + fab!(:topic) + fab!(:op) { Fabricate(:post, topic: topic, user: author) } + fab!(:whisper_group) { Fabricate(:group, name: "whisper_squad") } + + let(:targets_field) { DiscourseModCategories::POST_WHISPER_TARGETS_FIELD } + let(:groups_field) do + DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD + end + let(:armed_param) { DiscourseModCategories::POST_WHISPER_ARMED_PARAM } + let(:participants_field) do + DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD + end + + 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 + # Keep low-trust-level users out of the review queue so their posts are + # created directly. + Group.refresh_automatic_groups! + SiteSetting.approve_unless_allowed_groups = + Group::AUTO_GROUPS[:trust_level_0].to_s + end + + def create_post_for(user, params) + sign_in(user) + post "/posts.json", + params: { + topic_id: topic.id, + raw: "This is a whisper reply body long enough to be valid.", + }.merge(params) + end + + describe "staff-authored targeted whisper" do + it "marks the post and records the non-staff target as a participant" do + create_post_for( + moderator, + { armed_param => true, targets_field => [target.id, participant.id] }, + ) + expect(response.status).to eq(200) + + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields.key?(targets_field)).to eq(true) + expect(created.custom_fields[targets_field].map(&:to_i)).to match_array( + [target.id, participant.id], + ) + + topic.reload + expect( + Array(topic.custom_fields[participants_field]).map(&:to_i), + ).to match_array([target.id, participant.id]) + end + + it "notifies the chosen targets" do + Jobs.run_immediately! + target_baseline = Notification.where(user_id: target.id).count + + create_post_for( + moderator, + { armed_param => true, targets_field => [target.id] }, + ) + expect(response.status).to eq(200) + + expect(Notification.where(user_id: target.id).count).to( + be > target_baseline, + ) + end + + it "creates a normal post when the whisper flag is not armed" do + create_post_for(moderator, {}) + expect(response.status).to eq(200) + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields.key?(targets_field)).to eq(false) + end + + # Regression: a staff member arming a whisper with NO targets selected + # intends a staff-only whisper. The empty-array case must not be dropped. + it "creates a staff-only whisper when armed with no targets param" do + create_post_for(moderator, { armed_param => true }) + expect(response.status).to eq(200) + + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields.key?(targets_field)).to eq(true) + expect(created.custom_fields[targets_field]).to eq([]) + end + + it "creates a staff-only whisper when armed with an empty targets array" do + create_post_for( + moderator, + { armed_param => true, targets_field => [] }, + ) + expect(response.status).to eq(200) + + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields.key?(targets_field)).to eq(true) + expect(created.custom_fields[targets_field]).to eq([]) + end + end + + describe "staff-authored group whisper" do + it "validates and stores the submitted group ids" do + create_post_for( + moderator, + { armed_param => true, groups_field => [whisper_group.id] }, + ) + expect(response.status).to eq(200) + + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields.key?(targets_field)).to eq(true) + expect(created.custom_fields[groups_field].map(&:to_i)).to eq( + [whisper_group.id], + ) + end + + it "drops group ids that do not map to a real group" do + create_post_for( + moderator, + { armed_param => true, groups_field => [whisper_group.id, 999_999] }, + ) + expect(response.status).to eq(200) + + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields[groups_field].map(&:to_i)).to eq( + [whisper_group.id], + ) + end + + it "supports a whisper carrying both user and group targets" do + create_post_for( + moderator, + { + armed_param => true, + targets_field => [target.id], + groups_field => [whisper_group.id], + }, + ) + expect(response.status).to eq(200) + + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields[targets_field].map(&:to_i)).to eq( + [target.id], + ) + expect(created.custom_fields[groups_field].map(&:to_i)).to eq( + [whisper_group.id], + ) + end + end + + describe "non-staff whisper-back" do + before do + topic.custom_fields[participants_field] = [participant.id] + topic.save_custom_fields(true) + end + + it "forces empty (staff-only) user and group lists for a participant" do + create_post_for( + participant, + { + armed_param => true, + targets_field => [stranger.id], + groups_field => [whisper_group.id], + }, + ) + expect(response.status).to eq(200) + + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields.key?(targets_field)).to eq(true) + expect(created.custom_fields[targets_field]).to eq([]) + expect(created.custom_fields[groups_field]).to eq([]) + end + + it "notifies all staff on a whisper-back" do + Jobs.run_immediately! + admin_baseline = Notification.where(user_id: admin.id).count + + create_post_for( + participant, + { armed_param => true, targets_field => [] }, + ) + expect(response.status).to eq(200) + + expect(Notification.where(user_id: admin.id).count).to( + be > admin_baseline, + ) + end + + # Regression: a non-staff topic whisper participant posting a NORMAL + # reply (no whisper armed) must produce a plain public post — whisper-ness + # is no longer inferred from participant membership. + it "creates a normal public post when the whisper flag is not armed" do + create_post_for(participant, {}) + expect(response.status).to eq(200) + + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields.key?(targets_field)).to eq(false) + end + end + + describe "non-participant non-staff user" do + it "creates a plain post even when the whisper flag is armed" do + create_post_for( + stranger, + { armed_param => true, targets_field => [target.id] }, + ) + expect(response.status).to eq(200) + + created = Post.find(response.parsed_body["id"]) + expect(created.custom_fields.key?(targets_field)).to eq(false) + end + end +end diff --git a/discourse-mod/spec/requests/whisper_serialization_spec.rb b/discourse-mod/spec/requests/whisper_serialization_spec.rb new file mode 100644 index 0000000..6d9514c --- /dev/null +++ b/discourse-mod/spec/requests/whisper_serialization_spec.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Verifies whisper posts are serialized into the topic stream only for the +# audience, and that the SQL query filter agrees with Guardian#can_see_post?. +RSpec.describe "Whisper serialization" do + fab!(:admin) + fab!(:moderator) + fab!(:author, :user) + fab!(:target, :user) + fab!(:participant, :user) + fab!(:stranger, :user) + fab!(:topic) + fab!(:op) { Fabricate(:post, topic: topic, user: author) } + fab!(:whisper_post) { Fabricate(:post, topic: topic, user: author) } + fab!(:normal_post) { Fabricate(:post, topic: topic, user: stranger) } + fab!(:group_member, :user) + fab!(:whisper_group) { Fabricate(:group, name: "whisper_squad") } + + let(:targets_field) { DiscourseModCategories::POST_WHISPER_TARGETS_FIELD } + let(:groups_field) do + DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD + end + let(:participants_field) do + DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD + end + + 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! + + 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 + + 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 + + it "shows the whisper to the author" do + sign_in(author) + expect(stream_post_ids).to include(whisper_post.id) + end + + it "shows the whisper to a target" do + sign_in(target) + expect(stream_post_ids).to include(whisper_post.id) + end + + it "shows the whisper to a cumulative topic participant" do + sign_in(participant) + expect(stream_post_ids).to include(whisper_post.id) + end + + it "shows the whisper to staff" do + sign_in(admin) + expect(stream_post_ids).to include(whisper_post.id) + sign_in(moderator) + expect(stream_post_ids).to include(whisper_post.id) + end + + it "hides the whisper from a stranger" do + sign_in(stranger) + ids = stream_post_ids + expect(ids).not_to include(whisper_post.id) + expect(ids).to include(normal_post.id) + end + + it "hides the whisper from an anonymous viewer" do + expect(stream_post_ids).not_to include(whisper_post.id) + end + + it "serializes whisper attributes for a viewer who can see it" do + sign_in(target) + get "/t/#{topic.id}.json" + post_json = + response.parsed_body["post_stream"]["posts"].find do |p| + p["id"] == whisper_post.id + end + expect(post_json["mod_is_whisper"]).to eq(true) + expect(post_json["mod_whisper_target_user_ids"]).to eq([target.id]) + expect(post_json["mod_whisper_targets"].map { |t| t["id"] }).to eq( + [target.id], + ) + expect(post_json["mod_whisper_is_staff_only"]).to eq(false) + expect(post_json["mod_whisper_author_is_staff"]).to eq(false) + end + + it "marks an empty-target whisper as staff-only" do + whisper_post.custom_fields[targets_field] = [] + whisper_post.save_custom_fields(true) + + sign_in(admin) + get "/t/#{topic.id}.json" + post_json = + response.parsed_body["post_stream"]["posts"].find do |p| + p["id"] == whisper_post.id + end + expect(post_json["mod_is_whisper"]).to eq(true) + expect(post_json["mod_whisper_is_staff_only"]).to eq(true) + end + + it "exposes the topic whisper participant ids on the topic view" do + sign_in(admin) + get "/t/#{topic.id}.json" + expect(response.parsed_body["mod_whisper_participant_ids"]).to match_array( + [target.id, participant.id], + ) + end + + describe "audience visibility" do + # A whisper IS visible to every member of its audience: an explicit + # target, a cumulative topic participant, and any staff member. Asserted + # both via Guardian#can_see_post? and the topic-view JSON. + [:target, :participant, :admin, :moderator, :author].each do |persona| + it "is visible to #{persona} via Guardian and topic-view JSON" do + user = send(persona) + expect( + Guardian.new(user).can_see_post?(whisper_post.reload), + ).to eq(true) + + sign_in(user) + expect(stream_post_ids).to include(whisper_post.id) + end + end + + # A whisper is NOT visible to a non-audience user. + it "is not visible to a stranger via Guardian or topic-view JSON" do + expect( + Guardian.new(stranger).can_see_post?(whisper_post.reload), + ).to eq(false) + + sign_in(stranger) + expect(stream_post_ids).not_to include(whisper_post.id) + end + end + + describe "group-targeted whisper" do + before 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) + end + + it "shows the whisper to a member of the target group" do + expect( + Guardian.new(group_member).can_see_post?(whisper_post.reload), + ).to eq(true) + + sign_in(group_member) + expect(stream_post_ids).to include(whisper_post.id) + end + + it "hides the whisper from a non-member non-staff user" do + expect( + Guardian.new(stranger).can_see_post?(whisper_post.reload), + ).to eq(false) + + sign_in(stranger) + expect(stream_post_ids).not_to include(whisper_post.id) + end + + it "still shows the whisper to staff for oversight" do + sign_in(admin) + expect(stream_post_ids).to include(whisper_post.id) + end + + it "serializes the target group on the post" do + sign_in(group_member) + get "/t/#{topic.id}.json" + post_json = + response.parsed_body["post_stream"]["posts"].find do |p| + p["id"] == whisper_post.id + end + expect(post_json["mod_whisper_target_group_ids"]).to eq( + [whisper_group.id], + ) + expect(post_json["mod_whisper_target_groups"]).to eq( + [{ "id" => whisper_group.id, "name" => whisper_group.name }], + ) + # A group-targeted whisper is NOT staff-only. + expect(post_json["mod_whisper_is_staff_only"]).to eq(false) + end + + it "keeps Guardian and QueryFilter in parity across personas" do + [nil, author, target, participant, stranger, group_member, admin].each do |user| + guardian_visible = + Guardian.new(user).can_see_post?(whisper_post.reload) + sql_visible = + DiscourseModCategories::WhisperQueryFilter + .apply(Post.where(id: whisper_post.id), user) + .exists? + expect(sql_visible).to eq(guardian_visible), + "QueryFilter (#{sql_visible}) disagrees with Guardian " \ + "(#{guardian_visible}) for user #{user&.username || "anonymous"}" + end + end + end + + describe "Guardian <-> QueryFilter parity" do + [nil, :author, :target, :participant, :stranger, :admin, :moderator].each do |persona| + it "agrees for persona=#{persona || "anonymous"}" do + user = + case persona + when nil + nil + else + send(persona) + end + + guardian_visible = Guardian.new(user).can_see_post?(whisper_post.reload) + + filtered = + DiscourseModCategories::WhisperQueryFilter.apply( + Post.where(id: whisper_post.id), + user, + ) + sql_visible = filtered.exists? + + expect(sql_visible).to eq(guardian_visible), + "QueryFilter (#{sql_visible}) disagrees with Guardian " \ + "(#{guardian_visible}) for persona #{persona || "anonymous"}" + end + end + + it "agrees on a staff-only whisper-back across personas" do + whisper_post.custom_fields[targets_field] = [] + whisper_post.save_custom_fields(true) + + [nil, author, target, participant, stranger, admin].each do |user| + guardian_visible = + Guardian.new(user).can_see_post?(whisper_post.reload) + sql_visible = + DiscourseModCategories::WhisperQueryFilter + .apply(Post.where(id: whisper_post.id), user) + .exists? + expect(sql_visible).to eq(guardian_visible) + end + end + end +end diff --git a/discourse-mod/spec/saves/README.md b/discourse-mod/spec/saves/README.md new file mode 100644 index 0000000..039cb5a --- /dev/null +++ b/discourse-mod/spec/saves/README.md @@ -0,0 +1,53 @@ +# Category-save regression suite + +A focused, separate suite that exercises every meaningful variant of a category save (`PUT /categories/:id.json` and `POST /categories.json`) as a moderator under this plugin, and asserts each one **does not return 500**. + +It exists because the main `spec/` suite covers the *Guardian gate* (can a moderator reach the save?) but not the *save chain itself* (does the save complete cleanly once the gate is open?). Those are different code paths and they fail in different ways. + +## The bug I think I found + +While testing the plugin on a live forum, a moderator-initiated category edit save returned **HTTP 500** with an HTML error page instead of JSON. The browser console showed: + +``` +PUT /categories/4 → 500 Internal Server Error +SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON + at saveCategory (...) +``` + +The `JSON.parse` exception is a *symptom* — Ember tried to parse Rails' default HTML 500 page as JSON. The real fault is an unhandled exception somewhere in `CategoriesController#update`'s save chain. + +### Hypothesis + +The grant in this plugin is given to the **built-in moderators group** (staff, `is_admin? == false`). Core Discourse normally never lets a non-admin staff user edit categories at all, so some sub-operation in the save chain — most likely group-permission updates or another admin-gated bookkeeping step — branches on `is_admin?` and falls into an unhandled path when a moderator hits it. + +The original `alltechdev/discourse-mini-mod` plugin doesn't hit this because its grant targets **non-staff category-group moderators**, which is a code path core already supports through `enable_category_group_moderation`. + +### What this suite does + +Each spec issues a single save variant as a moderator and asserts the response is **not 500**. A `200` is ideal; a `422` (JSON validation error) is acceptable — the goal is to surface which exact save shape crashes the server, not to lock in particular response codes. + +The variants are split by which Discourse code path they exercise: + +| File | What it saves | +|------|---------------| +| `basic_save_spec.rb` | name, color, text_color, description, slug | +| `permissions_save_spec.rb` | `permissions[group_name]` mappings — the leading 500 suspect | +| `custom_fields_save_spec.rb` | category `custom_fields` hash | +| `settings_save_spec.rb` | position, sort_order, search_priority, auto_close_hours, default_view, etc. | +| `parent_change_spec.rb` | reparenting a category | +| `create_variants_spec.rb` | `POST /categories.json` with each of the above shapes | +| `approval_save_spec.rb` | "Require moderator approval on new topics" — every known param shape, plus `reviewable_by_group_id`. The "except TL3" half is the site setting `approve_new_topics_unless_trust_level`, admin-only and out of scope for the category save. | + +Each spec runs the same save as an admin as a control — if the admin save also 500s, the bug is in core/another plugin, not us. + +## Running locally + +From your Discourse checkout with the plugin mounted at `plugins/discourse-mod`: + +```bash +bundle exec rspec plugins/discourse-mod/spec/saves --format documentation +``` + +## CI + +The dedicated workflow `.github/workflows/save-tests.yml` runs only this folder on every push and PR. It's separate from the main `plugin-tests.yml` so a save-chain regression shows up as its own failing check. diff --git a/discourse-mod/spec/saves/approval_save_spec.rb b/discourse-mod/spec/saves/approval_save_spec.rb new file mode 100644 index 0000000..98ad646 --- /dev/null +++ b/discourse-mod/spec/saves/approval_save_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "rails_helper" + +# "Require moderator approval on new topics" is a per-category toggle backed +# by CategorySetting#require_topic_approval. Different Discourse versions +# have accepted this through different param shapes (top-level custom_fields, +# nested category_setting attrs, or the Rails nested_attributes form), so we +# probe each shape independently — any of them returning 500 means the save +# chain breaks for our moderator grant on that specific shape. +# +# Note on "except for TL3": that exemption is the SITE setting +# `approve_new_topics_unless_trust_level`, which is admin-only and outside +# this plugin's scope. The category-level approval flag is what a moderator +# would set; the TL exemption is set separately by an admin in /admin/site_settings. +RSpec.describe "Category save — moderator approval on new topics", type: :request do + fab!(:moderator) + fab!(:admin) + fab!(:category) + fab!(:reviewer_group, :group) + + before { SiteSetting.mod_categories_enabled = true } + + def base_payload + { + name: category.name, + color: category.color, + text_color: category.text_color, + } + end + + def expect_not_500 + expect(response.status).not_to eq(500), + "approval save crashed (500). body starts: #{response.body[0, 200]}" + end + + describe "require_topic_approval via custom_fields (legacy shape)" do + let(:payload) do + base_payload.merge(custom_fields: { "require_topic_approval" => true }) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "require_topic_approval via top-level param" do + let(:payload) { base_payload.merge(require_topic_approval: true) } + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "require_topic_approval via category_setting nested attrs" do + let(:payload) do + base_payload.merge(category_setting: { require_topic_approval: true }) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "require_reply_approval (sibling toggle on the same model)" do + let(:payload) do + base_payload.merge(category_setting: { require_reply_approval: true }) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "reviewable_by_group_id (which group reviews queued items)" do + let(:payload) { base_payload.merge(reviewable_by_group_id: reviewer_group.id) } + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "clearing the reviewable group" do + before do + category.update!(reviewable_by_group_id: reviewer_group.id) if category.respond_to?(:reviewable_by_group_id=) + end + + let(:payload) { base_payload.merge(reviewable_by_group_id: nil) } + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "full approval setup in one save (toggle + reviewer group)" do + let(:payload) do + base_payload.merge( + require_topic_approval: true, + reviewable_by_group_id: reviewer_group.id, + ) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "disabling approval after it was previously enabled" do + before do + category.custom_fields["require_topic_approval"] = true + category.save_custom_fields + end + + let(:payload) do + base_payload.merge( + require_topic_approval: false, + custom_fields: { "require_topic_approval" => false }, + ) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end +end diff --git a/discourse-mod/spec/saves/basic_save_spec.rb b/discourse-mod/spec/saves/basic_save_spec.rb new file mode 100644 index 0000000..34d211d --- /dev/null +++ b/discourse-mod/spec/saves/basic_save_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Covers the simplest category edit shapes — name, colors, description, slug. +# Each save runs as a moderator and asserts the response is not a 500. The +# same save is run as admin as a control: if both fail, the bug isn't ours. +RSpec.describe "Category save — basic fields", type: :request do + fab!(:moderator) + fab!(:admin) + fab!(:category) + + before { SiteSetting.mod_categories_enabled = true } + + def expect_not_500 + expect(response.status).not_to eq(500), + "save crashed (500). body starts: #{response.body[0, 200]}" + end + + shared_examples "a save that does not crash" do |payload_proc| + it "completes for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: instance_exec(&payload_proc) + expect_not_500 + end + + it "completes for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: instance_exec(&payload_proc) + expect_not_500 + end + end + + describe "rename" do + include_examples "a save that does not crash", + -> { + { + name: "renamed-#{SecureRandom.hex(4)}", + color: category.color, + text_color: category.text_color, + } + } + end + + describe "color change" do + include_examples "a save that does not crash", + -> { + { + name: category.name, + color: "0088cc", + text_color: "ffffff", + } + } + end + + describe "description change" do + include_examples "a save that does not crash", + -> { + { + name: category.name, + color: category.color, + text_color: category.text_color, + description: "Updated description body for this category.", + } + } + end + + describe "slug change" do + include_examples "a save that does not crash", + -> { + { + name: category.name, + color: category.color, + text_color: category.text_color, + slug: "renamed-slug-#{SecureRandom.hex(4)}", + } + } + end +end diff --git a/discourse-mod/spec/saves/create_variants_spec.rb b/discourse-mod/spec/saves/create_variants_spec.rb new file mode 100644 index 0000000..bd8a399 --- /dev/null +++ b/discourse-mod/spec/saves/create_variants_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +# `POST /categories.json` is its own controller action and can fail +# differently from update. Same probe pattern: never 500 for a moderator. +RSpec.describe "Category create — variants", type: :request do + fab!(:moderator) + fab!(:admin) + fab!(:parent_category, :category) + fab!(:group) + + before { SiteSetting.mod_categories_enabled = true } + + def expect_not_500 + expect(response.status).not_to eq(500), + "create crashed (500). body starts: #{response.body[0, 200]}" + end + + describe "minimal top-level category" do + let(:payload) do + { + name: "mod-created-#{SecureRandom.hex(4)}", + color: "ff0000", + text_color: "ffffff", + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + post "/categories.json", params: payload + expect_not_500 + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + post "/categories.json", params: payload + expect_not_500 + end + end + + describe "subcategory under an existing parent" do + let(:payload) do + { + name: "mod-sub-#{SecureRandom.hex(4)}", + color: "00ff00", + text_color: "ffffff", + parent_category_id: parent_category.id, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + post "/categories.json", params: payload + expect_not_500 + end + end + + describe "create with permissions in the same request" do + let(:payload) do + { + name: "mod-perms-#{SecureRandom.hex(4)}", + color: "0000ff", + text_color: "ffffff", + permissions: { + group.name => 2, + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + post "/categories.json", params: payload + expect_not_500 + end + end + + describe "create with custom_fields in the same request" do + let(:payload) do + { + name: "mod-cf-#{SecureRandom.hex(4)}", + color: "abcdef", + text_color: "ffffff", + custom_fields: { + "another_plugin_key" => "x", + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + post "/categories.json", params: payload + expect_not_500 + end + end + + describe "create with topic settings in the same request" do + let(:payload) do + { + name: "mod-settings-#{SecureRandom.hex(4)}", + color: "123456", + text_color: "ffffff", + auto_close_hours: 48, + default_view: "latest", + sort_order: "activity", + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + post "/categories.json", params: payload + expect_not_500 + end + end +end diff --git a/discourse-mod/spec/saves/custom_fields_save_spec.rb b/discourse-mod/spec/saves/custom_fields_save_spec.rb new file mode 100644 index 0000000..8ca92b5 --- /dev/null +++ b/discourse-mod/spec/saves/custom_fields_save_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Custom fields are written through a separate code path +# (CategoryCustomField + bulk_save) and have historically had their own +# permission quirks. Covers add / update / remove shapes. +RSpec.describe "Category save — custom_fields", type: :request do + fab!(:moderator) + fab!(:admin) + fab!(:category) + + before { SiteSetting.mod_categories_enabled = true } + + def expect_not_500 + expect(response.status).not_to eq(500), + "custom_fields save crashed (500). body starts: #{response.body[0, 200]}" + end + + describe "adding a new custom field" do + let(:payload) do + { + name: category.name, + color: category.color, + text_color: category.text_color, + custom_fields: { + "my_plugin_key" => "hello", + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "updating an existing custom field" do + before do + category.custom_fields["my_plugin_key"] = "before" + category.save_custom_fields + end + + let(:payload) do + { + name: category.name, + color: category.color, + text_color: category.text_color, + custom_fields: { + "my_plugin_key" => "after", + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "clearing a custom field" do + before do + category.custom_fields["my_plugin_key"] = "before" + category.save_custom_fields + end + + let(:payload) do + { + name: category.name, + color: category.color, + text_color: category.text_color, + custom_fields: { + "my_plugin_key" => "", + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end +end diff --git a/discourse-mod/spec/saves/live_payload_repro_spec.rb b/discourse-mod/spec/saves/live_payload_repro_spec.rb new file mode 100644 index 0000000..ac57148 --- /dev/null +++ b/discourse-mod/spec/saves/live_payload_repro_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Reproduction of the exact payload that 500'd against forums.jtechforums.org +# on 2026-05-11 when a moderator (with this plugin installed and enabled) +# tried to save the moderation tab on category 5 ("Filtering"). Captured +# verbatim from Edge DevTools and trimmed only of the corrupt Hebrew +# localization entry (which is plausibly a payload-independent cause — +# tested separately at the bottom of this file). +# +# Each `it` block tries one variant of the payload. Whichever one(s) 500 +# names the field combination that breaks the save chain for a moderator +# under this plugin. +RSpec.describe "Category save — JTech live-payload reproduction", type: :request do + fab!(:moderator) + fab!(:admin) + fab!(:category) + fab!(:tl3_group) { Group.find(Group::AUTO_GROUPS[:trust_level_3]) } + + before { SiteSetting.mod_categories_enabled = true } + + def base_payload + { + name: category.name, + slug: category.slug, + color: "00615a", + text_color: "FFFFFF", + permissions: { "everyone" => 1 }, + position: 3, + allow_badges: true, + custom_fields: { + "require_topic_approval" => "t", + }, + topic_featured_link_allowed: true, + show_subcategory_list: false, + num_featured_topics: 3, + subcategory_list_style: "rows_with_featured_topics", + default_top_period: "all", + minimum_required_tags: 0, + navigate_to_first_post_after_read: false, + search_priority: 0, + moderating_group_ids: [], + reply_posting_review_group_ids: [], + default_list_filter: "all", + style_type: "icon", + icon: "shield-halved", + locale: "en", + } + end + + def expect_not_500(label) + expect(response.status).not_to eq(500), + "#{label} crashed (500). body starts: #{response.body[0, 400]}" + end + + describe "minimal — base fields only, no review-mode, no localizations" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: base_payload + expect_not_500("minimal save") + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: base_payload + expect_not_500("minimal save (admin)") + end + end + + describe "the new approval-mode-with-exempt-group fields" do + let(:payload) do + base_payload.merge( + category_setting_attributes: { topic_posting_review_mode: "everyone_except" }, + topic_posting_review_group_ids: [tl3_group.id], + ) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500("approval-mode + exempt-group") + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: payload + expect_not_500("approval-mode + exempt-group (admin)") + end + end + + describe "topic_posting_review_group_ids on its own (no mode)" do + let(:payload) { base_payload.merge(topic_posting_review_group_ids: [tl3_group.id]) } + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500("review-group-ids only") + end + end + + describe "category_setting_attributes mode on its own (no group_ids)" do + let(:payload) do + base_payload.merge( + category_setting_attributes: { topic_posting_review_mode: "everyone_except" }, + ) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500("mode without groups") + end + end + + describe "the spurious site-settings hash sent in the category payload" do + let(:payload) do + base_payload.merge( + category_type_site_settings: { + show_filter_by_solved_status: true, + prioritize_solved_topics_in_search: true, + show_who_marked_solved: true, + }, + ) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500("nested site-settings hash") + end + end + + describe "category_types multi-type save" do + let(:payload) { base_payload.merge(category_types: %w[discussion support]) } + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500("multi category_types") + end + end + + describe "category_localizations with a malformed description" do + let(:payload) do + base_payload.merge( + category_localizations: [ + { + locale: "he", + name: "filer", + description: + " כל נושאי פילטרינג, עזרה ופ.parentElement.insertAdjacentHTML('beforeend', 'קעי');ל投资额建议。打开过滤器是不允许的。", + }, + ], + ) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500("malformed Hebrew localization description") + end + end + + describe "the full live payload all-at-once" do + let(:payload) do + base_payload.merge( + category_setting_attributes: { topic_posting_review_mode: "everyone_except" }, + topic_posting_review_group_ids: [tl3_group.id], + category_types: %w[discussion support], + category_type_site_settings: { + show_filter_by_solved_status: true, + prioritize_solved_topics_in_search: true, + show_who_marked_solved: true, + }, + custom_fields: base_payload[:custom_fields].merge( + "enable_accepted_answers" => "true", + "notify_on_staff_accept_solved" => "true", + "empty_box_on_unsolved" => "false", + "solved_topics_auto_close_hours" => "48", + ), + ) + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500("full live payload (no localizations)") + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: payload + expect_not_500("full live payload (admin)") + end + end +end diff --git a/discourse-mod/spec/saves/parent_change_spec.rb b/discourse-mod/spec/saves/parent_change_spec.rb new file mode 100644 index 0000000..a249d1a --- /dev/null +++ b/discourse-mod/spec/saves/parent_change_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Re-parenting a category goes through a different validation path than a +# simple rename and can fail with reasons like "circular parent" or "would +# leave subcategories behind" — but it should never 500. +RSpec.describe "Category save — parent change", type: :request do + fab!(:moderator) + fab!(:admin) + fab!(:top_level_a, :category) + fab!(:top_level_b, :category) + fab!(:subcategory) { Fabricate(:category, parent_category: top_level_a) } + + before { SiteSetting.mod_categories_enabled = true } + + def expect_not_500 + expect(response.status).not_to eq(500), + "parent-change save crashed (500). body starts: #{response.body[0, 200]}" + end + + describe "reparenting a subcategory under a different top-level" do + let(:payload) do + { + name: subcategory.name, + color: subcategory.color, + text_color: subcategory.text_color, + parent_category_id: top_level_b.id, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{subcategory.id}.json", params: payload + expect_not_500 + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{subcategory.id}.json", params: payload + expect_not_500 + end + end + + describe "promoting a subcategory to a top-level category" do + let(:payload) do + { + name: subcategory.name, + color: subcategory.color, + text_color: subcategory.text_color, + parent_category_id: nil, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{subcategory.id}.json", params: payload + expect_not_500 + end + end + + describe "no-op save with parent_category_id unchanged" do + let(:payload) do + { + name: subcategory.name, + color: subcategory.color, + text_color: subcategory.text_color, + parent_category_id: top_level_a.id, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{subcategory.id}.json", params: payload + expect_not_500 + end + end +end diff --git a/discourse-mod/spec/saves/permissions_save_spec.rb b/discourse-mod/spec/saves/permissions_save_spec.rb new file mode 100644 index 0000000..a7cb886 --- /dev/null +++ b/discourse-mod/spec/saves/permissions_save_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Category permissions are the leading suspect for moderator-save 500s. +# Discourse's category permission update path historically branches on +# `is_admin?` rather than `is_staff?` in some bookkeeping steps, so a +# non-admin staff user (a moderator under this plugin) may hit an unhandled +# code path that returns HTML 500. +RSpec.describe "Category save — permissions", type: :request do + fab!(:moderator) + fab!(:admin) + fab!(:category) + fab!(:group) + + before { SiteSetting.mod_categories_enabled = true } + + def expect_not_500 + expect(response.status).not_to eq(500), + "permissions save crashed (500). body starts: #{response.body[0, 200]}" + end + + describe "setting permissions to a single group at 'create_post' (level 2)" do + let(:payload) do + { + name: category.name, + color: category.color, + text_color: category.text_color, + permissions: { + group.name => 2, + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + + it "does not 500 for an admin (control)" do + sign_in(admin) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "opening permissions to everyone (Group::AUTO_GROUPS[:everyone])" do + let(:payload) do + { + name: category.name, + color: category.color, + text_color: category.text_color, + permissions: { + "everyone" => 1, + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "restricting permissions to staff only" do + let(:payload) do + { + name: category.name, + color: category.color, + text_color: category.text_color, + permissions: { + "staff" => 1, + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "wiping all custom permissions (empty hash)" do + before do + category.set_permissions(group.id => :full) + category.save! + end + + let(:payload) do + { + name: category.name, + color: category.color, + text_color: category.text_color, + permissions: { + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end + + describe "swapping one group's permission level" do + before do + category.set_permissions(group.id => :readonly) + category.save! + end + + let(:payload) do + { + name: category.name, + color: category.color, + text_color: category.text_color, + permissions: { + group.name => 3, + }, + } + end + + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: payload + expect_not_500 + end + end +end diff --git a/discourse-mod/spec/saves/settings_save_spec.rb b/discourse-mod/spec/saves/settings_save_spec.rb new file mode 100644 index 0000000..9135736 --- /dev/null +++ b/discourse-mod/spec/saves/settings_save_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Covers the rest of the form fields the wrench-edit form posts: position, +# sort order, search priority, auto-close, default views, etc. None of these +# *should* require admin, but each is a different column-touching path so we +# probe them individually. +RSpec.describe "Category save — settings", type: :request do + fab!(:moderator) + fab!(:admin) + fab!(:category) + + before { SiteSetting.mod_categories_enabled = true } + + def base_payload + { + name: category.name, + color: category.color, + text_color: category.text_color, + } + end + + def expect_not_500 + expect(response.status).not_to eq(500), + "settings save crashed (500). body starts: #{response.body[0, 200]}" + end + + describe "position change" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: base_payload.merge(position: 3) + expect_not_500 + end + end + + describe "sort order + ascending" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", + params: base_payload.merge(sort_order: "created", sort_ascending: "true") + expect_not_500 + end + end + + describe "search priority" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", + params: base_payload.merge(search_priority: Searchable::PRIORITIES[:high]) + expect_not_500 + end + end + + describe "auto-close hours" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: base_payload.merge(auto_close_hours: 72) + expect_not_500 + end + end + + describe "default_view" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: base_payload.merge(default_view: "top") + expect_not_500 + end + end + + describe "default_top_period" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", params: base_payload.merge(default_top_period: "weekly") + expect_not_500 + end + end + + describe "topic_featured_link_allowed" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", + params: base_payload.merge(topic_featured_link_allowed: "true") + expect_not_500 + end + end + + describe "navigate_to_first_post_after_read" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", + params: base_payload.merge(navigate_to_first_post_after_read: "true") + expect_not_500 + end + end + + describe "show_subcategory_list + num_featured_topics" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", + params: base_payload.merge(show_subcategory_list: "true", num_featured_topics: 7) + expect_not_500 + end + end + + describe "topic_template" do + it "does not 500 for a moderator" do + sign_in(moderator) + put "/categories/#{category.id}.json", + params: + base_payload.merge( + topic_template: "Hi! Use this template when posting in this category.", + ) + expect_not_500 + end + end +end diff --git a/discourse-mod/spec/system/first_post_checklist_spec.rb b/discourse-mod/spec/system/first_post_checklist_spec.rb new file mode 100644 index 0000000..c3e614d --- /dev/null +++ b/discourse-mod/spec/system/first_post_checklist_spec.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require "rails_helper" + +# End-to-end coverage for the first-post checklist: the moderator config +# modal (opened from the sidebar), and the modal a new user must complete +# before their first post. Screenshots are written to tmp/capybara/ for +# the CI artifact. +RSpec.describe "First-post checklist", type: :system do + fab!(:moderator) + fab!(:user) do + Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true) + end + fab!(:tl0_user) do + Fabricate(:user, trust_level: TrustLevel[0], refresh_auto_groups: true) + end + fab!(:category) + fab!(:topic) do + Fabricate(:topic, category: category, title: "An existing app thread") + end + fab!(:first_post) do + Fabricate(:post, topic: topic, raw: "The original post in this thread.") + end + + NS = DiscourseModCategories::CHECKLIST_STORE_NAMESPACE + KEY = DiscourseModCategories::CHECKLIST_STORE_KEY + LOG_KEY = DiscourseModCategories::CHECKLIST_LOG_KEY + VERSION_FIELD = DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD + + before do + SiteSetting.mod_categories_enabled = true + SiteSetting.min_post_length = 5 + SiteSetting.body_min_entropy = 1 + # The spec fills the composer instantly; without this a TL0 user's + # first post is held by the fast-typer review-queue check. + SiteSetting.auto_silence_fast_typers_on_first_post = false + SiteSetting.approve_post_count = 0 + end + + def shot(name) + begin + Timeout.timeout(8) do + until page.evaluate_script( + "Array.from(document.images).every((i) => i.complete)", + ) + sleep 0.1 + end + end + rescue Timeout::Error + # Capture anyway rather than failing the spec over a slow image. + end + page.save_screenshot("#{name}.png") + end + + def set_checklist(version:, max_tl: 2, button_label: "I agree, post") + PluginStore.set( + NS, + KEY, + { + "version" => version, + "max_tl" => max_tl, + "button_label" => button_label, + "updated_at" => Time.zone.now.iso8601, + "items" => [ + { + "label" => "I read the community guidelines", + "url" => "https://example.com/guidelines", + }, + { + "label" => "This is an app upload, not an off-topic question", + "url" => "", + }, + ], + }, + ) + end + + def open_checklist_modal + visit("/") + find("[data-list-item-name='mod-checklist']", wait: 10).click + expect(page).to have_css(".mod-checklist-modal", wait: 10) + end + + def open_reply + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "Here is my reply on the forum.") + find(".save-or-cancel .create").click + end + + it "lets a moderator configure the checklist from the sidebar modal" do + sign_in(moderator) + + open_checklist_modal + expect(page).to have_css(".mod-checklist-inactive") + shot("51_checklist_editor_empty") + + find(".mod-checklist-add").click + all(".mod-checklist-row-label").last.fill_in( + with: "I read the community guidelines", + ) + all(".mod-checklist-row-url").last.fill_in( + with: "https://example.com/guidelines", + ) + find(".mod-checklist-add").click + all(".mod-checklist-row-label").last.fill_in( + with: "This is an app upload, not an off-topic question", + ) + audience = PageObjects::Components::SelectKit.new(".mod-checklist-audience") + audience.expand + audience.select_row_by_value("0") + find(".mod-checklist-button-label").fill_in(with: "I agree, post my topic") + shot("52_checklist_editor_filled") + + find(".mod-checklist-save").click + expect(page).to have_css(".mod-checklist-saved", wait: 10) + # The saved items round-trip back into the editor. + expect(page).to have_css(".mod-checklist-row", count: 2) + shot("53_checklist_editor_saved") + end + + it "lets a moderator reorder checklist rows and persists the new order" do + set_checklist(version: 1, max_tl: 2) + sign_in(moderator) + + open_checklist_modal + expect(page).to have_css(".mod-checklist-row", count: 2) + # The up button on the first row and the down button on the last + # row are disabled. + expect(all(".mod-checklist-move-up").first).to be_disabled + expect(all(".mod-checklist-move-down").last).to be_disabled + + # Move the second row above the first. + all(".mod-checklist-move-up").last.click + expect(all(".mod-checklist-row-label").first.value).to eq( + "This is an app upload, not an off-topic question", + ) + shot("75_checklist_rows_reordered") + + find(".mod-checklist-save").click + expect(page).to have_css(".mod-checklist-saved", wait: 10) + + # The new order round-trips back from the server. + stored = PluginStore.get(NS, KEY) + expect(stored["items"].map { |i| i["label"] }).to eq( + [ + "This is an app upload, not an off-topic question", + "I read the community guidelines", + ], + ) + end + + it "requires a TL0 user to accept, then leaves their later posts alone" do + set_checklist(version: 1, max_tl: 0, button_label: "I agree, post my reply") + + sign_in(tl0_user) + visit(topic.url) + open_reply + + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + shot("54_tl0_checklist_modal") + + all(".mod-checklist-checkbox").each(&:click) + shot("55_tl0_modal_all_checked") + + find(".mod-checklist-confirm").click + expect(page).to have_css(".topic-post", minimum: 2, wait: 10) + shot("56_tl0_reply_posted_after_accept") + + open_reply + expect(page).to have_css(".topic-post", minimum: 3, wait: 10) + expect(page).to have_no_css(".mod-first-post-checklist-modal") + shot("57_tl0_second_post_no_prompt") + end + + it "shows the modal to a TL1 user under a TL0-TL2 checklist" do + set_checklist(version: 1, max_tl: 2) + + sign_in(user) + visit(topic.url) + open_reply + + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + shot("58_tl1_checklist_modal") + end + + it "re-prompts a user after the checklist version is bumped" do + # The user accepted version 1; staff then publish version 2. + set_checklist(version: 1, max_tl: 2) + user.upsert_custom_fields(VERSION_FIELD => 1) + set_checklist(version: 2, max_tl: 2) + + sign_in(moderator) + open_checklist_modal + expect(page).to have_css(".mod-checklist-version", text: "2") + shot("59_checklist_version_bumped") + + sign_in(user) + visit(topic.url) + open_reply + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + shot("60_reprompt_after_version_bump") + end + + it "re-prompts mid-session after a version bump without a hard refresh" do + # The TL1 user accepts version 1 in this browser session. + set_checklist(version: 1, max_tl: 2) + + sign_in(user) + visit(topic.url) + open_reply + + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + # The "Last updated" line is shown on the accept modal. + expect(page).to have_css(".mod-checklist-updated-at") + shot("102_reprompt_session_first_accept") + + all(".mod-checklist-checkbox").each(&:click) + find(".mod-checklist-confirm").click + expect(page).to have_css(".topic-post", minimum: 2, wait: 10) + + # Staff bump the checklist to version 2 while the SAME browser session + # stays open — no page reload happens after this point. + set_checklist(version: 2, max_tl: 2) + + # SPA navigation only: click the header home logo (an Ember route + # transition, not a document load) and back to the topic via its list + # link. Capybara `visit` is deliberately NOT used here — a full reload + # would re-bootstrap the current-user payload and mask the bug. + find(".d-header .home-logo a, .d-header #site-logo, .d-header .title a", + match: :first).click + expect(page).to have_css(".topic-list-item", wait: 10) + find(".topic-list-item a.title", match: :first).click + expect(page).to have_css("#topic-footer-buttons", wait: 10) + + # Posting again re-prompts the user because the composer gate re-fetches + # the owed checklist from the server — the stale bootstrapped value is + # not trusted. + open_reply + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + expect(page).to have_css(".mod-checklist-updated-at") + shot("103_reprompt_session_after_bump") + end + + it "shows the acceptance audit log in the config modal" do + set_checklist(version: 2, max_tl: 2) + PluginStore.set( + NS, + LOG_KEY, + [ + { "user_id" => tl0_user.id, "version" => 1, "at" => 2.days.ago.iso8601 }, + { "user_id" => user.id, "version" => 1, "at" => 1.day.ago.iso8601 }, + { "user_id" => tl0_user.id, "version" => 2, "at" => 1.hour.ago.iso8601 }, + ], + ) + + sign_in(moderator) + open_checklist_modal + expect(page).to have_css(".mod-checklist-log-table", wait: 10) + expect(page).to have_css(".mod-checklist-log-table tbody tr", count: 3) + shot("61_checklist_acceptance_log") + end + + it "lets staff require a logged user to re-accept" do + set_checklist(version: 1, max_tl: 2) + user.upsert_custom_fields(VERSION_FIELD => 1) + PluginStore.set( + NS, + LOG_KEY, + [{ "user_id" => user.id, "version" => 1, "at" => 1.hour.ago.iso8601 }], + ) + + sign_in(moderator) + open_checklist_modal + expect(page).to have_css(".mod-checklist-log-table tbody tr", count: 1) + shot("97_checklist_log_before_reaccept") + + find(".mod-checklist-require-reaccept").click + expect(page).to have_css(".fk-d-toast", wait: 10) + shot("98_checklist_require_reaccept") + + expect(user.reload.custom_fields[VERSION_FIELD]).to eq(0) + end + + it "lets staff create a targeted checklist for a user who is then prompted" do + sign_in(moderator) + open_checklist_modal + + find(".mod-checklist-targeted-add").click + find(".mod-checklist-targeted-name").fill_in(with: "App uploaders") + + picker = PageObjects::Components::SelectKit.new(".mod-checklist-targeted-users") + picker.expand + picker.search(user.username) + picker.select_row_by_value(user.username) + picker.collapse + + find(".mod-checklist-targeted-add-item").click + all(".mod-checklist-targeted-item .mod-checklist-row-label").last.fill_in( + with: "I read the app upload rules", + ) + shot("99_targeted_checklist_filled") + + find(".mod-checklist-targeted-save").click + expect(page).to have_css(".fk-d-toast", wait: 10) + expect(page).to have_css(".mod-checklist-version", wait: 10) + shot("100_targeted_checklist_saved") + + stored = DiscourseModCategories.targeted_checklists + expect(stored.size).to eq(1) + expect(stored.first["user_ids"]).to eq([user.id]) + + # The targeted user is now prompted with this checklist on posting. + sign_in(user) + visit(topic.url) + open_reply + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + expect(page).to have_css( + ".mod-checklist-text", + text: "I read the app upload rules", + ) + shot("101_targeted_checklist_prompt") + end +end diff --git a/discourse-mod/spec/system/gallery_expansion_spec.rb b/discourse-mod/spec/system/gallery_expansion_spec.rb new file mode 100644 index 0000000..d76a8cc --- /dev/null +++ b/discourse-mod/spec/system/gallery_expansion_spec.rb @@ -0,0 +1,1067 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Expanded screenshot gallery for the discourse-mod plugin. Every example +# captures one or more screenshots showing a genuinely different visual +# state for a feature, complementing the dedicated per-feature system specs. +# Screenshot numbers start at 104 to avoid collisions with the existing +# gallery (which runs through ~103). All screenshots are written to +# tmp/capybara/ and uploaded by CI as the `ui-screenshots` artifact. +RSpec.describe "Gallery expansion", type: :system do + fab!(:admin) + fab!(:moderator) { Fabricate(:moderator, username: "mod_morgan") } + fab!(:other_moderator) { Fabricate(:moderator, username: "mod_misha") } + fab!(:user) + fab!(:tl0_user) do + Fabricate(:user, trust_level: TrustLevel[0], refresh_auto_groups: true) + end + fab!(:tl1_user) do + Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true) + end + fab!(:category) + fab!(:topic) do + Fabricate(:topic, category: category, title: "Share your app build here") + end + fab!(:first_post) do + Fabricate(:post, topic: topic, raw: "Drop your app uploads in this thread.") + end + + NS = DiscourseModCategories::CHECKLIST_STORE_NAMESPACE + KEY = DiscourseModCategories::CHECKLIST_STORE_KEY + LOG_KEY = DiscourseModCategories::CHECKLIST_LOG_KEY + TARGETED_KEY = DiscourseModCategories::TARGETED_CHECKLISTS_KEY + TARGETS_FIELD = DiscourseModCategories::POST_WHISPER_TARGETS_FIELD + TARGET_GROUPS_FIELD = + DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD + PARTICIPANTS_FIELD = + DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD + + before do + SiteSetting.mod_categories_enabled = true + SiteSetting.topic_footer_message_enabled = true + SiteSetting.topic_reply_prompt_enabled = true + SiteSetting.precheck_new_topic_enabled = true + SiteSetting.mod_whisper_enabled = true + SiteSetting.min_post_length = 5 + SiteSetting.min_first_post_length = 5 + SiteSetting.min_topic_title_length = 5 + SiteSetting.body_min_entropy = 1 + SiteSetting.title_min_entropy = 1 + SiteSetting.auto_silence_fast_typers_on_first_post = false + SiteSetting.approve_post_count = 0 + Group.refresh_automatic_groups! + SiteSetting.approve_unless_allowed_groups = + Group::AUTO_GROUPS[:trust_level_0].to_s + end + + def shot(name) + begin + Timeout.timeout(8) do + until page.evaluate_script( + "Array.from(document.images).every((i) => i.complete)", + ) + sleep 0.1 + end + end + rescue Timeout::Error + # Capture anyway rather than failing the spec over a slow image. + end + page.save_screenshot("#{name}.png") + end + + def open_admin_menu + find(".toggle-admin-menu", match: :first).click + end + + def open_mod_messages_modal + open_admin_menu + find(".mod-topic-messages-button").click + expect(page).to have_css(".mod-topic-messages-modal", wait: 10) + end + + # --------------------------------------------------------------------------- + # Moderator category management + # --------------------------------------------------------------------------- + context "moderator category management" do + before { sign_in(moderator) } + + it "lists categories on the categories page as a moderator" do + Fabricate(:category, name: "Releases") + Fabricate(:category, name: "Bug Reports") + visit("/categories") + expect(page).to have_css(".category-list", wait: 10) + shot("104_moderator_categories_list") + end + + it "shows the categories page chrome for a moderator" do + visit("/categories") + expect(page).to have_css(".category-list", wait: 10) + shot("105_moderator_categories_page_chrome") + end + + it "opens a category settings tab as a moderator" do + visit("/c/#{category.slug}/edit/settings") + expect(page).to have_css(".mod-new-topic-prompt", wait: 10) + shot("106_category_edit_settings_tab") + end + + it "opens the category general tab as a moderator" do + visit("/c/#{category.slug}/edit/general") + expect(page).to have_css(".edit-category-tab, .category-color-editor, #edit-category-tabs", wait: 10) + shot("107_category_edit_general_tab") + end + + it "opens the category security tab as a moderator" do + visit("/c/#{category.slug}/edit/security") + expect(page).to have_css(".edit-category-tab, .edit-category-tab-security, #edit-category-tabs", wait: 10) + shot("108_category_edit_security_tab") + end + + it "shows the topic list inside a category" do + Fabricate(:topic, category: category, title: "A first conversation") + Fabricate(:topic, category: category, title: "A second conversation") + visit("/c/#{category.slug}/#{category.id}") + expect(page).to have_css(".topic-list-item", wait: 10) + shot("109_category_topic_list_view") + end + + it "shows the category header in a topic" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + shot("110_category_badge_in_topic_header") + end + end + + # --------------------------------------------------------------------------- + # Per-topic footer message — extra states + # --------------------------------------------------------------------------- + context "per-topic footer message — extra states" do + before { sign_in(moderator) } + + it "renders a multi-paragraph markdown footer message" do + topic.custom_fields["mod_topic_footer_message"] = + "**Heads up:** post one upload per topic.\n\n" \ + "Use [this guide](https://example.com/guide) before posting." + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-message", wait: 10) + shot("111_footer_multiparagraph_markdown") + end + + it "renders a footer with a markdown link to external guidelines" do + topic.custom_fields["mod_topic_footer_message"] = + "Please review the [community guidelines](https://example.com/g) before posting." + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-message a", wait: 10) + shot("112_footer_with_markdown_link") + end + + it "shows the official-notice box with the shield icon" do + topic.custom_fields["mod_topic_footer_message"] = + "Moderation notice: only post finished app uploads here." + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-message", wait: 10) + shot("113_footer_shield_icon_box") + end + + it "shows the footer on a topic with multiple posts" do + 5.times do |i| + Fabricate(:post, topic: topic, raw: "Reply number #{i} in this thread.") + end + topic.custom_fields["mod_topic_footer_message"] = + "Keep replies on-topic — uploads only." + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-message", wait: 10) + shot("114_footer_on_multi_post_thread") + end + + it "shows the modal with only the footer field set (empty reply prompt)" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_mod_messages_modal + find(".mod-footer-input").fill_in( + with: "Only the footer — no reply prompt.", + ) + shot("115_modal_only_footer_field_set") + end + + it "shows the Prompt Checklist modal in statement mode (replaces the old reply-prompt field)" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + find(".toggle-admin-menu", match: :first).click + expect(page).to have_css(".mod-topic-prompt-checklist-button", wait: 10) + find(".mod-topic-prompt-checklist-button").click + expect(page).to have_css(".mod-topic-prompt-checklist-modal", wait: 10) + shot("116_modal_only_reply_prompt_set") + end + + it "shows a topic with a footer message and a closed banner together" do + topic.update!(closed: true) + topic.custom_fields["mod_topic_footer_message"] = + "This thread is closed — see the announcement." + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-message", wait: 10) + shot("117_footer_on_closed_topic_with_banner") + end + end + + # --------------------------------------------------------------------------- + # Per-topic prompt checklist — extra states (replaces the old reply-prompt + # tests; the editor now lives under the Prompt Checklist entry and the + # statement-mode flow supersedes the legacy reply-prompt textarea.) + # --------------------------------------------------------------------------- + context "per-topic prompt checklist — extra states" do + before { sign_in(moderator) } + + def open_prompt_checklist_modal + find(".toggle-admin-menu", match: :first).click + expect(page).to have_css(".mod-topic-prompt-checklist-button", wait: 10) + find(".mod-topic-prompt-checklist-button").click + expect(page).to have_css(".mod-topic-prompt-checklist-modal", wait: 10) + end + + def switch_to_statement_mode + # The editor defaults to checklist mode for a topic with no + # existing config; switching to statement mode reveals the + # statement textarea. + mode = + PageObjects::Components::SelectKit.new( + ".mod-topic-prompt-checklist-mode", + ) + mode.expand + mode.select_row_by_value("statement") + expect(page).to have_css(".mod-topic-prompt-checklist-statement", wait: 10) + end + + it "shows the statement-mode prompt with the audience dropdown at TL0" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_prompt_checklist_modal + switch_to_statement_mode + find(".mod-topic-prompt-checklist-statement").fill_in( + with: "Read the rules first.", + ) + audience = + PageObjects::Components::SelectKit.new( + ".mod-topic-prompt-checklist-max-tl", + ) + audience.expand + audience.select_row_by_value("0") + shot("118_reply_prompt_audience_capped_tl0") + end + + it "shows the statement-mode prompt with the audience dropdown at TL2" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_prompt_checklist_modal + switch_to_statement_mode + find(".mod-topic-prompt-checklist-statement").fill_in( + with: "Be sure your reply is on-topic.", + ) + audience = + PageObjects::Components::SelectKit.new( + ".mod-topic-prompt-checklist-max-tl", + ) + audience.expand + audience.select_row_by_value("2") + shot("119_reply_prompt_audience_capped_tl2") + end + + it "shows the modal with a long multi-line statement entered" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_prompt_checklist_modal + switch_to_statement_mode + find(".mod-topic-prompt-checklist-statement").fill_in( + with: + "Before replying, please check:\n" \ + " - is this an upload?\n" \ + " - did you search existing threads?\n" \ + " - did you read https://example.com/guide ?", + ) + shot("120_modal_multiline_reply_prompt") + end + + it "pre-fills the editor in statement mode from a legacy reply prompt" do + topic.custom_fields["mod_topic_reply_prompt"] = + "Please link to your upload before replying." + topic.custom_fields["mod_topic_reply_prompt_max_tl"] = 1 + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + open_prompt_checklist_modal + expect(find(".mod-topic-prompt-checklist-statement").value).to eq( + "Please link to your upload before replying.", + ) + shot("121_modal_reopened_reply_prompt_persisted") + end + end + + context "per-topic reply prompt — user-facing" do + it "shows a clickable URL inside the reply confirmation dialog" do + topic.custom_fields["mod_topic_reply_prompt"] = + "Read https://example.com/policy and then post." + topic.save_custom_fields(true) + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "A reply about the policy.") + find(".save-or-cancel .create").click + expect(page).to have_css(".dialog-body", wait: 10) + shot("122_reply_prompt_clickable_link_dialog") + end + + it "does not prompt a TL4 user when the cap is TL1" do + topic.custom_fields["mod_topic_reply_prompt"] = + "Only new members get prompted." + topic.custom_fields["mod_topic_reply_prompt_max_tl"] = 1 + topic.save_custom_fields(true) + leader = + Fabricate(:user, trust_level: TrustLevel[4], refresh_auto_groups: true) + sign_in(leader) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "A reply from a TL4 leader.") + find(".save-or-cancel .create").click + # No dialog appears — the cap exempts this user. + expect(page).to have_no_css(".dialog-body", wait: 5) + shot("123_reply_prompt_skipped_above_cap") + end + end + + # --------------------------------------------------------------------------- + # Per-category new-topic prompt — extra states + # --------------------------------------------------------------------------- + context "per-category new-topic prompt — extra states" do + before { sign_in(moderator) } + + it "shows a live preview with a markdown bold and a link" do + visit("/c/#{category.slug}/edit/settings") + expect(page).to have_css(".mod-new-topic-prompt", wait: 10) + find(".mod-new-topic-prompt-input").fill_in( + with: + "**Important:** check https://example.com/rules before starting a thread.", + ) + expect(page).to have_css(".mod-prompt-preview", wait: 10) + shot("124_category_prompt_preview_bold_link") + end + + it "shows a multi-line preview rendering line breaks" do + visit("/c/#{category.slug}/edit/settings") + expect(page).to have_css(".mod-new-topic-prompt", wait: 10) + find(".mod-new-topic-prompt-input").fill_in( + with: "Line one of the prompt.\nLine two.\nLine three.", + ) + expect(page).to have_css(".mod-prompt-preview", wait: 10) + shot("125_category_prompt_preview_multiline") + end + + it "shows the audience dropdown set to TL1" do + visit("/c/#{category.slug}/edit/settings") + expect(page).to have_css(".mod-new-topic-prompt", wait: 10) + find(".mod-new-topic-prompt-input").fill_in(with: "A prompt.") + audience = + PageObjects::Components::SelectKit.new(".mod-new-topic-audience-input") + audience.expand + audience.select_row_by_value("1") + shot("126_category_prompt_audience_tl1") + end + + it "shows the audience dropdown set to TL0" do + visit("/c/#{category.slug}/edit/settings") + expect(page).to have_css(".mod-new-topic-prompt", wait: 10) + find(".mod-new-topic-prompt-input").fill_in(with: "A prompt.") + audience = + PageObjects::Components::SelectKit.new(".mod-new-topic-audience-input") + audience.expand + audience.select_row_by_value("0") + shot("127_category_prompt_audience_tl0") + end + + it "shows a previously-saved prompt on revisit" do + category.custom_fields["mod_category_new_topic_prompt"] = + "Persisted prompt from a previous save." + category.custom_fields["mod_category_new_topic_prompt_max_tl"] = 2 + category.save_custom_fields(true) + + visit("/c/#{category.slug}/edit/settings") + expect(page).to have_css(".mod-new-topic-prompt", wait: 10) + expect(find(".mod-new-topic-prompt-input").value).to eq( + "Persisted prompt from a previous save.", + ) + shot("128_category_prompt_persisted_state") + end + + it "preview is empty when the field is cleared" do + visit("/c/#{category.slug}/edit/settings") + expect(page).to have_css(".mod-new-topic-prompt", wait: 10) + find(".mod-new-topic-prompt-input").fill_in(with: "") + shot("129_category_prompt_preview_empty") + end + end + + # --------------------------------------------------------------------------- + # Pin a post to the bottom — extra states + # --------------------------------------------------------------------------- + context "pin a post to the bottom — extra states" do + fab!(:thread_posts) do + (1..7).map do |i| + Fabricate( + :post, + topic: topic, + raw: "Reply number #{i} in this thread, long enough to pass.", + ) + end + end + + def pin!(target) + topic.custom_fields["mod_topic_pinned_post_id"] = target.id + topic.save_custom_fields(true) + end + + it "shows the bottom-pinned post viewed by a regular user" do + pin!(thread_posts[2]) + topic.custom_fields["mod_topic_footer_message"] = + "Keep replies on-topic only." + topic.save_custom_fields(true) + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-pinned-post", wait: 10) + shot("130_pinned_post_regular_user_view") + end + + it "shows the badge on the original in-stream post" do + pin!(thread_posts[1]) + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}/#{thread_posts[1].post_number}") + expect(page).to have_css(".mod-pinned-in-stream-badge", wait: 10) + shot("131_pinned_post_in_stream_badge") + end + + it "shows the pinned bottom post with jump-to-original affordance" do + pin!(thread_posts[0]) + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css( + ".topic-footer-pinned-post a.pinned-post-jump", + wait: 10, + ) + shot("132_pinned_post_jump_to_original") + end + + it "shows a topic with a pinned post AND a private note (staff)" do + pin!(thread_posts[3]) + topic.custom_fields["mod_topic_private_note"] = + "Watch this thread — staff only." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_created_at"] = + 1.hour.ago.iso8601 + topic.save_custom_fields(true) + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-pinned-post", wait: 10) + expect(page).to have_css(".mod-private-note", wait: 10) + shot("133_pinned_post_with_private_note") + end + + it "shows the post admin menu while a post is already pinned" do + pin!(thread_posts[2]) + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}/#{thread_posts[2].post_number}") + within(find("#post_#{thread_posts[2].post_number}")) do + find(".show-more-actions").click if has_css?( + ".show-more-actions", + wait: 2, + ) + find(".show-post-admin-menu", match: :first).click + end + expect(page).to have_css(".mod-pin-post-to-bottom", wait: 10) + shot("134_post_admin_menu_while_pinned") + end + + it "shows a topic with both a pinned post and a footer message together" do + pin!(thread_posts[4]) + topic.custom_fields["mod_topic_footer_message"] = + "Please link uploads — replies must be on-topic." + topic.save_custom_fields(true) + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-pinned-post", wait: 10) + expect(page).to have_css(".topic-footer-message", wait: 10) + shot("135_pinned_plus_footer_user_view") + end + end + + # --------------------------------------------------------------------------- + # Per-topic reply approval — extra states + # --------------------------------------------------------------------------- + context "per-topic reply approval — extra states" do + before { sign_in(moderator) } + + it "shows the modal with the approval checkbox unchecked by default" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + open_mod_messages_modal + expect(find(".mod-require-approval-input")).not_to be_checked + shot("136_approval_checkbox_unchecked") + end + + it "shows the approval checkbox ticked then unticked again" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + open_mod_messages_modal + find(".mod-require-approval-input").click + expect(find(".mod-require-approval-input")).to be_checked + shot("137_approval_checkbox_ticked") + find(".mod-require-approval-input").click + expect(find(".mod-require-approval-input")).not_to be_checked + shot("138_approval_checkbox_untoggled") + end + + it "shows the approval state persisted across a modal reopen" do + topic.custom_fields["mod_topic_require_reply_approval"] = true + topic.save_custom_fields(true) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + open_mod_messages_modal + expect(find(".mod-require-approval-input")).to be_checked + shot("139_approval_checkbox_persisted") + end + + it "shows the approval checkbox alongside the footer field" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + open_mod_messages_modal + find(".mod-footer-input").fill_in(with: "Replies route to mods.") + find(".mod-require-approval-input").click + shot("140_approval_with_messages_filled") + end + + it "renders the topic the same way for a regular user (no extra UI)" do + topic.custom_fields["mod_topic_require_reply_approval"] = true + topic.save_custom_fields(true) + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + # There is no extra approval UI surfaced to the regular user. + expect(page).to have_no_css(".mod-require-approval-input") + shot("141_approval_topic_view_regular_user") + end + end + + # --------------------------------------------------------------------------- + # Private moderator note — extra states + # --------------------------------------------------------------------------- + context "private moderator note — extra states" do + before { sign_in(moderator) } + + it "shows the note positioned at the top of the topic" do + topic.custom_fields["mod_topic_private_note"] = + "Note above the original post." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_position"] = "top" + topic.custom_fields["mod_topic_private_note_created_at"] = + 1.hour.ago.iso8601 + topic.save_custom_fields(true) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-private-note", wait: 10) + shot("142_private_note_position_top") + end + + it "shows the note positioned at the bottom of the topic" do + topic.custom_fields["mod_topic_private_note"] = + "Note below the original post." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_position"] = "bottom" + topic.custom_fields["mod_topic_private_note_created_at"] = + 1.hour.ago.iso8601 + topic.save_custom_fields(true) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-private-note", wait: 10) + shot("143_private_note_position_bottom") + end + + it "shows a long note thread with multiple staff replies" do + topic.custom_fields["mod_topic_private_note"] = + "Initial moderator note for triage." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_created_at"] = + 4.hours.ago.iso8601 + topic.custom_fields["mod_topic_private_note_replies"] = [ + { + "id" => "aaaaaaaaaaaa0001", + "user_id" => moderator.id, + "raw" => "I've sent the user a DM.", + "created_at" => 3.hours.ago.iso8601, + }, + { + "id" => "aaaaaaaaaaaa0002", + "user_id" => other_moderator.id, + "raw" => "Thanks — I'll watch the next reply.", + "created_at" => 2.hours.ago.iso8601, + }, + { + "id" => "aaaaaaaaaaaa0003", + "user_id" => moderator.id, + "raw" => "Closing the loop — resolved.", + "created_at" => 1.hour.ago.iso8601, + }, + ] + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-private-note-reply", count: 3, wait: 10) + shot("144_private_note_three_replies_thread") + end + + it "shows the edit and delete affordances on a note reply" do + topic.custom_fields["mod_topic_private_note"] = "Initial note." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_created_at"] = + 2.hours.ago.iso8601 + topic.custom_fields["mod_topic_private_note_replies"] = [ + { + "id" => "bbbbbbbbbbbb0001", + "user_id" => moderator.id, + "raw" => "A reply with edit/delete buttons visible.", + "created_at" => 1.hour.ago.iso8601, + }, + ] + topic.save_custom_fields(true) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-private-note-reply", wait: 10) + shot("145_private_note_reply_edit_delete_affordances") + end + + it "shows the reply composer open with text being entered" do + topic.custom_fields["mod_topic_private_note"] = "A note to follow up on." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_created_at"] = + 2.hours.ago.iso8601 + topic.save_custom_fields(true) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-private-note", wait: 10) + find(".mod-private-note-reply-button").click + find(".mod-private-note-reply-input").fill_in( + with: "Drafting a follow-up reply right now.", + ) + shot("146_private_note_reply_composer_drafting") + end + + it "the regular user never sees the private note (no DOM node)" do + topic.custom_fields["mod_topic_private_note"] = "Staff eyes only." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.save_custom_fields(true) + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + expect(page).to have_no_css(".mod-private-note") + shot("147_private_note_user_no_node") + end + + it "a non-staff TL1 user never sees the private note" do + topic.custom_fields["mod_topic_private_note"] = "Staff eyes only." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.save_custom_fields(true) + sign_in(tl1_user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + expect(page).to have_no_css(".mod-private-note") + shot("148_private_note_non_staff_view") + end + end + + # --------------------------------------------------------------------------- + # Moderator-notes user-menu tab — extra states + # --------------------------------------------------------------------------- + context "moderator-notes user-menu tab — extra states" do + fab!(:second_topic) do + Fabricate(:topic, category: category, title: "A second thread to review") + end + fab!(:second_post) do + Fabricate(:post, topic: second_topic, raw: "Original post in thread two.") + end + fab!(:third_topic) do + Fabricate(:topic, category: category, title: "A third thread under review") + end + fab!(:third_post) do + Fabricate(:post, topic: third_topic, raw: "Original post in thread three.") + end + + before do + [topic, second_topic, third_topic].each_with_index do |t, i| + t.custom_fields["mod_topic_private_note"] = + "Note number #{i + 1} — needs eyes." + t.custom_fields["mod_topic_private_note_user_id"] = moderator.id + t.custom_fields["mod_topic_private_note_activity_at"] = + (i + 1).hours.ago.iso8601 + t.custom_fields["mod_topic_private_note_created_at"] = + (i + 1).hours.ago.iso8601 + t.save_custom_fields(true) + end + sign_in(other_moderator) + end + + it "shows the user menu with the shield tab and an unread badge" do + visit("/") + find(".header-dropdown-toggle.current-user").click + expect(page).to have_css( + "#user-menu-button-discourse-mod-notes", + wait: 10, + ) + shot("149_user_menu_shield_tab_with_unread") + end + + it "shows the notes panel listing multiple notes" do + visit("/") + find(".header-dropdown-toggle.current-user").click + find("#user-menu-button-discourse-mod-notes").click + expect(page).to have_css(".mod-notes-panel .mod-notes-item", wait: 10) + shot("150_notes_panel_multiple_entries") + end + + it "shows the notes panel scrolled to a single note" do + # Trim back to one note for a single-entry panel state. + [second_topic, third_topic].each do |t| + t.custom_fields["mod_topic_private_note"] = "" + t.save_custom_fields(true) + end + + visit("/") + find(".header-dropdown-toggle.current-user").click + find("#user-menu-button-discourse-mod-notes").click + expect(page).to have_css(".mod-notes-panel .mod-notes-item", wait: 10) + shot("151_notes_panel_single_entry") + end + + it "shows the moderator-notes panel after the seen marker is recorded" do + other_moderator.upsert_custom_fields( + "mod_notes_seen_at" => Time.zone.now.iso8601, + ) + visit("/") + find(".header-dropdown-toggle.current-user").click + find("#user-menu-button-discourse-mod-notes").click + expect(page).to have_css(".mod-notes-panel .mod-notes-item", wait: 10) + shot("152_notes_panel_after_seen") + end + + it "shows the empty-state when there are no moderator notes" do + [topic, second_topic, third_topic].each do |t| + t.custom_fields["mod_topic_private_note"] = "" + t.save_custom_fields(true) + end + visit("/") + find(".header-dropdown-toggle.current-user").click + find("#user-menu-button-discourse-mod-notes").click + shot("153_notes_panel_empty_state") + end + + it "clicking a note entry navigates to its topic" do + visit("/") + find(".header-dropdown-toggle.current-user").click + find("#user-menu-button-discourse-mod-notes").click + expect(page).to have_css(".mod-notes-panel .mod-notes-item", wait: 10) + first(".mod-notes-panel .mod-notes-item a").click + expect(page).to have_css("#topic-title", wait: 10) + shot("154_notes_panel_link_navigated") + end + end + + # --------------------------------------------------------------------------- + # First-post checklist — extra states + # --------------------------------------------------------------------------- + context "first-post checklist — extra states" do + def set_checklist(version:, max_tl: 2, button_label: "I agree, post", items: nil) + items ||= [ + { "label" => "I read the community guidelines", + "url" => "https://example.com/guidelines" }, + { "label" => "This is an app upload", + "url" => "" }, + ] + PluginStore.set( + NS, + KEY, + { + "version" => version, + "max_tl" => max_tl, + "button_label" => button_label, + "updated_at" => Time.zone.now.iso8601, + "items" => items, + }, + ) + end + + def open_checklist_modal + visit("/") + find("[data-list-item-name='mod-checklist']", wait: 10).click + expect(page).to have_css(".mod-checklist-modal", wait: 10) + end + + it "shows the inactive notice when no checklist exists" do + sign_in(moderator) + open_checklist_modal + expect(page).to have_css(".mod-checklist-inactive", wait: 10) + shot("155_checklist_editor_inactive_notice") + end + + it "shows the checklist editor with a custom button label" do + set_checklist(version: 1, max_tl: 2, button_label: "Yes — post my reply") + sign_in(moderator) + open_checklist_modal + expect(page).to have_css(".mod-checklist-row", wait: 10) + expect(find(".mod-checklist-button-label").value).to eq( + "Yes — post my reply", + ) + shot("156_checklist_editor_custom_button_label") + end + + it "shows the audience set to 'Up to basic (TL0 to TL1)' in the editor" do + set_checklist(version: 1, max_tl: 1) + sign_in(moderator) + open_checklist_modal + expect(page).to have_css(".mod-checklist-row", wait: 10) + shot("157_checklist_editor_audience_tl1") + end + + it "shows the 'Last updated' line on the user-facing modal" do + set_checklist(version: 1, max_tl: 2) + sign_in(tl1_user) + visit(topic.url) + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "Here is my first reply.") + find(".save-or-cancel .create").click + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + expect(page).to have_css(".mod-checklist-updated-at", wait: 10) + shot("158_checklist_user_modal_last_updated") + end + + it "shows the checklist's custom button label on the user-facing modal" do + set_checklist( + version: 1, + max_tl: 2, + button_label: "I agree — post my reply", + ) + sign_in(tl1_user) + visit(topic.url) + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "Here is my first reply.") + find(".save-or-cancel .create").click + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + expect(page).to have_css( + ".mod-checklist-confirm", + text: "I agree — post my reply", + wait: 10, + ) + shot("159_checklist_user_modal_custom_button") + end + + it "shows a populated audit log with mixed versions" do + set_checklist(version: 3, max_tl: 2) + PluginStore.set( + NS, + LOG_KEY, + [ + { "user_id" => tl0_user.id, "version" => 1, "at" => 3.days.ago.iso8601 }, + { "user_id" => tl1_user.id, "version" => 1, "at" => 2.days.ago.iso8601 }, + { "user_id" => tl0_user.id, "version" => 2, "at" => 1.day.ago.iso8601 }, + { "user_id" => tl1_user.id, "version" => 2, "at" => 12.hours.ago.iso8601 }, + { "user_id" => user.id, "version" => 3, "at" => 30.minutes.ago.iso8601 }, + ], + ) + sign_in(moderator) + open_checklist_modal + expect(page).to have_css(".mod-checklist-log-table tbody tr", count: 5, wait: 10) + shot("160_checklist_audit_log_many_entries") + end + + it "shows the targeted checklist editor open with a populated row" do + set_checklist(version: 1, max_tl: 2) + PluginStore.set( + NS, + TARGETED_KEY, + [ + { + "id" => "tar-001", + "name" => "App uploaders", + "user_ids" => [user.id], + "version" => 1, + "updated_at" => Time.zone.now.iso8601, + "button_label" => "I agree, post", + "items" => [ + { "label" => "I read the upload rules", "url" => "" }, + ], + }, + ], + ) + sign_in(moderator) + open_checklist_modal + expect(page).to have_css(".mod-checklist-row", wait: 10) + shot("161_targeted_checklist_listed") + end + + it "shows a TL2 user prompted under a TL0-TL2 cap" do + tl2 = + Fabricate(:user, trust_level: TrustLevel[2], refresh_auto_groups: true) + set_checklist(version: 1, max_tl: 2) + sign_in(tl2) + visit(topic.url) + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "First reply at TL2.") + find(".save-or-cancel .create").click + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + shot("162_checklist_tl2_user_prompted") + end + end + + # --------------------------------------------------------------------------- + # Moderator whisper — extra states + # --------------------------------------------------------------------------- + context "moderator whisper — extra states" do + fab!(:target_one) { Fabricate(:user, username: "target_tom") } + fab!(:target_two) { Fabricate(:user, username: "target_tina") } + fab!(:target_three) { Fabricate(:user, username: "target_tara") } + fab!(:stranger) { Fabricate(:user, username: "stranger_sam") } + + def make_whisper(targets:, groups: [], raw: "A whisper.") + whisper = Fabricate(:post, topic: topic, user: moderator, raw: raw) + whisper.custom_fields[TARGETS_FIELD] = targets.map(&:id) + whisper.custom_fields[TARGET_GROUPS_FIELD] = groups.map(&:id) if groups.any? + whisper.save_custom_fields(true) + non_staff_ids = targets.reject { |u| u.staff? }.map(&:id) + if non_staff_ids.any? + topic.custom_fields[PARTICIPANTS_FIELD] = non_staff_ids + topic.save_custom_fields(true) + end + whisper + end + + it "shows the whisper banner with a single target" do + make_whisper(targets: [target_one]) + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-whisper-banner", wait: 15) + shot("163_whisper_banner_one_target") + end + + it "shows the whisper banner with three targets" do + make_whisper(targets: [target_one, target_two, target_three]) + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-whisper-banner", wait: 15) + shot("164_whisper_banner_three_targets") + end + + it "shows a staff-only whisper banner (no user targets)" do + whisper = Fabricate(:post, topic: topic, user: moderator, raw: "Staff only.") + whisper.custom_fields[TARGETS_FIELD] = [] + whisper.save_custom_fields(true) + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-whisper-banner", wait: 15) + shot("165_whisper_banner_staff_only") + end + + it "shows a group-targeted whisper banner" do + group = Fabricate(:group, name: "whisper_squad") + group.add(target_one) + make_whisper(targets: [], groups: [group]) + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-whisper-banner", wait: 15) + shot("166_whisper_banner_group_target") + end + + it "shows the armed-whisper pill with a single user" do + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + find("#topic-footer-buttons .create", match: :first).click + expect(page).to have_css(".d-editor-input", wait: 10) + button_selector = + ".d-editor-button-bar button.mod-whisper-target, " \ + ".d-editor-button-bar button[title='#{I18n.t( + "js.discourse_mod_categories.whisper.toolbar_title", + )}']" + find(button_selector, match: :first).click + expect(page).to have_css(".mod-whisper-target-modal", wait: 10) + chooser = + PageObjects::Components::SelectKit.new( + ".mod-whisper-target-modal .email-group-user-chooser", + ) + chooser.expand + chooser.search(target_one.username) + chooser.select_row_by_value(target_one.username) + chooser.collapse + find(".mod-whisper-target-modal .mod-whisper-confirm").click + expect(page).to have_css(".mod-whisper-armed-pill", wait: 10) + shot("167_armed_whisper_pill_single_user") + end + + it "shows the add-participant modal with a chosen user" do + make_whisper(targets: [target_one]) + sign_in(admin) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-whisper-banner", wait: 15) + + within("#post_#{topic.reload.posts.last.post_number}") do + find(".post-controls .show-more-actions").click if has_css?( + ".post-controls .show-more-actions", + wait: 2, + ) + find(".post-controls .show-post-admin-menu").click + end + expect(page).to have_css(".mod-whisper-add-participant", wait: 10) + find(".mod-whisper-add-participant").click + expect(page).to have_css( + ".mod-whisper-add-participant-modal", + wait: 10, + ) + chooser = + PageObjects::Components::SelectKit.new( + ".mod-whisper-add-participant-modal .email-group-user-chooser", + ) + chooser.expand + chooser.search(stranger.username) + chooser.select_row_by_value(stranger.username) + chooser.collapse + shot("168_whisper_add_participant_modal_user_chosen") + end + + it "shows a non-participant viewing the topic with no whisper visible" do + make_whisper(targets: [target_one]) + sign_in(stranger) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + expect(page).to have_no_css(".mod-whisper-banner") + shot("169_whisper_non_participant_view") + end + + it "shows a recipient's view with the whisper banner visible" do + make_whisper(targets: [target_one]) + sign_in(target_one) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-whisper-banner", wait: 15) + shot("170_whisper_recipient_full_view") + end + end +end diff --git a/discourse-mod/spec/system/mod_note_avatar_badge_visuals_spec.rb b/discourse-mod/spec/system/mod_note_avatar_badge_visuals_spec.rb new file mode 100644 index 0000000..73af6b9 --- /dev/null +++ b/discourse-mod/spec/system/mod_note_avatar_badge_visuals_spec.rb @@ -0,0 +1,171 @@ +# 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", type: :system 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 + until page.evaluate_script( + "Array.from(document.images).every((i) => i.complete)", + ) + sleep 0.1 + end + 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/discourse-mod/spec/system/mod_note_header_indicators_spec.rb b/discourse-mod/spec/system/mod_note_header_indicators_spec.rb new file mode 100644 index 0000000..d4933a8 --- /dev/null +++ b/discourse-mod/spec/system/mod_note_header_indicators_spec.rb @@ -0,0 +1,134 @@ +# 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", type: :system do + fab!(:admin) + fab!(:moderator) + fab!(:user) + fab!(:category) + fab!(:topic) do + Fabricate(:topic, category: category, title: "Share your app build here") + end + fab!(:first_post) do + Fabricate(:post, topic: topic, raw: "Drop your app uploads in this thread.") + end + + 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 + until page.evaluate_script( + "Array.from(document.images).every((i) => i.complete)", + ) + sleep 0.1 + end + 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 `<title>` 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/discourse-mod/spec/system/moderator_messages_spec.rb b/discourse-mod/spec/system/moderator_messages_spec.rb new file mode 100644 index 0000000..de52567 --- /dev/null +++ b/discourse-mod/spec/system/moderator_messages_spec.rb @@ -0,0 +1,730 @@ +# frozen_string_literal: true + +require "rails_helper" + +# End-to-end coverage for the moderator-set messages, capturing a +# screenshot at every meaningful UI step. Screenshots are written to +# tmp/capybara/ and published as the CI artifact. +RSpec.describe "Moderator messages", type: :system do + fab!(:admin) + fab!(:moderator) + fab!(:user) + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category, title: "Share your app build here") } + fab!(:post) { Fabricate(:post, topic: topic, raw: "Drop your app uploads in this thread.") } + + let(:reply_warning) do + "Is this an app upload or link to an app? If it's just a comment or " \ + "question, please post somewhere else." + end + let(:footer_text) do + "This thread is for app uploads only — keep replies on-topic." + end + + before do + SiteSetting.mod_categories_enabled = true + SiteSetting.topic_footer_message_enabled = true + SiteSetting.topic_reply_prompt_enabled = true + SiteSetting.precheck_new_topic_enabled = true + SiteSetting.min_post_length = 5 + SiteSetting.body_min_entropy = 1 + end + + def shot(name) + # Wait for images (avatars) to finish loading so screenshots are not + # captured mid-load. + begin + Timeout.timeout(8) do + until page.evaluate_script( + "Array.from(document.images).every((i) => i.complete)", + ) + sleep 0.1 + end + end + rescue Timeout::Error + # Capture anyway rather than failing the spec over a slow image. + end + page.save_screenshot("#{name}.png") + end + + def open_admin_menu + find(".toggle-admin-menu", match: :first).click + end + + def open_mod_messages_modal + open_admin_menu + find(".mod-topic-messages-button").click + end + + context "a moderator sets the per-topic messages via the admin menu" do + before { sign_in(moderator) } + + it "walks through opening the menu, the modal, saving, and rendering" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + shot("01_topic_page_moderator") + + open_admin_menu + expect(page).to have_css(".mod-topic-messages-button", wait: 10) + shot("02_topic_admin_menu_open") + + find(".mod-topic-messages-button").click + expect(page).to have_css(".mod-topic-messages-modal", wait: 10) + shot("03_mod_messages_modal_empty") + + find(".mod-topic-messages-modal .mod-footer-input").fill_in( + with: footer_text, + ) + shot("04_mod_messages_footer_filled") + + # The before-reply prompt has moved to the Prompt Checklist modal; + # this modal no longer carries it. + expect(page).to have_no_css(".mod-topic-messages-modal .mod-reply-input") + expect(page).to have_no_css( + ".mod-topic-messages-modal .mod-reply-audience-input", + ) + shot("05_mod_messages_both_filled") + + find(".mod-topic-messages-modal .mod-messages-save").click + expect(page).to have_no_css(".mod-topic-messages-modal", wait: 10) + + expect(page).to have_css( + ".topic-footer-message", + text: "This thread is for app uploads only", + wait: 10, + ) + shot("06_footer_rendered_after_save") + + expect(topic.custom_fields["mod_topic_footer_message"]).to eq(footer_text) + end + + it "shows both modal sections — the reply prompt is no longer here" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_mod_messages_modal + expect(page).to have_css(".mod-topic-messages-modal", wait: 10) + # The modal is split into a "visible to everyone" section and a + # "moderation" section. + expect(page).to have_css(".mod-messages-section", minimum: 2) + shot("80_mod_messages_modal_sections") + + # The before-reply prompt + audience dropdown have moved out of + # this modal entirely; staff configure both under Prompt Checklist. + expect(page).to have_no_css(".mod-reply-input") + expect(page).to have_no_css(".mod-reply-audience-input") + + find(".mod-require-approval-input").click + find(".mod-messages-save").click + # A success toast confirms the save. + expect(page).to have_css(".fk-d-default-toast", wait: 10) + shot("82_mod_messages_save_toast") + end + + it "re-opens the modal pre-filled for editing and updates the values" do + topic.custom_fields["mod_topic_footer_message"] = "An existing footer" + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + shot("07_topic_with_existing_footer") + + open_mod_messages_modal + expect(page).to have_css(".mod-topic-messages-modal", wait: 10) + expect(find(".mod-footer-input").value).to eq("An existing footer") + shot("08_mod_messages_modal_editing") + + find(".mod-footer-input").fill_in(with: "Updated footer notice") + shot("09_mod_messages_modal_edited") + + find(".mod-messages-save").click + expect(page).to have_css( + ".topic-footer-message", + text: "Updated footer notice", + wait: 10, + ) + shot("10_footer_updated_after_edit") + end + + it "can clear the messages by saving blank fields" do + topic.custom_fields["mod_topic_footer_message"] = footer_text + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-message", wait: 10) + shot("11_footer_before_clearing") + + open_mod_messages_modal + find(".mod-footer-input").fill_in(with: "") + shot("12_mod_messages_modal_cleared") + find(".mod-messages-save").click + + expect(page).to have_no_css(".topic-footer-message", wait: 10) + shot("13_footer_removed_after_clearing") + end + + it "can require reply approval for the topic from the modal" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_mod_messages_modal + expect(page).to have_css(".mod-topic-messages-modal", wait: 10) + + find(".mod-require-approval-input").click + shot("38_mod_messages_require_approval_checked") + + find(".mod-messages-save").click + expect(page).to have_no_css(".mod-topic-messages-modal", wait: 10) + expect( + topic.reload.custom_fields["mod_topic_require_reply_approval"], + ).to eq(true) + end + + it "sets a staff-only private note from the modal and shows it" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_mod_messages_modal + expect(page).to have_css(".mod-topic-messages-modal", wait: 10) + + find(".mod-private-note-input").fill_in( + with: "Keep an eye on this thread — staff only.", + ) + find(".mod-messages-save").click + expect(page).to have_no_css(".mod-topic-messages-modal", wait: 10) + + expect(page).to have_css( + ".mod-private-note", + text: "Keep an eye on this thread", + wait: 10, + ) + # Shown like a post — the moderator who set it, with avatar + name. + expect(page).to have_css( + ".mod-private-note .mod-private-note-username", + text: moderator.name.presence || moderator.username, + ) + expect(page).to have_css(".mod-private-note .mod-private-note-avatar") + shot("40_private_note_staff_view") + end + + it "lets a moderator reply to the note thread" do + topic.custom_fields["mod_topic_private_note"] = "Initial moderator note." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_created_at"] = + 2.hours.ago.iso8601 + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-private-note", wait: 10) + # The note shows a relative timestamp and a reply button. + expect(page).to have_css(".mod-private-note .mod-private-note-time") + expect(page).to have_css(".mod-private-note-reply-button") + shot("46_private_note_with_timestamp_and_reply_button") + + find(".mod-private-note-reply-button").click + find(".mod-private-note-reply-input").fill_in( + with: "Thanks — I'll keep an eye on this.", + ) + shot("47_private_note_reply_box") + + find(".mod-private-note-reply-box .btn-primary").click + # The reply body is cooked as markdown, so straight quotes become + # typographic — match a fragment without an apostrophe. + expect(page).to have_css( + ".mod-private-note-reply", + text: "keep an eye on this", + wait: 10, + ) + shot("48_private_note_reply_added") + expect( + topic.reload.custom_fields["mod_topic_private_note_replies"], + ).to be_present + end + + it "lets a moderator edit and delete a reply in the note thread" do + topic.custom_fields["mod_topic_private_note"] = "Initial moderator note." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_created_at"] = + 2.hours.ago.iso8601 + topic.custom_fields["mod_topic_private_note_replies"] = [ + { + "id" => "aaaaaaaaaaaaaaaa", + "user_id" => moderator.id, + "raw" => "Original reply text.", + "created_at" => 1.hour.ago.iso8601, + }, + ] + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-private-note-reply", wait: 10) + + # Edit the reply inline. + find(".mod-private-note-reply .mod-private-note-edit-reply").click + find(".mod-private-note-edit-input").fill_in( + with: "Updated reply text.", + ) + shot("92_private_note_reply_editing") + find(".mod-private-note-reply-box .btn-primary").click + expect(page).to have_css( + ".mod-private-note-reply", + text: "Updated reply text", + wait: 10, + ) + shot("93_private_note_reply_edited") + expect( + topic.reload.custom_fields["mod_topic_private_note_replies"].first[ + "raw" + ], + ).to eq("Updated reply text.") + + # Delete the reply (confirm via the dialog). + find(".mod-private-note-reply .mod-private-note-delete-reply").click + find(".dialog-footer .btn-primary").click + expect(page).to have_no_css(".mod-private-note-reply", wait: 10) + shot("94_private_note_reply_deleted") + expect( + topic.reload.custom_fields["mod_topic_private_note_replies"], + ).to eq([]) + end + end + + context "the reply prompt fires for a user" do + before do + topic.custom_fields["mod_topic_reply_prompt"] = reply_warning + topic.save_custom_fields(true) + sign_in(user) + end + + it "warns the user, supports Go back, and supports Post anyway" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + shot("14_user_views_topic") + + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "Here is a reply to this topic.") + shot("15_user_reply_composer") + + find(".save-or-cancel .create").click + expect(page).to have_css( + ".dialog-body", + text: "Is this an app upload or link to an app?", + wait: 10, + ) + shot("16_reply_prompt_dialog") + + find(".dialog-footer button", text: "Go back").click + expect(page).to have_css(".d-editor-input") + shot("17_reply_prompt_go_back") + + find(".save-or-cancel .create").click + expect(page).to have_css(".dialog-body", wait: 10) + find(".dialog-footer button", text: "Post anyway").click + expect(page).to have_no_css(".dialog-body", wait: 10) + shot("18_reply_prompt_post_anyway") + end + + it "does not show the admin menu or mod-messages button to a regular user" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + expect(page).to have_no_css(".toggle-admin-menu") + expect(page).to have_no_css(".mod-topic-messages-button") + shot("19_regular_user_no_mod_button") + end + end + + context "the footer message renders for a regular user" do + before do + topic.custom_fields["mod_topic_footer_message"] = footer_text + topic.save_custom_fields(true) + sign_in(user) + end + + it "shows the moderator footer at the bottom of the topic" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css( + ".topic-footer-message", + text: "This thread is for app uploads only", + wait: 10, + ) + shot("20_footer_visible_to_user") + end + + it "renders the footer message as HTML" do + topic.custom_fields["mod_topic_footer_message"] = + "<strong>Important:</strong> read the rules." + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css( + ".topic-footer-message strong", + text: "Important:", + wait: 10, + ) + shot("36_footer_html_rendered") + end + + it "still shows the footer on a closed topic" do + topic.update!(closed: true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".topic-footer-message", wait: 10) + shot("37_footer_on_closed_topic") + end + + it "never shows the staff-only private note to a regular user" do + topic.custom_fields["mod_topic_private_note"] = "Staff eyes only" + topic.save_custom_fields(true) + + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + expect(page).to have_no_css(".mod-private-note") + shot("41_private_note_hidden_from_user") + end + end + + context "a moderator sets the per-category new-topic prompt" do + before { sign_in(moderator) } + + it "saves the prompt from the category settings screen" do + visit("/c/#{category.slug}/edit/settings") + expect(page).to have_css(".mod-new-topic-prompt", wait: 10) + shot("21_category_settings_prompt_field") + + find(".mod-new-topic-prompt-input").fill_in( + with: "Please search for an existing topic before starting a new one.", + ) + shot("22_category_prompt_filled") + + find(".mod-save-new-topic-prompt").click + expect(page).to have_css(".mod-saved-indicator", wait: 10) + shot("23_category_prompt_saved") + + expect( + category.reload.custom_fields["mod_category_new_topic_prompt"], + ).to eq( + "Please search for an existing topic before starting a new one.", + ) + end + + it "shows a live preview of the new-topic prompt with a clickable link" do + visit("/c/#{category.slug}/edit/settings") + expect(page).to have_css(".mod-new-topic-prompt", wait: 10) + + find(".mod-new-topic-prompt-input").fill_in( + with: + "Read the guidelines at https://example.com/guidelines before posting.", + ) + # The preview renders the typed text the same way the dialog will, + # turning the URL into a real link. + expect(page).to have_css(".mod-prompt-preview", wait: 10) + link = + find( + ".mod-prompt-preview-body a[href='https://example.com/guidelines']", + ) + expect(link[:target]).to eq("_blank") + shot("83_category_prompt_live_preview") + + # The audience combo-box caps which trust levels see the prompt. + audience = + PageObjects::Components::SelectKit.new(".mod-new-topic-audience-input") + audience.expand + shot("84_category_prompt_audience_dropdown") + audience.select_row_by_value("2") + + find(".mod-save-new-topic-prompt").click + expect(page).to have_css(".mod-saved-indicator", wait: 10) + expect( + category.reload.custom_fields["mod_category_new_topic_prompt_max_tl"], + ).to eq(2) + end + end + + context "a moderator pins a post to the bottom" do + # A long thread (12 posts: the OP + 11 replies) so posts can be pinned + # at various distances from the end. + fab!(:thread_posts) do + (1..11).map do |i| + Fabricate( + :post, + topic: topic, + raw: "Reply number #{i} in this long thread, long enough to pass.", + ) + end + end + + let(:last_post) { thread_posts[10] } # post 12 + let(:second_to_last) { thread_posts[9] } # post 11 + let(:third_to_last) { thread_posts[8] } # post 10 + let(:tenth_to_last) { thread_posts[1] } # post 3 + + def pin!(target) + topic.custom_fields["mod_topic_pinned_post_id"] = target.id + topic.save_custom_fields(true) + end + + def visit_at(target) + visit("/t/#{topic.slug}/#{topic.id}/#{target.post_number}") + expect(page).to have_css("#topic-title", wait: 10) + end + + it "pins a post from the post admin (moderator actions) menu" do + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + within(first(".topic-post")) do + find(".show-more-actions").click if has_css?( + ".show-more-actions", + wait: 2, + ) + find(".show-post-admin-menu", match: :first).click + end + expect(page).to have_css(".mod-pin-post-to-bottom", wait: 10) + shot("25_post_admin_menu_pin_option") + + find(".mod-pin-post-to-bottom").click + expect(page).to have_css(".topic-footer-pinned-post", wait: 10) + shot("26_post_pinned_to_bottom") + + expect( + topic.reload.custom_fields["mod_topic_pinned_post_id"], + ).to be_present + end + + it "renders a copy at the bottom and a pin badge on the original" do + pin!(tenth_to_last) + sign_in(user) + visit_at(tenth_to_last) + + expect(page).to have_css(".topic-footer-pinned-post", wait: 10) + expect(page).to have_css(".topic-footer-pinned-post .pinned-post-badge") + expect(page).to have_css(".topic-footer-pinned-post .pinned-post-avatar") + expect(page).to have_css(".topic-footer-pinned-post a.pinned-post-jump") + # The original post in the stream is badged too. + expect(page).to have_css(".mod-pinned-in-stream-badge") + expect(page).to have_no_css( + ".topic-footer-pinned-post.topic-footer-message", + ) + shot("39_pinned_post_as_bottom_post") + end + + it "shows a footer message and a pinned post together" do + topic.custom_fields["mod_topic_footer_message"] = footer_text + topic.custom_fields["mod_topic_pinned_post_id"] = third_to_last.id + topic.save_custom_fields(true) + sign_in(user) + visit_at(third_to_last) + + expect(page).to have_css(".topic-footer-pinned-post", wait: 10) + expect(page).to have_css( + ".topic-footer-message:not(.topic-footer-pinned-post)", + text: "This thread is for app uploads only", + ) + shot("27_footer_message_and_pinned_post_together") + end + + it "unpins a post from the post admin menu" do + pin!(tenth_to_last) + sign_in(moderator) + visit_at(tenth_to_last) + expect(page).to have_css(".topic-footer-pinned-post", wait: 10) + shot("28_pinned_post_before_unpin") + + within(find("#post_#{tenth_to_last.post_number}")) do + find(".show-more-actions").click if has_css?( + ".show-more-actions", + wait: 2, + ) + find(".show-post-admin-menu", match: :first).click + end + find(".mod-pin-post-to-bottom").click + + expect(page).to have_no_css(".topic-footer-pinned-post", wait: 10) + shot("29_pinned_post_after_unpin") + expect( + topic.reload.custom_fields["mod_topic_pinned_post_id"], + ).to be_blank + end + + context "depending on how far the pinned post is from the end" do + before { sign_in(user) } + + it "the last post: only a pin badge on the original, no copy" do + pin!(last_post) + visit_at(last_post) + + expect(page).to have_css(".mod-pinned-in-stream-badge", wait: 10) + expect(page).to have_no_css(".topic-footer-pinned-post") + shot("42_pinned_last_post") + end + + it "the second-to-last post: badge on the original plus a bottom copy" do + pin!(second_to_last) + visit_at(second_to_last) + + expect(page).to have_css(".mod-pinned-in-stream-badge", wait: 10) + expect(page).to have_css(".topic-footer-pinned-post") + shot("43_pinned_second_to_last") + end + + it "the third-to-last post: badge on the original plus a bottom copy" do + pin!(third_to_last) + visit_at(third_to_last) + + expect(page).to have_css(".mod-pinned-in-stream-badge", wait: 10) + expect(page).to have_css(".topic-footer-pinned-post") + shot("44_pinned_third_to_last") + end + + it "the tenth-to-last post: badge on the original plus a bottom copy" do + pin!(tenth_to_last) + visit_at(tenth_to_last) + + expect(page).to have_css(".mod-pinned-in-stream-badge", wait: 10) + expect(page).to have_css(".topic-footer-pinned-post") + shot("45_pinned_tenth_to_last") + end + end + end + + context "navigating between topics with and without moderator messages" do + fab!(:other_topic) do + Fabricate(:topic, category: category, title: "A plain unmoderated thread") + end + fab!(:other_post) do + Fabricate(:post, topic: other_topic, raw: "Just an ordinary topic with nothing special.") + end + + before do + topic.custom_fields["mod_topic_footer_message"] = footer_text + topic.custom_fields["mod_topic_private_note"] = "Watch this one — staff only." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_created_at"] = 2.hours.ago.iso8601 + topic.save_custom_fields(true) + sign_in(moderator) + end + + # Navigates to a topic by clicking its title in the category topic list, + # exercising the SPA router (no full page reload). A full reload would + # build fresh connector instances and hide the staleness bug. + def navigate_to_topic(title) + visit("/c/#{category.slug}/#{category.id}") + expect(page).to have_css(".topic-list-item", wait: 10) + find(".topic-list-item a.title", text: title, match: :first).click + expect(page).to have_css("#topic-title", wait: 10) + end + + it "does not carry the footer or private note onto a topic that has neither" do + navigate_to_topic("Share your app build here") + expect(page).to have_css(".topic-footer-message", text: footer_text, wait: 10) + expect(page).to have_css(".mod-private-note", wait: 10) + shot("89_messages_on_first_topic") + + # SPA navigation to a topic with no moderator messages: the reused + # connector must drop the previous topic's footer/note. + navigate_to_topic("A plain unmoderated thread") + expect(page).to have_no_css(".topic-footer-message") + expect(page).to have_no_css(".mod-private-note") + shot("90_no_stale_messages_on_second_topic") + + # Navigate back — the original topic's messages reappear. + navigate_to_topic("Share your app build here") + expect(page).to have_css(".topic-footer-message", text: footer_text, wait: 10) + expect(page).to have_css(".mod-private-note", wait: 10) + shot("91_messages_restored_on_first_topic") + end + end + + context "the moderator-notes user-menu tab" do + before do + topic.custom_fields["mod_topic_private_note"] = + "Please review this thread." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_activity_at"] = Time + .zone + .now + .iso8601 + topic.save_custom_fields(true) + sign_in(moderator) + end + + it "shows a shield tab in the user menu listing moderator notes" do + visit("/") + find(".header-dropdown-toggle.current-user").click + expect(page).to have_css( + "#user-menu-button-discourse-mod-notes", + wait: 10, + ) + shot("49_user_menu_shield_tab") + + find("#user-menu-button-discourse-mod-notes").click + expect(page).to have_css( + ".mod-notes-panel .mod-notes-item", + text: "Please review this thread", + wait: 10, + ) + shot("50_moderator_notes_tab_panel") + end + end + + context "a moderator note notifies another staff member" do + fab!(:other_moderator) { Fabricate(:moderator) } + + before do + # The note (and its fan-out notification) is created directly so the + # spec focuses on how the OTHER staff member sees and follows it. + topic.custom_fields["mod_topic_private_note"] = "Please review this thread." + topic.custom_fields["mod_topic_private_note_user_id"] = moderator.id + topic.custom_fields["mod_topic_private_note_created_at"] = + Time.zone.now.iso8601 + topic.custom_fields["mod_topic_private_note_activity_at"] = + Time.zone.now.iso8601 + topic.save_custom_fields(true) + + note_url = "#{topic.relative_url}/#{topic.reload.highest_post_number}" + Notification.create!( + notification_type: Notification.types[:custom], + user_id: other_moderator.id, + topic_id: topic.id, + post_number: topic.highest_post_number, + high_priority: true, + data: { + topic_title: topic.title, + display_username: moderator.username, + mod_note: true, + url: note_url, + message: "discourse_mod_categories.note_notification", + title: "discourse_mod_categories.note_notification_title", + }.to_json, + ) + sign_in(other_moderator) + end + + it "shows a clear notification that links to the moderator note" do + visit("/") + find(".header-dropdown-toggle.current-user").click + + # The moderator-note notification renders with accurate, + # self-describing text in the bell list. + expect(page).to have_css(".notification.custom", wait: 10) + notification = + find(".notification.custom", text: moderator.username, match: :first) + expect(notification.text).to include("moderator note") + # The link carries the note href and the "Moderator note" hover title. + link = notification.find("a") + expect(link[:title]).to eq("Moderator note") + expect(link[:href]).to include("/t/") + shot("95_mod_note_notification_in_user_menu") + + link.click + + # Clicking the notification lands on the topic — at the note, the same + # target the notes feed uses. + expect(page).to have_css("#topic-title", wait: 10) + expect(current_path).to start_with("/t/") + expect(current_path).to include(topic.id.to_s) + shot("96_mod_note_notification_opened") + end + end +end diff --git a/discourse-mod/spec/system/precheck_flows_spec.rb b/discourse-mod/spec/system/precheck_flows_spec.rb new file mode 100644 index 0000000..2765874 --- /dev/null +++ b/discourse-mod/spec/system/precheck_flows_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "rails_helper" + +# End-to-end coverage for the composer precheck prompts: the per-category +# "before a new topic" prompt and the per-topic "before a reply" prompt. +# Screenshots are written to tmp/capybara/ for the CI artifact. +RSpec.describe "Precheck prompt flows", type: :system do + fab!(:admin) + fab!(:moderator) + fab!(:user) + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category, title: "Existing app thread") } + fab!(:post) { Fabricate(:post, topic: topic, raw: "The original post in this thread.") } + + before do + SiteSetting.mod_categories_enabled = true + SiteSetting.precheck_new_topic_enabled = true + SiteSetting.topic_reply_prompt_enabled = true + SiteSetting.topic_footer_message_enabled = true + SiteSetting.min_topic_title_length = 5 + SiteSetting.min_first_post_length = 5 + SiteSetting.min_post_length = 5 + SiteSetting.title_min_entropy = 1 + SiteSetting.body_min_entropy = 1 + end + + def shot(name) + begin + Timeout.timeout(8) do + until page.evaluate_script( + "Array.from(document.images).every((i) => i.complete)", + ) + sleep 0.1 + end + end + rescue Timeout::Error + # Capture anyway rather than failing the spec over a slow image. + end + page.save_screenshot("#{name}.png") + end + + context "the per-category new-topic prompt" do + before do + category.custom_fields["mod_category_new_topic_prompt"] = + "Before posting: is this a brand-new app, or a duplicate of an " \ + "existing thread?" + category.save_custom_fields(true) + end + + def open_new_topic_in(target_category) + visit("/") + find("#create-topic").click + expect(page).to have_css(".d-editor-input", wait: 10) + category_chooser = + PageObjects::Components::SelectKit.new(".category-chooser") + category_chooser.expand + category_chooser.select_row_by_value(target_category.id) + end + + it "warns when starting a new topic and supports Go back then Post anyway" do + sign_in(admin) + open_new_topic_in(category) + + find("#reply-title").fill_in(with: "My brand new app release") + find(".d-editor-input").fill_in(with: "Details about my new app go here.") + shot("27_new_topic_composer_in_category") + + find(".save-or-cancel .create").click + expect(page).to have_css( + ".dialog-body", + text: "is this a brand-new app", + wait: 10, + ) + shot("28_new_topic_prompt_dialog") + + find(".dialog-footer button", text: "Go back").click + expect(page).to have_css(".d-editor-input") + shot("29_new_topic_prompt_go_back") + + find(".save-or-cancel .create").click + expect(page).to have_css(".dialog-body", wait: 10) + find(".dialog-footer button", text: "Post anyway").click + expect(page).to have_css( + ".fancy-title", + text: "My brand new app release", + wait: 10, + ) + shot("30_new_topic_posted_after_confirm") + end + + it "does not prompt in a category that has no prompt set" do + plain_category = Fabricate(:category) + sign_in(admin) + open_new_topic_in(plain_category) + + find("#reply-title").fill_in(with: "A topic in a plain category") + find(".d-editor-input").fill_in(with: "No prompt should appear here.") + find(".save-or-cancel .create").click + + expect(page).to have_css( + ".fancy-title", + text: "A topic in a plain category", + wait: 10, + ) + expect(page).to have_no_css(".dialog-body") + shot("31_no_prompt_plain_category") + end + end + + context "the per-topic reply prompt" do + it "does not prompt when the topic has no reply prompt set" do + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "A reply with no prompt configured.") + find(".save-or-cancel .create").click + + expect(page).to have_no_css(".dialog-body", wait: 5) + shot("33_reply_no_prompt") + end + + it "prompts with the moderator's message and posts after Post anyway" do + topic.custom_fields["mod_topic_reply_prompt"] = + "Is this an app upload or link to an app? If it's just a comment " \ + "or question, please post somewhere else." + topic.save_custom_fields(true) + + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "Here is my app upload reply.") + find(".save-or-cancel .create").click + + expect(page).to have_css( + ".dialog-body", + text: "Is this an app upload", + wait: 10, + ) + shot("34_reply_prompt_dialog") + + find(".dialog-footer button", text: "Post anyway").click + expect(page).to have_no_css(".dialog-body", wait: 10) + shot("35_reply_posted_after_confirm") + end + + it "renders a clickable link inside the reply precheck dialog" do + topic.custom_fields["mod_topic_reply_prompt"] = + "Please review the guidelines at https://example.com/guidelines " \ + "before replying." + topic.save_custom_fields(true) + + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "Here is my reply to the thread.") + find(".save-or-cancel .create").click + + expect(page).to have_css(".dialog-body", wait: 10) + # The shared linkify helper turns the http(s) URL into a real anchor. + link = + find( + ".dialog-body a[href='https://example.com/guidelines']", + wait: 10, + ) + expect(link[:target]).to eq("_blank") + expect(link[:rel]).to include("noopener") + shot("88_precheck_dialog_clickable_link") + end + end +end diff --git a/discourse-mod/spec/system/topic_prompt_checklist_spec.rb b/discourse-mod/spec/system/topic_prompt_checklist_spec.rb new file mode 100644 index 0000000..9f52d13 --- /dev/null +++ b/discourse-mod/spec/system/topic_prompt_checklist_spec.rb @@ -0,0 +1,312 @@ +# frozen_string_literal: true + +require "rails_helper" + +# End-to-end coverage for the per-topic prompt checklist: staff add the +# checklist to a topic via the wrench-menu "Prompt Checklist" entry, +# another user replies and is prompted, accepts, posts, and is not +# prompted again until staff bump the version. Screenshots are written +# to tmp/capybara/ and published as the CI artifact. +RSpec.describe "Per-topic prompt checklist", type: :system do + fab!(:moderator) + fab!(:user) do + Fabricate(:user, trust_level: TrustLevel[2], refresh_auto_groups: true) + end + fab!(:tl0_user) do + Fabricate(:user, trust_level: TrustLevel[0], refresh_auto_groups: true) + end + fab!(:category) + fab!(:topic) do + Fabricate(:topic, category: category, title: "Gated reply thread") + end + fab!(:first_post) do + Fabricate(:post, topic: topic, raw: "The original post in this thread.") + end + + TOPIC_FIELD = DiscourseModCategories::TOPIC_PROMPT_CHECKLIST_FIELD + USER_FIELD = DiscourseModCategories::USER_TOPIC_CHECKLIST_FIELD + + before do + SiteSetting.mod_categories_enabled = true + SiteSetting.min_post_length = 5 + SiteSetting.body_min_entropy = 1 + SiteSetting.auto_silence_fast_typers_on_first_post = false + SiteSetting.approve_unless_allowed_groups = + Group::AUTO_GROUPS[:trust_level_0].to_s + SiteSetting.approve_post_count = 0 + end + + def shot(name) + begin + Timeout.timeout(8) do + until page.evaluate_script( + "Array.from(document.images).every((i) => i.complete)", + ) + sleep 0.1 + end + end + rescue Timeout::Error + end + page.save_screenshot("#{name}.png") + end + + def set_topic_checklist(version:, items:, button_label: "I agree, post my reply") + topic.custom_fields[TOPIC_FIELD] = { + "version" => version, + "items" => items, + "button_label" => button_label, + "updated_at" => Time.zone.now.iso8601, + } + topic.save_custom_fields(true) + end + + def open_admin_menu + find(".toggle-admin-menu", match: :first).click + end + + def open_reply + find("#topic-footer-buttons .create", match: :first).click + find(".d-editor-input").fill_in(with: "Here is my reply on the forum.") + find(".save-or-cancel .create").click + end + + it "lets staff open the editor, add items, save, and re-prompt a user" do + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + shot("171_topic_page_moderator") + + open_admin_menu + expect(page).to have_css( + ".mod-topic-prompt-checklist-button", + wait: 10, + ) + shot("172_topic_admin_menu_with_prompt_checklist") + + find(".mod-topic-prompt-checklist-button").click + expect(page).to have_css(".mod-topic-prompt-checklist-modal", wait: 10) + expect(page).to have_css(".mod-topic-prompt-checklist-inactive", wait: 10) + shot("173_topic_prompt_checklist_modal_empty") + + find(".mod-topic-prompt-checklist-add").click + all(".mod-topic-prompt-checklist-modal .mod-checklist-row-label").last + .fill_in(with: "Confirm this is a real reply, not spam") + find(".mod-topic-prompt-checklist-add").click + all(".mod-topic-prompt-checklist-modal .mod-checklist-row-label").last + .fill_in(with: "I read the topic guidelines") + find(".mod-topic-prompt-checklist-button-label").fill_in( + with: "I agree, post my reply", + ) + shot("174_topic_prompt_checklist_modal_filled") + + find(".mod-topic-prompt-checklist-save").click + expect(page).to have_css(".fk-d-toast", wait: 10) + shot("175_topic_prompt_checklist_saved") + + stored = topic.reload.custom_fields[TOPIC_FIELD] + expect(stored["version"]).to eq(1) + expect(stored["items"].size).to eq(2) + end + + it "prompts another user replying to the topic, then leaves them alone after accept" do + set_topic_checklist( + version: 1, + items: [ + { "label" => "Confirm this is a real reply, not spam", "url" => "" }, + { "label" => "I read the topic guidelines", "url" => "" }, + ], + ) + + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + open_reply + + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + shot("176_topic_prompt_checklist_user_prompted") + + all(".mod-checklist-checkbox").each(&:click) + shot("177_topic_prompt_checklist_user_all_checked") + + find(".mod-checklist-confirm").click + expect(page).to have_css(".topic-post", minimum: 2, wait: 10) + shot("178_topic_prompt_checklist_user_reply_posted") + + # Reload-free second reply: not prompted again. + open_reply + expect(page).to have_css(".topic-post", minimum: 3, wait: 10) + expect(page).to have_no_css(".mod-first-post-checklist-modal") + shot("179_topic_prompt_checklist_user_second_reply_no_prompt") + + map = user.reload.custom_fields[USER_FIELD] + expect(map[topic.id.to_s]).to eq(1) + end + + it "re-prompts the same user after a version bump" do + set_topic_checklist( + version: 1, + items: [{ "label" => "Rule one", "url" => "" }], + ) + user.upsert_custom_fields(USER_FIELD => { topic.id.to_s => 1 }) + + # Staff bump. + set_topic_checklist( + version: 2, + items: [{ "label" => "Updated rule one", "url" => "" }], + ) + + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + open_reply + + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + shot("180_topic_prompt_checklist_user_reprompt_after_bump") + end + + it "shows the statement-mode prompt with a single accept button" do + topic.custom_fields[TOPIC_FIELD] = { + "version" => 1, + "mode" => "statement", + "statement" => "Please confirm you have read the topic guidelines.", + "items" => [], + "frequency" => "once", + "max_tl" => 4, + "button_label" => "I confirm", + "updated_at" => Time.zone.now.iso8601, + } + topic.save_custom_fields(true) + + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + open_reply + + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + expect(page).to have_css(".mod-checklist-statement", wait: 10) + expect(page).to have_no_css(".mod-checklist-checkbox") + # The confirm button is enabled immediately since there is nothing + # to tick. + expect(page).to have_css(".mod-checklist-confirm:not([disabled])") + shot("181_topic_prompt_statement_mode_user_prompted") + + find(".mod-checklist-confirm").click + expect(page).to have_css(".topic-post", minimum: 2, wait: 10) + shot("182_topic_prompt_statement_mode_posted") + end + + it "re-prompts on every reply when frequency is every_reply" do + topic.custom_fields[TOPIC_FIELD] = { + "version" => 1, + "mode" => "checklist", + "items" => [{ "label" => "Confirm on every reply", "url" => "" }], + "frequency" => "every_reply", + "max_tl" => 4, + "button_label" => "I confirm", + "updated_at" => Time.zone.now.iso8601, + } + topic.save_custom_fields(true) + + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + open_reply + + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + all(".mod-checklist-checkbox").each(&:click) + find(".mod-checklist-confirm").click + expect(page).to have_css(".topic-post", minimum: 2, wait: 10) + shot("183_topic_prompt_every_reply_first_reply") + + # Second reply: the checklist must fire again — frequency=every_reply + # disregards the user's previously-recorded acceptance. + open_reply + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + shot("184_topic_prompt_every_reply_second_reply_reprompted") + end + + it "does not prompt a user above the max_tl cap" do + topic.custom_fields[TOPIC_FIELD] = { + "version" => 1, + "mode" => "checklist", + "items" => [{ "label" => "Only TL0/TL1 are prompted", "url" => "" }], + "frequency" => "once", + "max_tl" => 1, + "button_label" => "Confirm", + "updated_at" => Time.zone.now.iso8601, + } + topic.save_custom_fields(true) + + # `user` is TL2 — above the TL1 cap, so they should NOT be prompted. + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + open_reply + + expect(page).to have_css(".topic-post", minimum: 2, wait: 10) + expect(page).to have_no_css(".mod-first-post-checklist-modal") + shot("185_topic_prompt_max_tl_cap_skipped_for_higher_tl") + end + + it "lets staff switch the editor to Statement mode, save, and prompts a user" do + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_admin_menu + expect(page).to have_css(".mod-topic-prompt-checklist-button", wait: 10) + find(".mod-topic-prompt-checklist-button").click + expect(page).to have_css(".mod-topic-prompt-checklist-modal", wait: 10) + + mode = + PageObjects::Components::SelectKit.new( + ".mod-topic-prompt-checklist-mode", + ) + mode.expand + mode.select_row_by_value("statement") + expect(page).to have_css(".mod-topic-prompt-checklist-statement", wait: 10) + find(".mod-topic-prompt-checklist-statement").fill_in( + with: "Please read the rules before posting.", + ) + find(".mod-topic-prompt-checklist-button-label").fill_in( + with: "I agree", + ) + find(".mod-topic-prompt-checklist-save").click + expect(page).to have_css(".fk-d-toast", wait: 10) + shot("187_topic_prompt_statement_editor_saved") + + stored = topic.reload.custom_fields[TOPIC_FIELD] + expect(stored["mode"]).to eq("statement") + expect(stored["statement"]).to eq( + "Please read the rules before posting.", + ) + + # A regular user replying is now prompted with the statement modal, + # with the accept button enabled immediately and no checkboxes. + sign_in(user) + visit("/t/#{topic.slug}/#{topic.id}") + open_reply + + expect(page).to have_css(".mod-first-post-checklist-modal", wait: 10) + expect(page).to have_css(".mod-checklist-statement", wait: 10) + expect(page).to have_no_css(".mod-checklist-checkbox") + expect(page).to have_css(".mod-checklist-confirm:not([disabled])") + shot("188_topic_prompt_statement_editor_then_user_prompted") + + find(".mod-checklist-confirm").click + expect(page).to have_css(".topic-post", minimum: 2, wait: 10) + shot("189_topic_prompt_statement_editor_then_user_posted") + end + + it "no longer shows the before-reply prompt field in the Moderator Actions modal" do + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + find(".toggle-admin-menu", match: :first).click + expect(page).to have_css(".mod-topic-messages-button", wait: 10) + find(".mod-topic-messages-button").click + expect(page).to have_css(".mod-topic-messages-modal", wait: 10) + + # The legacy section is gone — moderators now configure the prompt + # exclusively through the Prompt Checklist entry. + expect(page).to have_no_css(".mod-reply-input") + expect(page).to have_no_css(".mod-reply-audience-input") + shot("186_moderator_actions_modal_no_reply_prompt") + end +end diff --git a/discourse-mod/spec/system/whisper_spec.rb b/discourse-mod/spec/system/whisper_spec.rb new file mode 100644 index 0000000..c2f6e59 --- /dev/null +++ b/discourse-mod/spec/system/whisper_spec.rb @@ -0,0 +1,420 @@ +# frozen_string_literal: true + +require "rails_helper" + +# End-to-end coverage for the moderator whisper feature: arming a whisper +# from the composer, the target modal, posting, audience visibility, and the +# non-participant gate. A screenshot is captured at each meaningful step; +# screenshots land in tmp/capybara/ and are published as the CI artifact. +RSpec.describe "Moderator whisper", type: :system do + fab!(:moderator) { Fabricate(:moderator, username: "mod_morgan") } + fab!(:admin) { Fabricate(:admin, username: "admin_ada") } + fab!(:recipient) { Fabricate(:user, username: "target_tom") } + fab!(:other_target) { Fabricate(:user, username: "target_tina") } + fab!(:stranger) { Fabricate(:user, username: "stranger_sam") } + fab!(:category) + fab!(:topic) do + Fabricate(:topic, category: category, title: "A thread to whisper in") + end + fab!(:op) { Fabricate(:post, topic: topic, raw: "The original post here.") } + fab!(:group_member) { Fabricate(:user, username: "group_gabe") } + fab!(:whisper_group) { Fabricate(:group, name: "whisper_squad") } + + let(:targets_field) { DiscourseModCategories::POST_WHISPER_TARGETS_FIELD } + let(:groups_field) do + DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD + end + let(:participants_field) do + DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD + end + + before do + # `mod_categories_enabled` is the plugin's master switch — the whole + # plugin's frontend assets only load when it is on. + 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 + end + + def shot(name) + begin + Timeout.timeout(8) do + until page.evaluate_script( + "Array.from(document.images).every((i) => i.complete)", + ) + sleep 0.1 + end + end + rescue Timeout::Error + # Capture anyway rather than failing the spec over a slow image. + end + page.save_screenshot("#{name}.png") + end + + def open_reply_composer + find("#topic-footer-buttons .create", match: :first).click + expect(page).to have_css(".d-editor-input", wait: 10) + end + + def whisper_button_selector + ".d-editor-button-bar button.mod-whisper-target, " \ + ".d-editor-button-bar button[title='#{I18n.t( + "js.discourse_mod_categories.whisper.toolbar_title", + )}']" + end + + def whisper_toolbar_button + find(whisper_button_selector, match: :first) + end + + def make_whisper_post(targets, raw: "A staff whisper for the audience.") + whisper = Fabricate(:post, topic: topic, user: moderator, raw: raw) + whisper.custom_fields[targets_field] = targets + whisper.save_custom_fields(true) + non_staff = User.where(id: targets).where(admin: false, moderator: false) + if non_staff.any? + topic.custom_fields[participants_field] = non_staff.pluck(:id) + topic.save_custom_fields(true) + end + whisper + end + + def make_group_whisper_post(group_ids, raw: "A staff whisper for a group.") + whisper = Fabricate(:post, topic: topic, user: moderator, raw: raw) + whisper.custom_fields[targets_field] = [] + whisper.custom_fields[groups_field] = group_ids + whisper.save_custom_fields(true) + whisper + end + + context "a staff whisper targeted at a group" do + before do + whisper_group.add(group_member) + make_group_whisper_post([whisper_group.id]) + end + + it "renders the group in the whisper banner" do + sign_in(moderator) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css( + ".cooked.mod-whisper .mod-whisper-banner", + wait: 15, + ) + expect(page).to have_css( + ".mod-whisper-banner .mod-whisper-banner__group", + text: whisper_group.name, + wait: 15, + ) + shot("76_group_whisper_banner") + end + + it "shows the whisper to a member of the target group" do + sign_in(group_member) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css( + ".cooked.mod-whisper .mod-whisper-banner", + wait: 15, + ) + shot("77_group_member_sees_whisper") + end + + it "hides the whisper from a non-member non-staff user" do + sign_in(stranger) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + expect(page).to have_no_css(".mod-whisper-banner") + shot("78_non_member_does_not_see_group_whisper") + end + end + + context "a moderator arms a whisper from the composer" do + before { sign_in(moderator) } + + it "shows the eye button, opens the modal and arms the pill" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_reply_composer + expect(page).to have_css(whisper_button_selector, wait: 10) + shot("62_composer_whisper_button") + + whisper_toolbar_button.click + expect(page).to have_css(".mod-whisper-target-modal", wait: 10) + expect(page).to have_css(".mod-whisper-target-modal__instructions") + shot("63_whisper_target_modal_empty") + + chooser = + PageObjects::Components::SelectKit.new( + ".mod-whisper-target-modal .email-group-user-chooser", + ) + chooser.expand + chooser.search(recipient.username) + chooser.select_row_by_value(recipient.username) + chooser.search(other_target.username) + chooser.select_row_by_value(other_target.username) + shot("64_whisper_target_modal_users_selected") + + chooser.collapse + find(".mod-whisper-target-modal .mod-whisper-confirm").click + expect(page).to have_no_css(".mod-whisper-target-modal", wait: 10) + + expect(page).to have_css(".mod-whisper-armed-pill", wait: 10) + expect(page).to have_css( + ".mod-whisper-armed-pill__user", + text: "@#{recipient.username}", + ) + shot("65_whisper_armed_pill") + + find(".d-editor-input").fill_in( + with: "A private aside for the two of you.", + ) + find(".save-or-cancel .create").click + + expect(page).to have_css( + ".cooked.mod-whisper.mod-whisper--staff .mod-whisper-banner", + wait: 15, + ) + shot("66_staff_whisper_posted_banner") + + whisper_post = topic.reload.posts.last + expect(whisper_post.custom_fields[targets_field].map(&:to_i)).to( + match_array([recipient.id, other_target.id]), + ) + end + end + + context "a moderator arms a staff-only whisper (no targets)" do + before { sign_in(moderator) } + + # Regression: confirming the target modal with NO users selected arms a + # staff-only whisper. It must post as a whisper, not a public post. + it "posts a staff-only whisper and renders the staff banner" do + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_reply_composer + expect(page).to have_css(whisper_button_selector, wait: 10) + + whisper_toolbar_button.click + expect(page).to have_css(".mod-whisper-target-modal", wait: 10) + # Confirm with an empty selection — a staff-only whisper. + find(".mod-whisper-target-modal .mod-whisper-confirm").click + expect(page).to have_no_css(".mod-whisper-target-modal", wait: 10) + + expect(page).to have_css(".mod-whisper-armed-pill", wait: 10) + expect(page).to have_css( + ".mod-whisper-armed-pill__label", + text: I18n.t( + "js.discourse_mod_categories.whisper.armed_pill_staff_only", + ), + ) + shot("65b_staff_only_whisper_armed") + + find(".d-editor-input").fill_in(with: "A note just for the staff team.") + find(".save-or-cancel .create").click + + # The banner survives the post stream inside .cooked. + expect(page).to have_css( + ".cooked.mod-whisper .mod-whisper-banner", + wait: 15, + ) + expect(page).to have_css( + ".cooked .mod-whisper-banner", + text: I18n.t("js.discourse_mod_categories.whisper.banner_to_staff"), + wait: 15, + ) + shot("65c_staff_only_whisper_posted_banner") + + whisper_post = topic.reload.posts.last + expect(whisper_post.custom_fields.key?(targets_field)).to eq(true) + expect(whisper_post.custom_fields[targets_field]).to eq([]) + end + end + + context "replying to a whisper auto-arms a whisper-back" do + let!(:whisper) { make_whisper_post([recipient.id]) } + + # Opening the composer to reply to a whisper auto-arms a whisper-back by + # default — the `composer:opened` handler sets modWhisperArmed and the + # target props, so the armed pill appears without any extra click. + # NOTE: quote-reply behaviour is intentionally unchanged and not covered. + it "shows the armed pill when replying to a whisper" do + sign_in(admin) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-whisper-banner", wait: 15) + + # Reply directly to the whisper post (article#post_<n>). + within("#post_#{whisper.post_number}") do + find(".post-controls button.reply").click + end + expect(page).to have_css(".d-editor-input", wait: 10) + + expect(page).to have_css(".mod-whisper-armed-pill", wait: 10) + shot("75_reply_to_whisper_auto_armed") + end + end + + context "audience visibility of a posted whisper" do + before { make_whisper_post([recipient.id]) } + + it "lets the recipient see the whisper banner" do + sign_in(recipient) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css( + ".cooked.mod-whisper .mod-whisper-banner", + wait: 15, + ) + shot("67_recipient_sees_whisper") + end + + it "does NOT show the whisper to a stranger" do + sign_in(stranger) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + expect(page).to have_no_css(".mod-whisper-banner") + shot("68_stranger_does_not_see_whisper") + end + + it "shows the whisper to a staff member for oversight" do + sign_in(admin) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css( + ".cooked.mod-whisper .mod-whisper-banner", + wait: 15, + ) + shot("69_staff_oversight") + end + end + + context "a topic participant whispers back" do + before { make_whisper_post([recipient.id]) } + + it "arms a staff-only whisper-back from the toolbar" do + sign_in(recipient) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-whisper-banner", wait: 15) + + open_reply_composer + whisper_toolbar_button.click + + expect(page).to have_css(".mod-whisper-armed-pill", wait: 10) + expect(page).to have_css( + ".mod-whisper-armed-pill__label", + text: I18n.t( + "js.discourse_mod_categories.whisper.armed_pill_staff_only", + ), + ) + shot("70_participant_whisper_back_armed") + + find(".d-editor-input").fill_in(with: "Thanks staff, replying back.") + find(".save-or-cancel .create").click + + expect(page).to have_css( + ".cooked.mod-whisper.mod-whisper--user .mod-whisper-banner", + wait: 15, + ) + shot("71_whisper_back_banner") + + whisper_back = topic.reload.posts.last + expect(whisper_back.custom_fields[targets_field]).to eq([]) + end + end + + context "a non-participant user" do + before { make_whisper_post([recipient.id]) } + + it "the eye button is a no-op for a non-participant" do + sign_in(stranger) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + + open_reply_composer + whisper_toolbar_button.click + # No whisper armed — the pill never appears. + expect(page).to have_no_css(".mod-whisper-armed-pill") + shot("72_non_participant_no_op") + end + end + + context "the site setting" do + before { sign_in(admin) } + + it "exposes the mod_whisper_enabled setting in the admin UI" do + visit("/admin/site_settings/category/all_results?filter=mod_whisper") + expect(page).to have_css( + ".admin-detail .setting", + wait: 10, + ) + shot("73_site_setting_page") + end + end + + context "staff add a user to the whisper conversation" do + before { make_whisper_post([recipient.id]) } + + it "adds a user via the whisper post admin menu" do + sign_in(admin) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css(".mod-whisper-banner", wait: 15) + + # Open the post admin (wrench) menu on the whisper post. + within("#post_#{topic.reload.posts.last.post_number}") do + find(".post-controls .show-more-actions").click if page.has_css?( + ".post-controls .show-more-actions", + ) + find(".post-controls .show-post-admin-menu").click + end + expect(page).to have_css(".mod-whisper-add-participant", wait: 10) + shot("79_whisper_post_admin_menu") + + find(".mod-whisper-add-participant").click + expect(page).to have_css( + ".mod-whisper-add-participant-modal", + wait: 10, + ) + + chooser = + PageObjects::Components::SelectKit.new( + ".mod-whisper-add-participant-modal .email-group-user-chooser", + ) + chooser.expand + chooser.search(stranger.username) + chooser.select_row_by_value(stranger.username) + chooser.collapse + shot("80_whisper_add_participant_modal") + + find(".mod-whisper-add-participant-confirm").click + expect(page).to have_no_css( + ".mod-whisper-add-participant-modal", + wait: 10, + ) + shot("81_whisper_participant_added") + + expect( + Array(topic.reload.custom_fields[participants_field]).map(&:to_i), + ).to include(stranger.id) + end + end + + context "with the plugin disabled" do + before do + make_whisper_post([recipient.id]) + SiteSetting.mod_whisper_enabled = false + end + + it "shows the (former) whisper post to everyone" do + sign_in(stranger) + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#topic-title", wait: 10) + # With the feature off, the post is a plain post visible to all. + expect(page).to have_css(".topic-post", minimum: 2, wait: 10) + expect(page).to have_no_css(".mod-whisper-banner") + shot("74_plugin_disabled_visible_to_all") + end + end +end diff --git a/plugin.rb b/plugin.rb index 467fcd1..cb95211 100644 --- a/plugin.rb +++ b/plugin.rb @@ -1,14 +1,17 @@ # frozen_string_literal: true -# name: discourse-dumbcourse -# about: Serve the Dumbcourse SPA under /dumb with push notifications -# version: 0.2 -# authors: TripleU +# name: dumbcourse +# about: Dumbcourse SPA under /dumb with push notifications, plus forum-moderator workflow features (categories, footer messages, prompts, checklists, whisper, private notes) +# version: 0.3.0 +# authors: Shalom Karr, Usher Weiss, Avrumi Sternheim # url: https://github.com/TripleU613/dumbcourse # required_version: 2.7.0 +# ============================================================================= +# DUMBCOURSE — unchanged from upstream +# ============================================================================= module ::DiscourseDumbcourse - PLUGIN_NAME = "discourse-dumbcourse" + PLUGIN_NAME = "dumbcourse" def self.base_path path = SiteSetting.dumbcourse_base_path.to_s.strip @@ -36,17 +39,238 @@ def requires_plugin(*) require_relative "lib/discourse_dumbcourse/engine" require_relative "lib/discourse_dumbcourse/push_sender" +# ============================================================================= +# DISCOURSE-MOD — forum-moderator workflow features +# +# All Ruby code for these features lives under the `discourse-mod/` subfolder +# (specs, docs, screenshots too). JS connectors / initializers must live at +# the standard `assets/javascripts/discourse/...` path because Discourse's +# Ember plugin loader scans that path only — our JS files are filename-prefixed +# (`mod-*`, `precheck-*`) to stay visually distinct from the dumbcourse files. +# ============================================================================= +require_relative "discourse-mod/lib/discourse_mod_categories/guardian_extensions" +require_relative "discourse-mod/lib/discourse_mod_categories/whisper_query_filter" + +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" + +module ::DiscourseModCategories + # Custom-field keys for the moderator-set messages. + TOPIC_FOOTER_FIELD = "mod_topic_footer_message" + TOPIC_REPLY_PROMPT_FIELD = "mod_topic_reply_prompt" + TOPIC_PINNED_POST_FIELD = "mod_topic_pinned_post_id" + TOPIC_REQUIRE_REPLY_APPROVAL_FIELD = "mod_topic_require_reply_approval" + TOPIC_PRIVATE_NOTE_FIELD = "mod_topic_private_note" + TOPIC_PRIVATE_NOTE_POSITION_FIELD = "mod_topic_private_note_position" + TOPIC_PRIVATE_NOTE_USER_FIELD = "mod_topic_private_note_user_id" + TOPIC_PRIVATE_NOTE_CREATED_AT_FIELD = "mod_topic_private_note_created_at" + TOPIC_PRIVATE_NOTE_REPLIES_FIELD = "mod_topic_private_note_replies" + TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD = "mod_topic_private_note_activity_at" + USER_NOTES_SEEN_FIELD = "mod_notes_seen_at" + CATEGORY_NEW_TOPIC_PROMPT_FIELD = "mod_category_new_topic_prompt" + # Highest trust level still shown a prompt (0-3); 4/blank means everyone. + TOPIC_REPLY_PROMPT_TL_FIELD = "mod_topic_reply_prompt_max_tl" + CATEGORY_NEW_TOPIC_PROMPT_TL_FIELD = "mod_category_new_topic_prompt_max_tl" + # Forum-wide first-post checklist: the config lives in the plugin store, + # and each user records the highest checklist version they have accepted. + USER_CHECKLIST_VERSION_FIELD = "mod_checklist_accepted_version" + CHECKLIST_STORE_NAMESPACE = "discourse_mod_categories" + CHECKLIST_STORE_KEY = "first_post_checklist" + # Append-only audit log of checklist acceptances. + CHECKLIST_LOG_KEY = "first_post_checklist_log" + # Targeted checklists: separate checklists aimed at specific users, + # stored as a JSON array under this key. A per-user json map records the + # version each targeted checklist was last accepted at. + TARGETED_CHECKLISTS_KEY = "targeted_checklists" + USER_TARGETED_CHECKLIST_FIELD = "mod_checklist_targeted_accepted" + # Per-topic prompt checklist: an opt-in checklist attached to a single + # topic. The checklist itself lives on the topic custom field; each user + # records which version (per topic id) they have accepted in their own + # json map custom field. + TOPIC_PROMPT_CHECKLIST_FIELD = "mod_topic_prompt_checklist" + USER_TOPIC_CHECKLIST_FIELD = "mod_topic_checklist_accepted" + + # The current checklist config, or nil when none is set. Shape: + # { "version" => Integer, "items" => [{ "label" =>, "url" => }], + # "updated_at" => ISO8601 String } + def self.checklist_config + PluginStore.get(CHECKLIST_STORE_NAMESPACE, CHECKLIST_STORE_KEY) + end + + # The single checklist the given user most needs to accept before they + # can post, or nil so the caller can skip the modal. + def self.owed_checklist_for(user, topic_id: nil) + return nil unless user + return nil unless SiteSetting.mod_categories_enabled + + targeted_accepted = user.custom_fields[USER_TARGETED_CHECKLIST_FIELD] + targeted_accepted = {} unless targeted_accepted.is_a?(Hash) + + owed_targeted = + targeted_checklists.find do |checklist| + items = checklist["items"] + next false unless items.is_a?(Array) && items.any? + next false unless Array(checklist["user_ids"]).map(&:to_i).include?( + user.id, + ) + checklist["version"].to_i > targeted_accepted[checklist["id"]].to_i + end + + if owed_targeted + return( + { + kind: "targeted", + id: owed_targeted["id"], + version: owed_targeted["version"].to_i, + items: owed_targeted["items"], + button_label: owed_targeted["button_label"].to_s, + updated_at: owed_targeted["updated_at"], + } + ) + end + + if topic_id.present? + topic_checklist = topic_prompt_checklist(topic_id) + if topic_checklist + mode = topic_checklist["mode"].to_s + mode = "checklist" unless %w[statement checklist].include?(mode) + frequency = topic_checklist["frequency"].to_s + frequency = "once" unless %w[once every_reply].include?(frequency) + + max_tl = + if topic_checklist.key?("max_tl") + topic_checklist["max_tl"].to_i + else + 4 + end + + below_cap = !user.staff? && user.trust_level > max_tl + + unless below_cap + version = topic_checklist["version"].to_i + accepted_version = 0 + if frequency == "once" + accepted_map = user.custom_fields[USER_TOPIC_CHECKLIST_FIELD] + accepted_map = JSON.parse(accepted_map) rescue {} if accepted_map.is_a?(String) + accepted_map = {} unless accepted_map.is_a?(Hash) + accepted_version = accepted_map[topic_id.to_s].to_i + end + + if frequency == "every_reply" || version > accepted_version + return( + { + kind: "topic", + id: topic_id.to_i, + version: version, + mode: mode, + statement: topic_checklist["statement"].to_s, + items: topic_checklist["items"], + frequency: frequency, + max_tl: max_tl, + button_label: topic_checklist["button_label"].to_s, + updated_at: topic_checklist["updated_at"], + } + ) + end + end + end + end + + return nil if user.staff? + + config = checklist_config + return nil unless config + + items = config["items"] + return nil unless items.is_a?(Array) && items.any? + + max_tl = config.key?("max_tl") ? config["max_tl"].to_i : 2 + return nil if user.trust_level > max_tl + + version = config["version"].to_i + accepted = user.custom_fields[USER_CHECKLIST_VERSION_FIELD].to_i + return nil if accepted >= version + + { + kind: "global", + version: version, + items: items, + button_label: config["button_label"].to_s, + updated_at: config["updated_at"], + } + end + + def self.topic_prompt_checklist(topic_id) + return nil if topic_id.blank? + topic = Topic.find_by(id: topic_id) + return nil unless topic + + raw = topic.custom_fields[TOPIC_PROMPT_CHECKLIST_FIELD] + raw = JSON.parse(raw) rescue nil if raw.is_a?(String) + return nil unless raw.is_a?(Hash) + + mode = raw["mode"].to_s + mode = "checklist" unless %w[statement checklist].include?(mode) + + if mode == "statement" + return nil if raw["statement"].to_s.strip.empty? + else + items = raw["items"] + return nil unless items.is_a?(Array) && items.any? + end + + raw + end + + def self.targeted_checklists + raw = PluginStore.get(CHECKLIST_STORE_NAMESPACE, TARGETED_CHECKLISTS_KEY) + raw.is_a?(Array) ? raw : [] + end + + # Moderator whisper custom fields. + POST_WHISPER_TARGETS_FIELD = "mod_whisper_target_user_ids" + POST_WHISPER_TARGET_GROUPS_FIELD = "mod_whisper_target_group_ids" + TOPIC_WHISPER_PARTICIPANTS_FIELD = "mod_whisper_participant_ids" + MAX_WHISPER_TARGETS = 10 + POST_WHISPER_ARMED_PARAM = "mod_whisper" + + class Engine < ::Rails::Engine + engine_name "discourse_mod_categories" + isolate_namespace DiscourseModCategories + + # Override the engine's routes file location so it does not collide + # with dumbcourse's config/routes.rb at the plugin root. Without this, + # both engines would load the SAME plugin-root config/routes.rb (their + # Engine.root is identical), redrawing every route and re-mounting + # both engines twice on each routes-reloader pass — Rails then raises + # 'Invalid route name, already in use: discourse_dumbcourse'. + config.paths["config/routes.rb"] = + File.expand_path("discourse-mod/config/routes.rb", __dir__) + end +end + +# ============================================================================= +# Shared after_initialize: dumbcourse block first, then discourse-mod. +# ============================================================================= after_initialize do + # --------------------------------------------------------------------------- + # DUMBCOURSE — unchanged from upstream + # --------------------------------------------------------------------------- enabled_site_setting :dumbcourse_enabled - # Hook: Notification created on(:notification_created) do |notification| next unless SiteSetting.dumbcourse_push_enabled Jobs.enqueue_in(2.seconds, :dumbcourse_push_notify, notification_id: notification.id) end - # Background job for sending push notifications module ::Jobs class DumbcoursePushNotify < ::Jobs::Base def execute(args) @@ -72,4 +296,358 @@ def execute(args) end end end + + # --------------------------------------------------------------------------- + # DISCOURSE-MOD — forum-moderator workflow features + # --------------------------------------------------------------------------- + require_relative "discourse-mod/app/controllers/discourse_mod_categories/messages_controller" + require_relative "discourse-mod/app/controllers/discourse_mod_categories/checklist_controller" + + reloadable_patch { ::Guardian.prepend(DiscourseModCategories::GuardianExtensions) } + + # Clearing the unread count when a mod-note notification is opened from + # the bell. Our `mod_note_unread_count` is keyed off the user's + # `mod_notes_seen_at` custom field (compared to topic activity + # timestamps), so flipping `Notification.read = true` on its own does + # not move that needle. This callback bridges the gap: when the bell + # marks one of OUR notifications read, advance seen_at and publish a + # reset so the avatar pip / title prefix clear in lockstep — matching + # what /notes-feed/seen does when the shield tab is opened. + reloadable_patch do + ::Notification.class_eval do + after_save do + next unless saved_change_to_read? && read? + next unless notification_type == ::Notification.types[:custom] + data_str = self[:data].to_s + next unless data_str.include?("\"mod_note\":true") + + owner = user + next unless owner + + owner.custom_fields[ + DiscourseModCategories::USER_NOTES_SEEN_FIELD + ] = Time.zone.now.iso8601 + owner.save_custom_fields(true) + + MessageBus.publish( + "/mod-note-unread-count/#{owner.id}", + { reset: true }, + user_ids: [owner.id], + ) + owner.publish_notifications_state + end + end + end + + register_topic_custom_field_type(DiscourseModCategories::TOPIC_FOOTER_FIELD, :string) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD, :string) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_PINNED_POST_FIELD, :integer) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_REQUIRE_REPLY_APPROVAL_FIELD, :boolean) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_PRIVATE_NOTE_FIELD, :string) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_PRIVATE_NOTE_POSITION_FIELD, :string) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_PRIVATE_NOTE_USER_FIELD, :integer) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_PRIVATE_NOTE_CREATED_AT_FIELD, :string) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_PRIVATE_NOTE_REPLIES_FIELD, :json) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD, :string) + register_user_custom_field_type(DiscourseModCategories::USER_NOTES_SEEN_FIELD, :string) + register_user_custom_field_type(DiscourseModCategories::USER_CHECKLIST_VERSION_FIELD, :integer) + register_user_custom_field_type(DiscourseModCategories::USER_TARGETED_CHECKLIST_FIELD, :json) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_PROMPT_CHECKLIST_FIELD, :json) + register_user_custom_field_type(DiscourseModCategories::USER_TOPIC_CHECKLIST_FIELD, :json) + register_category_custom_field_type(DiscourseModCategories::CATEGORY_NEW_TOPIC_PROMPT_FIELD, :string) + register_topic_custom_field_type(DiscourseModCategories::TOPIC_REPLY_PROMPT_TL_FIELD, :integer) + register_category_custom_field_type(DiscourseModCategories::CATEGORY_NEW_TOPIC_PROMPT_TL_FIELD, :integer) + + add_to_serializer(:topic_view, :mod_topic_footer_message) do + object.topic.custom_fields[DiscourseModCategories::TOPIC_FOOTER_FIELD] + end + add_to_serializer(:topic_view, :mod_topic_reply_prompt) do + object.topic.custom_fields[DiscourseModCategories::TOPIC_REPLY_PROMPT_FIELD] + end + add_to_serializer(:topic_view, :mod_topic_reply_prompt_max_tl) do + object.topic.custom_fields[DiscourseModCategories::TOPIC_REPLY_PROMPT_TL_FIELD] + end + add_to_serializer(:topic_view, :mod_topic_pinned_post_id) do + object.topic.custom_fields[DiscourseModCategories::TOPIC_PINNED_POST_FIELD] + end + add_to_serializer(:topic_view, :mod_topic_require_reply_approval) do + !!object.topic.custom_fields[DiscourseModCategories::TOPIC_REQUIRE_REPLY_APPROVAL_FIELD] + end + + add_to_serializer(:topic_view, :mod_topic_prompt_checklist) do + raw = object.topic.custom_fields[DiscourseModCategories::TOPIC_PROMPT_CHECKLIST_FIELD] + raw = JSON.parse(raw) rescue nil if raw.is_a?(String) + next nil unless raw.is_a?(Hash) + items = raw["items"].is_a?(Array) ? raw["items"] : [] + mode = raw["mode"].to_s + mode = "checklist" unless %w[statement checklist].include?(mode) + frequency = raw["frequency"].to_s + frequency = "once" unless %w[once every_reply].include?(frequency) + max_tl = raw.key?("max_tl") ? raw["max_tl"].to_i : 4 + if mode == "statement" + next nil if raw["statement"].to_s.strip.empty? + else + next nil if items.empty? + end + { + version: raw["version"].to_i, + mode: mode, + statement: raw["statement"].to_s, + items: items, + frequency: frequency, + max_tl: max_tl, + button_label: raw["button_label"].to_s, + updated_at: raw["updated_at"], + } + end + + add_to_serializer( + :topic_view, + :mod_topic_private_note, + include_condition: -> { scope.is_staff? }, + ) { object.topic.custom_fields[DiscourseModCategories::TOPIC_PRIVATE_NOTE_FIELD] } + add_to_serializer( + :topic_view, + :mod_topic_private_note_position, + include_condition: -> { scope.is_staff? }, + ) { object.topic.custom_fields[DiscourseModCategories::TOPIC_PRIVATE_NOTE_POSITION_FIELD] } + add_to_serializer( + :topic_view, + :mod_topic_private_note_author, + include_condition: -> { scope.is_staff? }, + ) do + user_id = object.topic.custom_fields[DiscourseModCategories::TOPIC_PRIVATE_NOTE_USER_FIELD] + user = user_id && User.find_by(id: user_id) + if user + { username: user.username, name: user.name, avatar_template: user.avatar_template } + end + end + add_to_serializer( + :topic_view, + :mod_topic_private_note_created_at, + include_condition: -> { scope.is_staff? }, + ) { object.topic.custom_fields[DiscourseModCategories::TOPIC_PRIVATE_NOTE_CREATED_AT_FIELD] } + add_to_serializer( + :topic_view, + :mod_topic_private_note_replies, + include_condition: -> { scope.is_staff? }, + ) do + raw = object.topic.custom_fields[DiscourseModCategories::TOPIC_PRIVATE_NOTE_REPLIES_FIELD] + entries = raw.is_a?(Array) ? raw : [] + entries.map do |entry| + author = entry["user_id"] && User.find_by(id: entry["user_id"]) + { + id: entry["id"].presence || SecureRandom.hex(8), + raw: entry["raw"].to_s, + created_at: entry["created_at"], + author: + author && + { username: author.username, name: author.name, avatar_template: author.avatar_template }, + } + end + end + + add_to_serializer(:current_user, :mod_note_unread_count) do + next 0 unless object.staff? + + seen_at = + object.custom_fields[DiscourseModCategories::USER_NOTES_SEEN_FIELD].presence || + "1970-01-01T00:00:00Z" + + TopicCustomField.where( + name: DiscourseModCategories::TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD, + ).where("value > ?", seen_at).count + end + + add_to_serializer(:current_user, :mod_first_post_checklist) do + DiscourseModCategories.owed_checklist_for(object) + end + + NewPostManager.add_handler do |manager| + topic_id = manager.args[:topic_id] + next nil if topic_id.blank? + + topic = Topic.find_by(id: topic_id) + next nil unless topic + next nil unless topic.custom_fields[DiscourseModCategories::TOPIC_REQUIRE_REPLY_APPROVAL_FIELD] + next nil if manager.user&.guardian&.can_review_topic?(topic) + + manager.enqueue("mod_topic_requires_reply_approval") + end + + Site.preloaded_category_custom_fields << + DiscourseModCategories::CATEGORY_NEW_TOPIC_PROMPT_FIELD + Site.preloaded_category_custom_fields << + DiscourseModCategories::CATEGORY_NEW_TOPIC_PROMPT_TL_FIELD + add_to_serializer(:basic_category, :mod_category_new_topic_prompt) do + object.custom_fields[DiscourseModCategories::CATEGORY_NEW_TOPIC_PROMPT_FIELD] + end + + # --- Moderator whisper ----------------------------------------------------- + + merge_whisper_participants = lambda do |topic, new_ids| + existing = + Array(topic.custom_fields[DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD]).map(&:to_i) + merged = (existing + new_ids.map(&:to_i)).reject { |i| i <= 0 }.uniq + next if merged.sort == existing.sort + + topic.custom_fields[DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD] = merged + topic.save_custom_fields(true) + end + + register_post_custom_field_type(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD, :json) + register_post_custom_field_type(DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_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_ARMED_PARAM, :string) + + add_to_serializer(:topic_view, :mod_whisper_participant_ids) do + raw = object.topic.custom_fields[DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD] + Array(raw).map(&:to_i) + end + + TopicView.apply_custom_default_scope do |scope, tv| + DiscourseModCategories::WhisperQueryFilter.apply(scope, tv.guardian&.user) + end + + on(:before_create_post) do |post, opts| + next unless SiteSetting.mod_whisper_enabled + + armed = + ::ActiveModel::Type::Boolean.new.cast(opts[DiscourseModCategories::POST_WHISPER_ARMED_PARAM]) + next unless armed + + normalize_ids = + lambda do |raw| + Array(raw) + .map { |v| v.is_a?(Numeric) || v.is_a?(String) ? v.to_i : 0 } + .reject { |i| i <= 0 } + .uniq + .first(DiscourseModCategories::MAX_WHISPER_TARGETS) + end + + requested_ids = normalize_ids.call(opts[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD]) + requested_group_ids = + normalize_ids.call(opts[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD]) + + author = post.user + topic = post.topic + next unless author && topic + + if author.staff? + valid_ids = ::User.where(id: requested_ids).pluck(:id) + valid_group_ids = ::Group.where(id: requested_group_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 + + non_staff_ids = + ::User.where(id: valid_ids).where(admin: false, moderator: false).pluck(:id) + merge_whisper_participants.call(topic, non_staff_ids) if non_staff_ids.any? + else + participant_ids = + Array(topic.custom_fields[DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD]).map(&:to_i) + next unless participant_ids.include?(author.id) + + post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD] = [] + post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD] = [] + end + end + + on(:post_created) do |post, opts, user| + next unless SiteSetting.mod_whisper_enabled + next unless post.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD) + + target_ids = + Array(post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD]).map(&:to_i) + + recipient_ids = + if user&.staff? + target_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, + original_post_id: post.id, + original_post_type: post.post_type, + }.to_json + + recipient_ids.each do |recipient_id| + Notification.create!( + notification_type: Notification.types[:custom], + user_id: recipient_id, + topic_id: topic&.id, + post_number: post.post_number, + data: data, + ) + end + end + + add_to_serializer(:post, :mod_is_whisper) do + object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD) + end + add_to_serializer(:post, :include_mod_is_whisper?) { SiteSetting.mod_whisper_enabled } + + add_to_serializer(:post, :mod_whisper_target_user_ids) do + Array(object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGETS_FIELD]).map(&:to_i) + end + add_to_serializer(:post, :include_mod_whisper_target_user_ids?) do + SiteSetting.mod_whisper_enabled && + object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD) + end + + add_to_serializer(:post, :mod_whisper_target_group_ids) do + Array(object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD]).map(&:to_i) + end + add_to_serializer(:post, :include_mod_whisper_target_group_ids?) do + SiteSetting.mod_whisper_enabled && + object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD) + end + + add_to_serializer(:post, :mod_whisper_target_groups) do + ids = + Array(object.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD]).map(&:to_i) + ::Group.where(id: ids).map { |g| { id: g.id, name: g.name } } + end + add_to_serializer(:post, :include_mod_whisper_target_groups?) 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) + ::User + .where(id: ids) + .map { |u| { id: u.id, username: u.username, avatar_template: u.avatar_template } } + end + add_to_serializer(:post, :include_mod_whisper_targets?) do + SiteSetting.mod_whisper_enabled && + object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD) + end + + 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? + end + add_to_serializer(:post, :include_mod_whisper_is_staff_only?) do + SiteSetting.mod_whisper_enabled && + object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD) + end + + add_to_serializer(:post, :mod_whisper_author_is_staff) { !!object.user&.staff? } + add_to_serializer(:post, :include_mod_whisper_author_is_staff?) do + SiteSetting.mod_whisper_enabled && + object.custom_fields.key?(DiscourseModCategories::POST_WHISPER_TARGETS_FIELD) + end + + add_to_serializer(:basic_category, :mod_category_new_topic_prompt_max_tl) do + object.custom_fields[DiscourseModCategories::CATEGORY_NEW_TOPIC_PROMPT_TL_FIELD] + end end diff --git a/spec/requests/dumbcourse_app_spec.rb b/spec/requests/dumbcourse_app_spec.rb new file mode 100644 index 0000000..11688ff --- /dev/null +++ b/spec/requests/dumbcourse_app_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Request-level coverage for the Dumbcourse SPA mount under `/<base_path>` +# (default `/dumb`). The controller serves a static `index.html` with +# settings injected into a `<script>` tag, redirects anonymous users to +# the in-SPA login, and returns 404 when the plugin is disabled. +RSpec.describe "Dumbcourse SPA" do + fab!(:user) + + describe "GET /dumb (anonymous)" do + it "redirects to the in-SPA login route" do + SiteSetting.dumbcourse_enabled = true + + get "/dumb" + + expect(response.status).to eq(302) + expect(response.location).to end_with("/dumb/login") + end + end + + describe "GET /dumb (authenticated)" do + before do + SiteSetting.dumbcourse_enabled = true + sign_in(user) + end + + it "serves the SPA index with the settings <script> injected" do + get "/dumb" + + expect(response.status).to eq(200) + expect(response.content_type).to start_with("text/html") + # The controller injects `window.DUMBCOURSE_SETTINGS = {...}` into the + # head; if the script is missing the in-browser SPA can't bootstrap. + expect(response.body).to include("window.DUMBCOURSE_SETTINGS=") + expect(response.body).to include('"basePath":"/dumb"') + end + end + + describe "GET /dumb when the plugin is disabled" do + it "returns 404" do + SiteSetting.dumbcourse_enabled = false + sign_in(user) + + get "/dumb" + + expect(response.status).to eq(404) + end + end + + describe "base_path configurability" do + it "honours a custom dumbcourse_base_path" do + SiteSetting.dumbcourse_enabled = true + SiteSetting.dumbcourse_base_path = "forum" + sign_in(user) + + get "/forum" + + expect(response.status).to eq(200) + expect(response.body).to include('"basePath":"/forum"') + end + end +end diff --git a/spec/requests/dumbcourse_push_spec.rb b/spec/requests/dumbcourse_push_spec.rb new file mode 100644 index 0000000..716c2fe --- /dev/null +++ b/spec/requests/dumbcourse_push_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Request-level coverage for Dumbcourse's push-notification endpoints under +# `/dumb/push/*`. The flow: +# - GET /push/info — anonymous OK, exposes server URL + enabled flag +# - POST /push/register — login-only, stores a device in PluginStore +# - GET /push/status — reports per-device registration state +# - DELETE /push/unregister — removes a device +# - GET/PUT /push/preferences — per-user notification toggles +# - GET /push/sse/:topic — permanently 410 Gone (see sse_controller.rb) +RSpec.describe "Dumbcourse push endpoints" do + fab!(:user) + + before { SiteSetting.dumbcourse_enabled = true } + + describe "GET /dumb/push/info" do + it "exposes the push server URL and enabled flag (anonymous)" do + SiteSetting.dumbcourse_push_enabled = true + + get "/dumb/push/info" + + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["server"]).to end_with("/dumb/push/sse") + expect(body["enabled"]).to eq(true) + end + end + + describe "POST /dumb/push/register" do + it "requires login" do + post "/dumb/push/register", + params: { topic: "abc", device_id: "dev-1" } + expect(response.status).to be_in([403, 302, 404]) + end + + it "stores the device under the user's PluginStore key" do + sign_in(user) + + post "/dumb/push/register", + params: { topic: "abc", device_id: "dev-1" } + + expect(response.status).to eq(200) + expect(response.parsed_body["success"]).to eq(true) + + devices = PluginStore.get("dumbcourse", "push_devices_#{user.id}") + expect(devices.keys).to include("dev-1") + expect(devices["dev-1"]["topic"]).to eq("abc") + end + + it "rejects a register call with a missing device_id" do + sign_in(user) + post "/dumb/push/register", params: { topic: "abc" } + expect(response.status).to eq(400) + end + end + + describe "GET /dumb/push/status" do + before do + sign_in(user) + PluginStore.set( + "dumbcourse", + "push_devices_#{user.id}", + { "dev-1" => { "topic" => "abc", "registered_at" => "now" } }, + ) + end + + it "reports a registered device" do + get "/dumb/push/status", params: { device_id: "dev-1" } + expect(response.status).to eq(200) + body = response.parsed_body + expect(body["registered"]).to eq(true) + expect(body["topic"]).to eq("abc") + end + + it "reports the device count when no device_id is given" do + get "/dumb/push/status" + expect(response.parsed_body["device_count"]).to eq(1) + end + end + + describe "DELETE /dumb/push/unregister" do + it "removes the device from the user's store" do + sign_in(user) + PluginStore.set( + "dumbcourse", + "push_devices_#{user.id}", + { "dev-1" => { "topic" => "abc" } }, + ) + + delete "/dumb/push/unregister", params: { device_id: "dev-1" } + expect(response.status).to eq(200) + + devices = PluginStore.get("dumbcourse", "push_devices_#{user.id}") + expect(devices).not_to have_key("dev-1") + end + end + + describe "GET/PUT /dumb/push/preferences" do + before { sign_in(user) } + + it "returns the default preferences on a fresh user" do + get "/dumb/push/preferences" + expect(response.status).to eq(200) + # The default_prefs hash includes one entry per notification type. + expect(response.parsed_body).to be_a(Hash) + end + + it "persists updated preferences" do + put "/dumb/push/preferences", + params: { mentions: false, likes: true } + expect(response.status).to eq(200) + + saved = PluginStore.get("dumbcourse", "push_prefs_#{user.id}") + expect(saved["mentions"]).to eq(false) + expect(saved["likes"]).to eq(true) + end + end + + describe "GET /dumb/push/sse/:topic (permanently disabled)" do + it "returns 410 Gone so EventSource stops retrying" do + get "/dumb/push/sse/anything" + + expect(response.status).to eq(410) + expect(response.body).to include("SSE permanently disabled") + end + end + + describe "GET /dumb/ntfy/* (permanently disabled)" do + it "also returns 410 Gone" do + get "/dumb/ntfy/some-topic/sse" + expect(response.status).to eq(410) + end + end +end diff --git a/test/javascripts/unit/first-post-checklist-test.js b/test/javascripts/unit/first-post-checklist-test.js new file mode 100644 index 0000000..14ed639 --- /dev/null +++ b/test/javascripts/unit/first-post-checklist-test.js @@ -0,0 +1,161 @@ +import { module, test } from "qunit"; +import { CREATE_TOPIC } from "discourse/models/composer"; +import { + firstPostChecklistFor, + refreshOwedChecklist, +} from "discourse/plugins/dumbcourse/discourse/lib/first-post-checklist"; +import { REPLY_ACTION } from "discourse/plugins/dumbcourse/discourse/lib/precheck-prompt"; + +// Pure-function unit tests for first-post checklist gate resolution. The +// server decides eligibility (trust level / version); the frontend only +// gates on the composer action and a non-empty checklist. + +const CHECKLIST = { + kind: "global", + version: 2, + items: [{ label: "Read the rules", url: "" }], +}; + +const TARGETED_CHECKLIST = { + kind: "targeted", + id: "abc123", + version: 1, + items: [{ label: "Read the app rules", url: "" }], +}; + +module("Unit | discourse-mod | first-post-checklist", function () { + test("returns the checklist for a new topic", function (assert) { + assert.strictEqual( + firstPostChecklistFor( + { action: CREATE_TOPIC }, + { mod_first_post_checklist: CHECKLIST } + ), + CHECKLIST + ); + }); + + test("returns the checklist for a reply", function (assert) { + assert.strictEqual( + firstPostChecklistFor( + { action: REPLY_ACTION }, + { mod_first_post_checklist: CHECKLIST } + ), + CHECKLIST + ); + }); + + ["edit", "privateMessage", "editSharedDraft", "", null, undefined].forEach( + (action) => { + test(`action=${JSON.stringify(action)} is never gated`, function (assert) { + assert.strictEqual( + firstPostChecklistFor( + { action }, + { mod_first_post_checklist: CHECKLIST } + ), + null + ); + }); + } + ); + + test("returns null when the user has no checklist", function (assert) { + assert.strictEqual( + firstPostChecklistFor({ action: CREATE_TOPIC }, {}), + null + ); + }); + + test("returns null when the checklist is null", function (assert) { + assert.strictEqual( + firstPostChecklistFor( + { action: CREATE_TOPIC }, + { mod_first_post_checklist: null } + ), + null + ); + }); + + test("returns null when the checklist has no items", function (assert) { + assert.strictEqual( + firstPostChecklistFor( + { action: CREATE_TOPIC }, + { mod_first_post_checklist: { version: 1, items: [] } } + ), + null + ); + }); + + test("statement-mode checklist with a non-blank statement is gated", function (assert) { + const checklist = { + kind: "topic", + version: 1, + mode: "statement", + statement: "Please confirm you have read the rules.", + items: [], + }; + assert.strictEqual( + firstPostChecklistFor( + { action: REPLY_ACTION }, + { mod_first_post_checklist: checklist } + ), + checklist + ); + }); + + test("statement-mode checklist with a blank statement is not gated", function (assert) { + const checklist = { + kind: "topic", + version: 1, + mode: "statement", + statement: " ", + items: [], + }; + assert.strictEqual( + firstPostChecklistFor( + { action: REPLY_ACTION }, + { mod_first_post_checklist: checklist } + ), + null + ); + }); + + test("returns null with no composer", function (assert) { + assert.strictEqual( + firstPostChecklistFor(null, { mod_first_post_checklist: CHECKLIST }), + null + ); + }); + + test("returns null with no current user", function (assert) { + assert.strictEqual( + firstPostChecklistFor({ action: CREATE_TOPIC }, null), + null + ); + }); + + test("carries a targeted checklist through unchanged", function (assert) { + const result = firstPostChecklistFor( + { action: CREATE_TOPIC }, + { mod_first_post_checklist: TARGETED_CHECKLIST } + ); + assert.strictEqual(result, TARGETED_CHECKLIST); + assert.strictEqual(result.kind, "targeted"); + assert.strictEqual(result.id, "abc123"); + }); + + test("passes an updated_at-carrying checklist through unchanged", function (assert) { + const checklist = { ...CHECKLIST, updated_at: "2026-01-02T03:04:05Z" }; + const result = firstPostChecklistFor( + { action: REPLY_ACTION }, + { mod_first_post_checklist: checklist } + ); + assert.strictEqual(result, checklist); + assert.strictEqual(result.updated_at, "2026-01-02T03:04:05Z"); + }); + + test("refreshOwedChecklist resolves without a current user", async function (assert) { + // No user means no session to refresh; it must not throw or fetch. + await refreshOwedChecklist(null); + assert.true(true, "resolved without error"); + }); +}); diff --git a/test/javascripts/unit/linkify-message-extra-test.js b/test/javascripts/unit/linkify-message-extra-test.js new file mode 100644 index 0000000..e3fa839 --- /dev/null +++ b/test/javascripts/unit/linkify-message-extra-test.js @@ -0,0 +1,78 @@ +import { module, test } from "qunit"; +import { messageToHtml } from "discourse/plugins/dumbcourse/discourse/lib/linkify-message"; + +// Extra edge-case coverage for the shared message renderer (escaping + +// URL linkification + line breaks). Complements linkify-message-test.js. + +function html(value) { + return messageToHtml(value).toString(); +} + +module("Unit | discourse-mod | linkify-message | extra", function () { + test("linkifies a URL with query string and fragment", function (assert) { + const result = html( + "See https://example.com/page?ref=mod&tag=app#anchor for details" + ); + assert.true( + result.includes( + '<a href="https://example.com/page?ref=mod&tag=app#anchor"' + ), + "the URL keeps its query and fragment (and & is escaped)" + ); + }); + + test("escapes wrapping HTML tags around a URL", function (assert) { + const result = html("<p>hello https://example.com end</p>"); + // The wrapping <p>/</p> are escaped, and the URL is linkified. + assert.true(result.startsWith("<p>hello ")); + assert.true(result.includes("https://example.com")); + assert.true(result.includes("end</p>")); + }); + + test("renders a string of only newlines as a stack of brs", function (assert) { + assert.strictEqual(html("\n\n\n"), "<br><br><br>"); + }); + + test("a single space is preserved", function (assert) { + assert.strictEqual(html(" "), " "); + }); + + test("URL adjacent to punctuation keeps the punctuation outside the anchor", function (assert) { + const result = html("Visit https://example.com."); + // The greedy URL regex includes the trailing dot; this is intentional. + assert.true(result.includes('<a href="https://example.com.')); + }); + + test("number-only message round-trips", function (assert) { + assert.strictEqual(html("12345"), "12345"); + }); + + test("escapes a quote followed by a URL", function (assert) { + const result = html('"see" https://example.com'); + assert.true(result.startsWith(""see" ")); + assert.true(result.includes('href="https://example.com"')); + }); + + test("linkifies HTTPS URLs in the middle of a sentence", function (assert) { + const result = html( + "The doc at https://example.com/docs talks about it." + ); + assert.true(result.includes('href="https://example.com/docs"')); + assert.true(result.includes("talks about it.")); + }); + + test("preserves trailing whitespace as text plus brs intact", function (assert) { + assert.strictEqual(html("hi\n"), "hi<br>"); + }); + + test("treats one CRLF and one LF the same way", function (assert) { + assert.strictEqual(html("a\r\nb"), html("a\nb")); + }); + + test("a literal < in the input is escaped before linkification", function (assert) { + // The < is escaped to < before the URL regex runs, so the URL captures + // the escaped entity too — confirming we never feed raw HTML to the regex. + const result = html("https://example.com/x<after"); + assert.true(result.includes("<after")); + }); +}); diff --git a/test/javascripts/unit/linkify-message-test.js b/test/javascripts/unit/linkify-message-test.js new file mode 100644 index 0000000..ed1e074 --- /dev/null +++ b/test/javascripts/unit/linkify-message-test.js @@ -0,0 +1,112 @@ +import { module, test } from "qunit"; +import { messageToHtml } from "discourse/plugins/dumbcourse/discourse/lib/linkify-message"; + +// Pure-function unit tests for the shared message renderer used by the +// precheck confirmation dialog and the category prompt preview. It escapes +// HTML, linkifies http(s) URLs, and preserves line breaks. + +// htmlSafe wraps the string; .toString() / .string exposes the raw HTML. +function html(value) { + const result = messageToHtml(value); + return result.toString(); +} + +module("Unit | discourse-mod | linkify-message", function () { + test("plain text passes through unchanged", function (assert) { + assert.strictEqual(html("Read the rules"), "Read the rules"); + }); + + test("blank inputs yield an empty string", function (assert) { + assert.strictEqual(html(""), ""); + assert.strictEqual(html(null), ""); + assert.strictEqual(html(undefined), ""); + }); + + test("escapes HTML special characters", function (assert) { + assert.strictEqual( + html("<script>alert('x')</script>"), + "<script>alert('x')</script>" + ); + }); + + test("escapes ampersands, quotes, angle brackets", function (assert) { + assert.strictEqual( + html(`a & b < c > d "quoted"`), + `a & b < c > d "quoted"` + ); + }); + + test("does not escape single quotes", function (assert) { + assert.strictEqual(html("it's fine"), "it's fine"); + }); + + test("linkifies an https URL", function (assert) { + assert.strictEqual( + html("See https://example.com/rules now"), + 'See <a href="https://example.com/rules" target="_blank" ' + + 'rel="noopener noreferrer">https://example.com/rules</a> now' + ); + }); + + test("linkifies an http URL", function (assert) { + assert.strictEqual( + html("http://example.com"), + '<a href="http://example.com" target="_blank" ' + + 'rel="noopener noreferrer">http://example.com</a>' + ); + }); + + test("linkifies multiple URLs in one message", function (assert) { + const result = html("a https://one.com b https://two.com c"); + assert.true(result.includes('href="https://one.com"')); + assert.true(result.includes('href="https://two.com"')); + }); + + test("does not linkify a bare domain without a scheme", function (assert) { + assert.strictEqual(html("visit example.com please"), "visit example.com please"); + }); + + test("does not linkify ftp or other schemes", function (assert) { + assert.strictEqual(html("ftp://example.com"), "ftp://example.com"); + }); + + test("converts a unix newline to a br", function (assert) { + assert.strictEqual(html("line one\nline two"), "line one<br>line two"); + }); + + test("converts a windows CRLF newline to a single br", function (assert) { + assert.strictEqual(html("line one\r\nline two"), "line one<br>line two"); + }); + + test("converts multiple newlines to multiple brs", function (assert) { + assert.strictEqual(html("a\nb\nc"), "a<br>b<br>c"); + }); + + test("escapes HTML inside a linkified message", function (assert) { + const result = html("<b>warn</b> https://example.com"); + assert.true(result.startsWith("<b>warn</b> ")); + assert.true(result.includes('href="https://example.com"')); + }); + + test("a URL stops at whitespace", function (assert) { + const result = html("https://example.com/path next-word"); + assert.true(result.includes(">https://example.com/path</a>")); + assert.true(result.includes("</a> next-word")); + }); + + test("preserves unicode content", function (assert) { + assert.strictEqual(html("🚀 проверка 名前"), "🚀 проверка 名前"); + }); + + test("escaped angle bracket does not break a following URL", function (assert) { + const result = html("a>b https://example.com"); + assert.true(result.startsWith("a>b ")); + assert.true(result.includes('href="https://example.com"')); + }); + + test("a URL on its own line is linkified and the break kept", function (assert) { + const result = html("Check this:\nhttps://example.com"); + assert.true(result.startsWith("Check this:<br>")); + assert.true(result.includes('href="https://example.com"')); + }); +}); diff --git a/test/javascripts/unit/mod-note-notification-test.js b/test/javascripts/unit/mod-note-notification-test.js new file mode 100644 index 0000000..943e62e --- /dev/null +++ b/test/javascripts/unit/mod-note-notification-test.js @@ -0,0 +1,86 @@ +import { module, test } from "qunit"; +import modNoteNotificationRenderer from "discourse/plugins/dumbcourse/discourse/lib/mod-note-notification"; + +// Unit tests for the moderator-note notification-type renderer. The renderer +// is a factory that receives a NotificationTypeBase class and returns a +// subclass. A minimal fake base stands in for core's NotificationTypeBase so +// the mod-note branch can be exercised in isolation; end-to-end behaviour is +// covered in spec/system. + +class FakeBase { + constructor({ notification }) { + this.notification = notification; + } + + get linkHref() { + return "base-link"; + } + + get linkTitle() { + return "base-title"; + } + + get icon() { + return "base-icon"; + } + + get label() { + return "base-label"; + } + + get description() { + return "base-description"; + } + + get username() { + return this.notification.data.display_username; + } +} + +function renderer(data) { + const Renderer = modNoteNotificationRenderer(FakeBase); + return new Renderer({ notification: { data } }); +} + +module("Unit | discourse-mod | mod-note-notification", function () { + test("links straight to the note url for a mod-note notification", function (assert) { + const r = renderer({ + mod_note: true, + url: "/t/some-topic/12/4", + display_username: "molly", + topic_title: "Some topic", + }); + assert.strictEqual(r.linkHref, "/t/some-topic/12/4"); + }); + + test("uses the shield icon for a mod-note notification", function (assert) { + const r = renderer({ mod_note: true, display_username: "molly" }); + assert.strictEqual(r.icon, "shield-halved"); + }); + + test("describes the note with the topic title", function (assert) { + const r = renderer({ + mod_note: true, + display_username: "molly", + topic_title: "Share your app build here", + }); + assert.strictEqual(r.description, "Share your app build here"); + }); + + test("defers to the base class for non-mod-note custom notifications", function (assert) { + const r = renderer({ message: "some.other.notification" }); + assert.strictEqual(r.linkHref, "base-link"); + assert.strictEqual(r.label, "base-label"); + assert.strictEqual(r.description, "base-description"); + }); + + test("mirrors core custom.js icon for non-mod-note notifications", function (assert) { + const r = renderer({ message: "discourse_mod_categories.whisper" }); + assert.strictEqual(r.icon, "notification.discourse_mod_categories.whisper"); + }); + + test("treats a missing mod_note marker as not a mod note", function (assert) { + const r = renderer({ url: "/t/x/1" }); + assert.strictEqual(r.linkHref, "base-link"); + }); +}); diff --git a/test/javascripts/unit/mod-note-unread-title-test.js b/test/javascripts/unit/mod-note-unread-title-test.js new file mode 100644 index 0000000..2552376 --- /dev/null +++ b/test/javascripts/unit/mod-note-unread-title-test.js @@ -0,0 +1,57 @@ +import { module, test } from "qunit"; +import { + applyUnreadPrefix, + stripUnreadPrefix, +} from "discourse/plugins/dumbcourse/discourse/lib/mod-note-unread-title"; + +// Pure-function unit tests for the moderator-notes browser-tab title +// prefix. The header pip is a tracked-property render, so its reactivity +// is implicitly exercised by the Glimmer runtime; this suite locks in the +// title-prefix maths the initializer drives `document.title` with. + +module("Unit | discourse-mod | mod-note-unread-title", function () { + test("prefixes a non-zero count onto the bare title", function (assert) { + assert.strictEqual(applyUnreadPrefix("My Forum", 3), "(3) My Forum"); + }); + + test("returns the bare title for a zero count", function (assert) { + assert.strictEqual(applyUnreadPrefix("My Forum", 0), "My Forum"); + }); + + test("returns the bare title for a missing count", function (assert) { + assert.strictEqual(applyUnreadPrefix("My Forum", null), "My Forum"); + assert.strictEqual(applyUnreadPrefix("My Forum", undefined), "My Forum"); + }); + + test("is idempotent — repeated calls do not stack prefixes", function (assert) { + const once = applyUnreadPrefix("My Forum", 2); + const twice = applyUnreadPrefix(once, 2); + assert.strictEqual(twice, "(2) My Forum"); + }); + + test("re-prefixing with a new count replaces the old one", function (assert) { + const before = applyUnreadPrefix("My Forum", 1); + const after = applyUnreadPrefix(before, 5); + assert.strictEqual(after, "(5) My Forum"); + }); + + test("a zero count strips an existing prefix back off", function (assert) { + const prefixed = applyUnreadPrefix("My Forum", 4); + assert.strictEqual(applyUnreadPrefix(prefixed, 0), "My Forum"); + }); + + test("stripUnreadPrefix removes the (N) shape but leaves other text alone", function (assert) { + assert.strictEqual(stripUnreadPrefix("(7) My Forum"), "My Forum"); + assert.strictEqual(stripUnreadPrefix("My Forum"), "My Forum"); + assert.strictEqual(stripUnreadPrefix("(notes) My Forum"), "(notes) My Forum"); + }); + + test("negative or non-numeric counts are treated as zero", function (assert) { + assert.strictEqual(applyUnreadPrefix("My Forum", -1), "My Forum"); + assert.strictEqual(applyUnreadPrefix("My Forum", "abc"), "My Forum"); + }); + + test("a numeric string count is honoured", function (assert) { + assert.strictEqual(applyUnreadPrefix("My Forum", "2"), "(2) My Forum"); + }); +}); diff --git a/test/javascripts/unit/mod-whisper-reply-audience-test.js b/test/javascripts/unit/mod-whisper-reply-audience-test.js new file mode 100644 index 0000000..39f555f --- /dev/null +++ b/test/javascripts/unit/mod-whisper-reply-audience-test.js @@ -0,0 +1,103 @@ +import { module, test } from "qunit"; +import { computeReplyAudience } from "discourse/plugins/dumbcourse/discourse/lib/mod-whisper-reply-audience"; + +// Pure-function unit tests for the whisper reply-audience helper. End-to-end +// behaviour is in spec/system. + +module( + "Unit | discourse-mod | mod-whisper-reply-audience", + function () { + test("returns [] when post is missing", function (assert) { + assert.deepEqual(computeReplyAudience(null, 1), []); + assert.deepEqual(computeReplyAudience(undefined, 1), []); + }); + + test("returns [] when currentUserId is missing", function (assert) { + assert.deepEqual(computeReplyAudience({ user_id: 5 }, null), []); + assert.deepEqual(computeReplyAudience({ user_id: 5 }, undefined), []); + }); + + test("includes the post author", function (assert) { + const result = computeReplyAudience( + { user_id: 5, username: "alice", avatar_template: "/a.png" }, + 99 + ); + assert.deepEqual(result, [ + { id: 5, username: "alice", avatar_template: "/a.png" }, + ]); + }); + + test("excludes the current user when they are the author", function (assert) { + const result = computeReplyAudience( + { user_id: 5, username: "alice" }, + 5 + ); + assert.deepEqual(result, []); + }); + + test("includes the explicit targets", function (assert) { + const result = computeReplyAudience( + { + user_id: 1, + username: "author", + mod_whisper_targets: [ + { id: 2, username: "bob", avatar_template: "/b.png" }, + { id: 3, username: "carol", avatar_template: "/c.png" }, + ], + }, + 99 + ); + assert.deepEqual(result.map((u) => u.id).sort(), [1, 2, 3]); + }); + + test("excludes the current user from the targets", function (assert) { + const result = computeReplyAudience( + { + user_id: 1, + username: "author", + mod_whisper_targets: [ + { id: 2, username: "bob" }, + { id: 7, username: "me" }, + ], + }, + 7 + ); + assert.deepEqual(result.map((u) => u.id).sort(), [1, 2]); + }); + + test("deduplicates author appearing as a target", function (assert) { + const result = computeReplyAudience( + { + user_id: 1, + username: "author", + mod_whisper_targets: [ + { id: 1, username: "author" }, + { id: 2, username: "bob" }, + ], + }, + 99 + ); + assert.deepEqual(result.map((u) => u.id).sort(), [1, 2]); + }); + + test("tolerates a non-array mod_whisper_targets", function (assert) { + const result = computeReplyAudience( + { user_id: 1, username: "author", mod_whisper_targets: "nope" }, + 99 + ); + assert.deepEqual(result.map((u) => u.id), [1]); + }); + + test("ignores malformed target entries", function (assert) { + const result = computeReplyAudience( + { + user_id: 1, + username: "author", + mod_whisper_targets: [null, undefined, 42, { id: 2, username: "b" }], + }, + 99 + ); + assert.deepEqual(result.map((u) => u.id).sort(), [1, 2]); + }); + } +); diff --git a/test/javascripts/unit/precheck-prompt-test.js b/test/javascripts/unit/precheck-prompt-test.js new file mode 100644 index 0000000..ba04786 --- /dev/null +++ b/test/javascripts/unit/precheck-prompt-test.js @@ -0,0 +1,350 @@ +import { module, test } from "qunit"; +import { CREATE_TOPIC } from "discourse/models/composer"; +import { + precheckPromptFor, + REPLY_ACTION, +} from "discourse/plugins/dumbcourse/discourse/lib/precheck-prompt"; + +// Exhaustive matrix coverage for the composer precheck prompt resolution. +// Pure-function unit tests; end-to-end behaviour is in spec/system. + +const PROMPTS = [ + { label: "undefined", value: undefined, blank: true }, + { label: "null", value: null, blank: true }, + { label: "empty", value: "", blank: true }, + { label: "spaces", value: " ", blank: true }, + { label: "tab", value: "\t", blank: true }, + { label: "newline", value: "\n", blank: true }, + { label: "mixed-ws", value: " \n\t ", blank: true }, + { label: "text", value: "Read the rules", trimmed: "Read the rules" }, + { + label: "padded", + value: " Read the rules ", + trimmed: "Read the rules", + }, + { + label: "app-warning", + value: + "Is this an app upload or link to an app? If it's just a comment or question, please post somewhere else.", + trimmed: + "Is this an app upload or link to an app? If it's just a comment or question, please post somewhere else.", + }, + { label: "html", value: "<b>careful</b>", trimmed: "<b>careful</b>" }, + { label: "unicode", value: "🚀 проверка 名前", trimmed: "🚀 проверка 名前" }, + { label: "long", value: "x".repeat(800), trimmed: "x".repeat(800) }, + { label: "digit", value: "0", trimmed: "0" }, +]; + +module("Unit | discourse-mod | precheck-prompt | new topic", function () { + [true, false].forEach((enabled) => { + [true, false].forEach((replyFlag) => { + PROMPTS.forEach((p) => { + const expected = enabled && !p.blank ? p.trimmed : null; + test(`enabled=${enabled} replyFlag=${replyFlag} prompt=${p.label}`, function (assert) { + const composer = { + action: CREATE_TOPIC, + category: { mod_category_new_topic_prompt: p.value }, + topic: { mod_topic_reply_prompt: "should be ignored" }, + }; + const siteSettings = { + precheck_new_topic_enabled: enabled, + topic_reply_prompt_enabled: replyFlag, + }; + assert.strictEqual( + precheckPromptFor(composer, siteSettings), + expected + ); + }); + }); + }); + }); + + test("no category resolves to null", function (assert) { + assert.strictEqual( + precheckPromptFor( + { action: CREATE_TOPIC }, + { precheck_new_topic_enabled: true } + ), + null + ); + }); + + test("null composer resolves to null", function (assert) { + assert.strictEqual( + precheckPromptFor(null, { precheck_new_topic_enabled: true }), + null + ); + }); + + test("null siteSettings resolves to null", function (assert) { + assert.strictEqual( + precheckPromptFor({ action: CREATE_TOPIC }, null), + null + ); + }); +}); + +module("Unit | discourse-mod | precheck-prompt | reply", function () { + [true, false].forEach((enabled) => { + [true, false].forEach((newTopicFlag) => { + PROMPTS.forEach((p) => { + const expected = enabled && !p.blank ? p.trimmed : null; + test(`enabled=${enabled} newTopicFlag=${newTopicFlag} prompt=${p.label}`, function (assert) { + const composer = { + action: REPLY_ACTION, + topic: { mod_topic_reply_prompt: p.value }, + category: { mod_category_new_topic_prompt: "should be ignored" }, + }; + const siteSettings = { + topic_reply_prompt_enabled: enabled, + precheck_new_topic_enabled: newTopicFlag, + }; + assert.strictEqual( + precheckPromptFor(composer, siteSettings), + expected + ); + }); + }); + }); + }); + + test("no topic resolves to null", function (assert) { + assert.strictEqual( + precheckPromptFor( + { action: REPLY_ACTION }, + { topic_reply_prompt_enabled: true } + ), + null + ); + }); + + test("REPLY_ACTION constant is 'reply'", function (assert) { + assert.strictEqual(REPLY_ACTION, "reply"); + }); +}); + +module( + "Unit | discourse-mod | precheck-prompt | other actions", + function () { + ["edit", "privateMessage", "editSharedDraft", "", null, undefined].forEach( + (action) => { + test(`action=${JSON.stringify(action)} is never gated`, function (assert) { + const composer = { + action, + category: { mod_category_new_topic_prompt: "x" }, + topic: { mod_topic_reply_prompt: "y" }, + }; + assert.strictEqual( + precheckPromptFor(composer, { + precheck_new_topic_enabled: true, + topic_reply_prompt_enabled: true, + }), + null + ); + }); + } + ); + } +); + +module( + "Unit | discourse-mod | precheck-prompt | advanced scenarios", + function () { + test("new topic uses the category prompt, ignores the topic prompt", function (assert) { + const composer = { + action: CREATE_TOPIC, + category: { mod_category_new_topic_prompt: "CATEGORY MESSAGE" }, + topic: { mod_topic_reply_prompt: "TOPIC MESSAGE" }, + }; + assert.strictEqual( + precheckPromptFor(composer, { + precheck_new_topic_enabled: true, + topic_reply_prompt_enabled: true, + }), + "CATEGORY MESSAGE" + ); + }); + + test("reply uses the topic prompt, ignores the category prompt", function (assert) { + const composer = { + action: REPLY_ACTION, + category: { mod_category_new_topic_prompt: "CATEGORY MESSAGE" }, + topic: { mod_topic_reply_prompt: "TOPIC MESSAGE" }, + }; + assert.strictEqual( + precheckPromptFor(composer, { + precheck_new_topic_enabled: true, + topic_reply_prompt_enabled: true, + }), + "TOPIC MESSAGE" + ); + }); + + test("new topic not gated when only the reply feature is on", function (assert) { + assert.strictEqual( + precheckPromptFor( + { + action: CREATE_TOPIC, + category: { mod_category_new_topic_prompt: "X" }, + }, + { topic_reply_prompt_enabled: true } + ), + null + ); + }); + + test("reply not gated when only the new-topic feature is on", function (assert) { + assert.strictEqual( + precheckPromptFor( + { action: REPLY_ACTION, topic: { mod_topic_reply_prompt: "X" } }, + { precheck_new_topic_enabled: true } + ), + null + ); + }); + + [ + [" spaced ", "spaced"], + ["\ttabbed\t", "tabbed"], + ["\nline\n", "line"], + ["no-trim-needed", "no-trim-needed"], + ].forEach(([input, expected]) => { + test(`new topic trims ${JSON.stringify(input)}`, function (assert) { + assert.strictEqual( + precheckPromptFor( + { + action: CREATE_TOPIC, + category: { mod_category_new_topic_prompt: input }, + }, + { precheck_new_topic_enabled: true } + ), + expected + ); + }); + + test(`reply trims ${JSON.stringify(input)}`, function (assert) { + assert.strictEqual( + precheckPromptFor( + { action: REPLY_ACTION, topic: { mod_topic_reply_prompt: input } }, + { topic_reply_prompt_enabled: true } + ), + expected + ); + }); + }); + + test("extra unrelated siteSettings keys do not affect the result", function (assert) { + assert.strictEqual( + precheckPromptFor( + { + action: CREATE_TOPIC, + category: { mod_category_new_topic_prompt: "Y" }, + }, + { + precheck_new_topic_enabled: true, + some_other_setting: true, + foo: "bar", + } + ), + "Y" + ); + }); + + test("category present but missing the prompt field resolves to null", function (assert) { + assert.strictEqual( + precheckPromptFor( + { action: CREATE_TOPIC, category: {} }, + { precheck_new_topic_enabled: true } + ), + null + ); + }); + + test("topic present but missing the prompt field resolves to null", function (assert) { + assert.strictEqual( + precheckPromptFor( + { action: REPLY_ACTION, topic: {} }, + { topic_reply_prompt_enabled: true } + ), + null + ); + }); + } +); + +module( + "Unit | discourse-mod | precheck-prompt | trust-level cap", + function () { + // maxTl is the highest trust level still prompted. A user is exempt + // only when their trust level is strictly above the cap. + const CASES = [ + // [maxTl, userTl, prompted?] + [undefined, 0, true], + [undefined, 4, true], + [4, 0, true], + [4, 4, true], + ["", 0, true], + [0, 0, true], + [0, 1, false], + [0, 4, false], + [1, 0, true], + [1, 1, true], + [1, 2, false], + [1, 3, false], + [2, 2, true], + [2, 3, false], + [3, 3, true], + [3, 4, false], + ["1", 2, false], + ["1", 1, true], + ]; + + CASES.forEach(([maxTl, userTl, prompted]) => { + test(`reply maxTl=${maxTl} userTl=${userTl} prompted=${prompted}`, function (assert) { + const result = precheckPromptFor( + { + action: REPLY_ACTION, + topic: { + mod_topic_reply_prompt: "Read the rules", + mod_topic_reply_prompt_max_tl: maxTl, + }, + }, + { topic_reply_prompt_enabled: true }, + { trust_level: userTl } + ); + assert.strictEqual(result, prompted ? "Read the rules" : null); + }); + + test(`new topic maxTl=${maxTl} userTl=${userTl} prompted=${prompted}`, function (assert) { + const result = precheckPromptFor( + { + action: CREATE_TOPIC, + category: { + mod_category_new_topic_prompt: "Read the rules", + mod_category_new_topic_prompt_max_tl: maxTl, + }, + }, + { precheck_new_topic_enabled: true }, + { trust_level: userTl } + ); + assert.strictEqual(result, prompted ? "Read the rules" : null); + }); + }); + + test("a cap with no current user still prompts", function (assert) { + assert.strictEqual( + precheckPromptFor( + { + action: REPLY_ACTION, + topic: { + mod_topic_reply_prompt: "Read the rules", + mod_topic_reply_prompt_max_tl: 1, + }, + }, + { topic_reply_prompt_enabled: true } + ), + "Read the rules" + ); + }); + } +); diff --git a/test/javascripts/unit/topic-footer-message-test.js b/test/javascripts/unit/topic-footer-message-test.js new file mode 100644 index 0000000..5eb2f0d --- /dev/null +++ b/test/javascripts/unit/topic-footer-message-test.js @@ -0,0 +1,221 @@ +import { module, test } from "qunit"; +import { + shouldRenderTopicFooterMessage, + topicFooterFeatureActive, + topicFooterMessage, +} from "discourse/plugins/dumbcourse/discourse/lib/topic-footer-message"; + +// Exhaustive matrix coverage for the moderator-set bottom-pinned message +// visibility decision (reads the topic's mod_topic_footer_message custom +// field). Pure-function unit tests; rendering is covered by spec/system. + +const MESSAGES = [ + { label: "undefined", value: undefined, blank: true }, + { label: "null", value: null, blank: true }, + { label: "empty", value: "", blank: true }, + { label: "spaces", value: " ", blank: true }, + { label: "tab", value: "\t", blank: true }, + { label: "newline", value: "\n", blank: true }, + { label: "mixed-ws", value: " \n\t ", blank: true }, + { label: "text", value: "Read the pinned guidelines", blank: false }, + { label: "padded", value: " hello ", blank: false }, + { label: "html", value: "<strong>Hi</strong>", blank: false }, + { label: "long", value: "x".repeat(2000), blank: false }, + { label: "unicode", value: "🚀 μνιςωδε", blank: false }, + { label: "digit", value: "0", blank: false }, + { label: "script-ish", value: "<script>x()</script>", blank: false }, +]; + +const TOPICS = [ + { label: "regular", archetype: "regular", render: true }, + { label: "no-archetype", archetype: undefined, render: true }, + { label: "banner", archetype: "banner", render: true }, + { label: "private_message", archetype: "private_message", render: false }, +]; + +const ENABLED = [ + { label: "true", value: true, on: true }, + { label: "false", value: false, on: false }, + { label: "absent", value: undefined, on: false }, +]; + +module( + "Unit | discourse-mod | topic-footer-message | shouldRender", + function () { + ENABLED.forEach((en) => { + MESSAGES.forEach((msg) => { + TOPICS.forEach((t) => { + const expected = en.on && !msg.blank && t.render; + test(`enabled=${en.label} msg=${msg.label} topic=${t.label} => ${expected}`, function (assert) { + const siteSettings = { topic_footer_message_enabled: en.value }; + const topic = { + archetype: t.archetype, + mod_topic_footer_message: msg.value, + }; + assert.strictEqual( + shouldRenderTopicFooterMessage(siteSettings, topic), + expected + ); + }); + }); + }); + }); + + test("null topic returns false", function (assert) { + assert.false( + shouldRenderTopicFooterMessage( + { topic_footer_message_enabled: true }, + null + ) + ); + }); + + test("undefined topic returns false", function (assert) { + assert.false( + shouldRenderTopicFooterMessage( + { topic_footer_message_enabled: true }, + undefined + ) + ); + }); + + test("null siteSettings returns false", function (assert) { + assert.false( + shouldRenderTopicFooterMessage(null, { + archetype: "regular", + mod_topic_footer_message: "hi", + }) + ); + }); + } +); + +module( + "Unit | discourse-mod | topic-footer-message | topicFooterMessage", + function () { + MESSAGES.forEach((msg) => { + const expected = msg.blank ? "" : msg.value.trim(); + test(`message=${msg.label} trims to expected`, function (assert) { + assert.strictEqual( + topicFooterMessage({ mod_topic_footer_message: msg.value }), + expected + ); + }); + }); + + test("null topic yields empty string", function (assert) { + assert.strictEqual(topicFooterMessage(null), ""); + }); + + test("undefined topic yields empty string", function (assert) { + assert.strictEqual(topicFooterMessage(undefined), ""); + }); + + test("topic without the field yields empty string", function (assert) { + assert.strictEqual(topicFooterMessage({}), ""); + }); + } +); + +module( + "Unit | discourse-mod | topic-footer-message | topicFooterFeatureActive", + function () { + // The structural gate is independent of the message text, so the + // connector can render and decide reactively whether to show the box. + ENABLED.forEach((en) => { + TOPICS.forEach((t) => { + ["", " ", "a message", undefined, null].forEach((msg, i) => { + const expected = en.on && t.render; + test(`enabled=${en.label} topic=${t.label} msg#${i} => ${expected}`, function (assert) { + const siteSettings = { topic_footer_message_enabled: en.value }; + const topic = { + archetype: t.archetype, + mod_topic_footer_message: msg, + }; + assert.strictEqual( + topicFooterFeatureActive(siteSettings, topic), + expected + ); + }); + }); + }); + }); + + test("null topic returns false", function (assert) { + assert.false( + topicFooterFeatureActive({ topic_footer_message_enabled: true }, null) + ); + }); + + test("null siteSettings returns false", function (assert) { + assert.false( + topicFooterFeatureActive(null, { archetype: "regular" }) + ); + }); + } +); + +module( + "Unit | discourse-mod | topic-footer-message | advanced scenarios", + function () { + test("HTML message is returned verbatim (trimmed) for trusted rendering", function (assert) { + assert.strictEqual( + topicFooterMessage({ + mod_topic_footer_message: + " <b>Bold</b> and <a href='#'>a link</a> ", + }), + "<b>Bold</b> and <a href='#'>a link</a>" + ); + }); + + test("feature stays active whether or not a message is present", function (assert) { + const settings = { topic_footer_message_enabled: true }; + assert.true( + topicFooterFeatureActive(settings, { + archetype: "regular", + mod_topic_footer_message: "", + }) + ); + assert.true( + topicFooterFeatureActive(settings, { + archetype: "regular", + mod_topic_footer_message: "a message", + }) + ); + }); + + test("overall visibility needs the gate AND a non-blank message", function (assert) { + const settings = { topic_footer_message_enabled: true }; + assert.false( + shouldRenderTopicFooterMessage(settings, { + archetype: "regular", + mod_topic_footer_message: " ", + }) + ); + assert.true( + shouldRenderTopicFooterMessage(settings, { + archetype: "regular", + mod_topic_footer_message: "visible", + }) + ); + }); + + test("private messages never activate the feature", function (assert) { + assert.false( + topicFooterFeatureActive( + { topic_footer_message_enabled: true }, + { archetype: "private_message", mod_topic_footer_message: "hi" } + ) + ); + }); + + test("disabled feature is never active", function (assert) { + assert.false( + topicFooterFeatureActive( + { topic_footer_message_enabled: false }, + { archetype: "regular", mod_topic_footer_message: "hi" } + ) + ); + }); + } +); diff --git a/test/javascripts/unit/trust-level-options-test.js b/test/javascripts/unit/trust-level-options-test.js new file mode 100644 index 0000000..e1efa5b --- /dev/null +++ b/test/javascripts/unit/trust-level-options-test.js @@ -0,0 +1,61 @@ +import { module, test } from "qunit"; +import { trustLevelOptions } from "discourse/plugins/dumbcourse/discourse/lib/trust-level-options"; + +// Pure-function unit tests for the trust-level audience dropdown options. +// The prompt caps include "Everyone" (4) and "Up to regulars" (3); the +// first-post checklist omits them since it only gates TL0-2. + +module("Unit | discourse-mod | trust-level-options", function () { + test("defaults to including all five audience choices", function (assert) { + const ids = trustLevelOptions().map((o) => o.id); + assert.deepEqual(ids, ["4", "0", "1", "2", "3"]); + }); + + test("includeAll=true keeps Everyone and Up-to-regulars", function (assert) { + const ids = trustLevelOptions(true).map((o) => o.id); + assert.deepEqual(ids, ["4", "0", "1", "2", "3"]); + }); + + test("includeAll=false drops the 3 and 4 choices", function (assert) { + const ids = trustLevelOptions(false).map((o) => o.id); + assert.deepEqual(ids, ["0", "1", "2"]); + }); + + test("every option has a string id and a name", function (assert) { + trustLevelOptions().forEach((o) => { + assert.strictEqual(typeof o.id, "string", `id ${o.id} is a string`); + assert.ok(o.name !== undefined && o.name !== null, `option ${o.id} has a name`); + }); + }); + + test("ids are unique", function (assert) { + const ids = trustLevelOptions().map((o) => o.id); + assert.strictEqual(new Set(ids).size, ids.length); + }); + + test("checklist subset is contained in the full set", function (assert) { + const full = new Set(trustLevelOptions(true).map((o) => o.id)); + trustLevelOptions(false).forEach((o) => { + assert.true(full.has(o.id), `id ${o.id} is also in the full set`); + }); + }); + + test("the TL0-TL2 options keep their order in both variants", function (assert) { + const full = trustLevelOptions(true).map((o) => o.id); + const subset = trustLevelOptions(false).map((o) => o.id); + assert.deepEqual(subset, ["0", "1", "2"]); + // 0,1,2 appear in the same relative order inside the full list. + assert.deepEqual( + full.filter((id) => subset.includes(id)), + subset + ); + }); + + test("returns a fresh array on each call", function (assert) { + const a = trustLevelOptions(); + const b = trustLevelOptions(); + assert.notStrictEqual(a, b); + a.push({ id: "99", name: "x" }); + assert.strictEqual(trustLevelOptions().length, 5); + }); +});