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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 59 additions & 20 deletions app/controllers/discourse_mod_categories/messages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def update_topic
reply_prompt: topic.custom_fields[TOPIC_REPLY_PROMPT_FIELD].to_s,
reply_prompt_max_tl: topic.custom_fields[TOPIC_REPLY_PROMPT_TL_FIELD],
pinned_post_id: topic.custom_fields[TOPIC_PINNED_POST_FIELD],
pinned_post: DiscourseModCategories.serialized_pinned_post(topic),
require_reply_approval: !!topic.custom_fields[TOPIC_REQUIRE_REPLY_APPROVAL_FIELD],
private_note: topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD].to_s,
private_note_position:
Expand Down Expand Up @@ -358,24 +359,41 @@ def mark_topic_notifications_seen
# Marks the current user's mod_note notifications whose `url` points
# at /review/... as read. Called by the frontend whenever the user
# navigates to /review or /review/:id — so flag_note / post_rejected
# notifications (which link to /review/:id rather than to a topic
# page) get marked read on direct navigation, not only via the bell-
# click or shield-tab-open paths. The data-column LIKE pins the
# update to mod_note rows whose URL starts with /review so we don't
# touch unrelated notifications.
# / post_approved notifications (which link to /review/:id rather
# than to a topic page) get marked read on direct navigation, not
# only via the bell-click or shield-tab-open paths.
#
# Scope rules:
# - `reviewable_id` param present → mark ONLY notifications whose
# data.url is exactly /review/<id> (or starts with /review/<id>/
# for sub-paths). Prevents clicking one queued-post notification
# from sweeping every other reviewable's notifications read.
# - param absent (visiting the /review index) → mark every
# /review-anchored mod_note row, matching the original behaviour
# since the staff member is viewing all reviewables at once.
def mark_review_notifications_seen
guardian.ensure_can_manage_mod_messages!

marked =
::Notification
.where(
user_id: current_user.id,
notification_type: ::Notification.types[:custom],
read: false,
scope =
::Notification.where(
user_id: current_user.id,
notification_type: ::Notification.types[:custom],
read: false,
).where("data LIKE ?", "%\"mod_note\":true%")

reviewable_id = params[:reviewable_id].to_s
if reviewable_id =~ /\A\d+\z/
scope =
scope.where(
"data LIKE ? OR data LIKE ?",
"%\"url\":\"/review/#{reviewable_id}\"%",
"%\"url\":\"/review/#{reviewable_id}/%",
)
.where("data LIKE ?", "%\"mod_note\":true%")
.where("data LIKE ?", "%\"url\":\"/review%")
.update_all(read: true)
else
scope = scope.where("data LIKE ?", "%\"url\":\"/review%")
end

marked = scope.update_all(read: true)

current_user.publish_notifications_state if marked > 0

Expand Down Expand Up @@ -405,10 +423,19 @@ def notes_feed
.limit(50)
.pluck(:topic_id)

# `Topic.where(id: topic_ids)` doesn't preserve the topic_ids order
# we just computed (Postgres returns rows in id order, not the order
# of the IN-list), so index by id and re-iterate in topic_ids order.
# Critical when several notes share an iso8601 second — the combined
# recency sort below ties on identical timestamps, and Ruby's stable
# sort falls back to insertion order; without this, the panel
# surfaces oldest-first instead of newest-first.
topics_by_id = Topic.where(id: topic_ids).index_by(&:id)
topic_notes =
Topic
.where(id: topic_ids)
.map do |topic|
topic_ids
.map do |id|
topic = topics_by_id[id]
next unless topic
note = topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD].to_s
next if note.blank?
replies = topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD]
Expand All @@ -427,8 +454,6 @@ def notes_feed
}
end
.compact
.sort_by { |n| n[:activity_at].to_s }
.reverse

# Non-topic-anchored event notifications: rows whose mod_note_kind
# is NOT "note" or "reply" (those are already covered by the
Expand Down Expand Up @@ -469,7 +494,21 @@ def notes_feed
}
end

render json: { notes: topic_notes + events }
# Merge both sources into a single recency-sorted list. Rows missing
# both timestamps fall to the bottom (sort tuple [1, 0]) regardless of
# how malformed the data is — rescue keeps a corrupt timestamp from
# 500-ing the feed.
combined =
(topic_notes + events).sort_by do |n|
ts = n[:activity_at].presence || n[:created_at].presence
begin
ts.present? ? [0, -Time.zone.parse(ts.to_s).to_i] : [1, 0]
rescue ArgumentError, TypeError
[1, 0]
end
end

render json: { notes: combined }
end

# Marks the staff user's moderator-note feed as read.
Expand Down
16 changes: 16 additions & 0 deletions assets/javascripts/discourse/components/mod-notes-panel.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ export default class ModNotesPanel extends Component {
@tracked notes = [];
@tracked loading = true;

// Deep-link from the panel's "View more" link to the standard
// notifications page with our ?type=mod_notes filter pre-applied (see
// notifications-type-filter.js + the NotificationsController patch).
get viewMoreUrl() {
const username = this.currentUser?.username;
if (!username) {
return null;
}
return `/u/${username}/notifications?type=mod_notes`;
}

constructor() {
super(...arguments);
this.load();
Expand Down Expand Up @@ -114,6 +125,11 @@ export default class ModNotesPanel extends Component {
</li>
{{/each}}
</ul>
{{#if this.viewMoreUrl}}
<a href={{this.viewMoreUrl}} class="mod-notes-view-more">
{{i18n "discourse_mod_categories.notes_tab.view_more"}}
</a>
{{/if}}
{{else}}
<div class="mod-notes-empty">
{{i18n "discourse_mod_categories.notes_tab.empty"}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";

// Some plugin-defined notification types don't have a
// `notifications.titles.X` translation, in which case i18n() returns the
// bracketed placeholder "[en.notifications.titles.X]" — ugly inside a
// dropdown. discourse-i18n's default export is the `i18n` FUNCTION (not
// an I18n object with `.lookup`), so the cleanest probe is to call i18n
// and check for the bracket marker on the returned string. On a miss,
// fall back to a humanized version of the type name
// ("chat_group_mention" → "Chat group mention") so the row reads cleanly.
function nameForType(type) {
const value = i18n(`notifications.titles.${type}`);
if (value && !value.startsWith("[")) {
return value;
}
return type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

// Second filter dropdown rendered next to Discourse's built-in
// All / Read / Unread filter on the user notifications page. Options come
// from `site.notification_types` so we automatically stay in sync as
// Discourse (or any plugin) adds new types. The `mod_notes` pseudo-type
// is staff-only and is gated server-side too — see the NotificationsController
// patch in sub_plugins/mod_categories.rb.
const ALL = "all";
const MOD_NOTES = "mod_notes";

export default class NotificationsTypeFilter extends Component {
@service router;
@service site;
@service currentUser;

get options() {
const items = [
{
id: ALL,
name: i18n("discourse_mod_categories.notification_type_filter.all"),
},
];

const types = this.site?.notification_types || {};
Object.keys(types)
.sort()
.forEach((name) => {
items.push({ id: name, name: nameForType(name) });
});

if (this.currentUser?.staff) {
items.push({
id: MOD_NOTES,
name: i18n(
"discourse_mod_categories.notification_type_filter.mod_notes"
),
});
}

return items;
}

get selectedValue() {
// Read straight from window.location since `type` isn't a declared
// controller queryParam (see the long note in the initializer about
// why class-field queryParams can't be extended via api.modifyClass).
if (typeof window === "undefined") {
return ALL;
}
return new URLSearchParams(window.location.search).get("type") || ALL;
}

@action
onChange(value) {
// Same reason — transitionTo({queryParams: {type: ...}}) silently
// drops `type` because Ember doesn't know about it. Mutate the URL
// directly and let the route's refreshModel logic re-fire via the
// URL change. Using router.transitionTo with the path+search string
// keeps the transition inside Ember (no full page reload).
const url = new URL(window.location.href);
if (value === ALL) {
url.searchParams.delete("type");
} else {
url.searchParams.set("type", value);
}
this.router.transitionTo(url.pathname + url.search);
}

<template>
<div class="notifications-type-filter">
<span class="filter-text">
{{i18n "discourse_mod_categories.notification_type_filter.label"}}
</span>
<ComboBox
@value={{this.selectedValue}}
@content={{this.options}}
@nameProperty="name"
@valueProperty="id"
@onChange={{this.onChange}}
@options={{hash filterable=true}}
class="notifications-type-filter-select"
/>
</div>
</template>
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default class TopicFooterMessage extends Component {

@tracked footerMessage = topicFooterMessage(this.topic);
@tracked pinnedPostId = this.topic?.mod_topic_pinned_post_id || null;
@tracked pinnedPostPayload = this.topic?.mod_topic_pinned_post || null;
@tracked cookedFooterMessage = null;

constructor() {
Expand Down Expand Up @@ -84,6 +85,7 @@ export default class TopicFooterMessage extends Component {
readTopicState(topic) {
this.footerMessage = topicFooterMessage(topic);
this.pinnedPostId = topic?.mod_topic_pinned_post_id || null;
this.pinnedPostPayload = topic?.mod_topic_pinned_post || null;
this.cookFooterMessage();
}

Expand All @@ -106,10 +108,18 @@ export default class TopicFooterMessage extends Component {
return this.cookedFooterMessage;
}

// Prefer the topic-attached payload (serialized server-side and returned
// by the pin endpoint) so the bottom copy renders immediately, even when
// the pinned post lives outside the currently-loaded post-stream window.
// The `postStream.posts` lookup is the historical fallback — kept so a
// stale topic-view that predates the new field still renders.
get pinnedPost() {
if (!this.pinnedPostId) {
return null;
}
if (this.pinnedPostPayload?.id === this.pinnedPostId) {
return this.pinnedPostPayload;
}
return (
this.topic?.postStream?.posts?.find((p) => p.id === this.pinnedPostId) ||
null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default {
}
);
topic?.set("mod_topic_pinned_post_id", result.pinned_post_id);
topic?.set("mod_topic_pinned_post", result.pinned_post || null);
if (topic) {
appEvents.trigger("discourse-mod:messages-updated", topic);
// Re-render the stream so the in-stream pin badge appears
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import { ajax } from "discourse/lib/ajax";

const REVIEW_URL_RE = /^\/review(\/\d+)?(\?.*)?$/;
const REVIEW_URL_RE = /^\/review(?:\/(\d+))?(?:\?.*)?$/;

// Marks the current user's mod_note notifications whose URL points at
// /review/... as read whenever they navigate to /review or /review/:id.
//
// Without this, a staff member who lands on the review queue via a
// bookmark, a direct URL paste, or a link from outside the bell drop-
// down would see the related flag_note / post_rejected notifications
// stay unread in the shield-tab and bell badge — only the bell-click
// path and the shield-tab-open path mark them read otherwise.
// down would see the related flag_note / post_rejected / post_approved
// notifications stay unread in the shield-tab and bell badge — only the
// bell-click path and the shield-tab-open path mark them read otherwise.
//
// Backend filter pins this to mod_note rows whose `data.url` starts
// with /review, so we don't touch unrelated notifications.
// When the URL targets a specific reviewable (/review/123), the id is
// forwarded so the backend can scope mark-as-read to that ONE row.
// Without that scoping, clicking a single notification swept every
// other reviewable's notifications read too — the exact bug reported
// after the staff-streams rollout. Hitting /review (the index) keeps
// the broad sweep since the staff member is viewing everything at once.
export default {
name: "discourse-mod-review-notifications-clear",

Expand All @@ -28,11 +32,15 @@ export default {
if (typeof url !== "string") {
return;
}
if (!REVIEW_URL_RE.test(url)) {
const match = url.match(REVIEW_URL_RE);
if (!match) {
return;
}
const reviewableId = match[1];
const data = reviewableId ? { reviewable_id: reviewableId } : {};
ajax("/discourse-mod-categories/review/notifications/seen", {
type: "POST",
data,
}).catch(() => {
// Silent — marking-read on review-page open is best-effort.
// A failure here just means the notification stays unread,
Expand Down
Loading