Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion .github/workflows/feature-screenshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ jobs:
RAILS_ENV: test
PGUSER: discourse
PGPASSWORD: discourse
PLUGIN_NAME: ${{ github.event.repository.name }}
# MUST be lowercase. Discourse derives the compiled stylesheet bundle
# slug from the on-disk plugin directory name, and the route that
# serves `/stylesheets/<slug>_<hash>.css` is constrained to
# `[-a-z0-9_]+`. Checking out into `plugins/JtechTools/` (the
# repo's casing) made every plugin CSS request 404 → text/html →
# "Refused to apply style" in the browser. Commit b284c8d fixed
# this for local dev; the CI workflow was missed.
PLUGIN_NAME: jtech-tools
CHEAP_SOURCE_MAPS: "1"
MINIO_RUNNER_LOG_LEVEL: DEBUG
MINIO_RUNNER_INSTALL_DIR: /home/discourse/.minio_runner
Expand Down
83 changes: 78 additions & 5 deletions app/controllers/discourse_mod_categories/messages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,17 @@ def add_note_reply
raise Discourse::InvalidParameters.new(:raw) if raw.empty?

replies = note_replies(topic)
replies << {
reply = {
"id" => SecureRandom.hex(8),
"user_id" => current_user.id,
"raw" => raw,
"created_at" => Time.zone.now.iso8601,
}
replies << reply
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)
notify_staff_of_reply(topic, reply)

render json: { replies: serialized_note_replies(topic) }
end
Expand Down Expand Up @@ -231,7 +232,7 @@ def notes_feed
{
topic_id: topic.id,
topic_title: topic.title,
url: "#{topic.relative_url}/#{topic.highest_post_number}",
url: "#{topic.relative_url}/#{topic.highest_post_number}#mod-private-note",
note: note,
reply_count: replies.is_a?(Array) ? replies.size : 0,
activity_at: activity_at,
Expand Down Expand Up @@ -368,14 +369,16 @@ def normalize_max_tl(value)
# 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.
# highest post number with a `#mod-private-note` anchor so the browser
# (and the component's own scroll-into-view) lands on the note section
# instead of the topic's first post when the topic is short.
#
# 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}"
note_url = "#{topic.relative_url}/#{topic.highest_post_number}#mod-private-note"

User
.where(admin: true)
Expand All @@ -388,6 +391,8 @@ def notify_staff_of_note(topic)
# Stable marker the frontend renderer keys off to recognize THIS
# custom notification as a moderator note.
mod_note: true,
mod_note_kind: "note",
excerpt: note.truncate(300),
url: note_url,
message: "discourse_mod_categories.note_notification",
title: "discourse_mod_categories.note_notification_title",
Expand All @@ -410,6 +415,48 @@ def notify_staff_of_note(topic)
end
end

# Sends a notification for a single reply in the moderator-note thread.
# Each reply gets its own bell row and live pop-up — carrying the reply
# author, the reply excerpt, and a URL anchored at the specific reply —
# so multiple replies in the same topic stack as distinct entries instead
# of looking like duplicate "note added" rows.
def notify_staff_of_reply(topic, reply)
reply_id = reply["id"].to_s
reply_raw = reply["raw"].to_s
reply_url =
"#{topic.relative_url}/#{topic.highest_post_number}#mod-private-note-reply-#{reply_id}"

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,
mod_note: true,
mod_note_kind: "reply",
reply_id: reply_id,
excerpt: reply_raw.truncate(300),
url: reply_url,
message: "discourse_mod_categories.note_reply_notification",
title: "discourse_mod_categories.note_reply_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_reply_alert(staff_user, topic, reply_raw, reply_url)
staff_user.publish_notifications_state
end
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
Expand Down Expand Up @@ -437,6 +484,32 @@ def publish_note_alert(staff_user, topic, note, note_url)
MessageBus.publish("/notification-alert/#{staff_user.id}", payload, user_ids: [staff_user.id])
end

# Per-reply variant of publish_note_alert — the excerpt is the reply body
# so a stack of replies pops up as distinct toasts, and the title says
# "replied to" so the recipient can tell a reply from the original note.
def publish_reply_alert(staff_user, topic, reply_raw, reply_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: reply_raw.truncate(300),
username: current_user.username,
post_url: reply_url,
translated_title:
I18n.t(
"discourse_mod_categories.note_reply_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)
Expand Down
41 changes: 39 additions & 2 deletions assets/javascripts/discourse/components/mod-private-note.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,36 @@ export default class ModPrivateNote extends Component {
this.readTopicState(topic);
}

// Notifications and the user-menu notes feed link to the topic with a
// `#mod-private-note` or `#mod-private-note-reply-<id>` hash. Without an
// explicit scroll, Discourse's post-stream scrolls the linked post into
// view AFTER the browser's native hash jump, leaving the target
// off-screen — especially when the topic only has one post, which
// silently lands at the top of the thread. Each reply article also
// carries its own id so a reply notification anchors to that reply.
@action
scrollToNoteIfAnchored() {
if (typeof window === "undefined") {
return;
}
const hash = window.location.hash || "";
if (
hash !== "#mod-private-note" &&
!hash.startsWith("#mod-private-note-reply-")
) {
return;
}
// Defer past Discourse's own scroll-to-post on initial topic load,
// and resolve the element after the replies finish rendering — a
// per-reply hash may point at an article that isn't in the DOM yet
// when the outer note container inserts.
setTimeout(() => {
const id = hash.slice(1);
const target = document.getElementById(id);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 250);
}

// 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
Expand Down Expand Up @@ -347,7 +377,11 @@ export default class ModPrivateNote extends Component {
{{didUpdate this.refreshOnNavigation @topic.id}}
>
{{#if this.visible}}
<div class="mod-private-note">
<div
id="mod-private-note"
class="mod-private-note"
{{didInsert this.scrollToNoteIfAnchored}}
>
<div class="mod-private-note-marker">
{{icon "lock"}}
<span>{{i18n
Expand Down Expand Up @@ -395,7 +429,10 @@ export default class ModPrivateNote extends Component {
</article>

{{#each this.decoratedReplies as |reply|}}
<article class="mod-private-note-post mod-private-note-reply">
<article
id="mod-private-note-reply-{{reply.id}}"
class="mod-private-note-post mod-private-note-reply"
>
{{#if reply.avatarUrl}}
<img
class="mod-private-note-avatar"
Expand Down
37 changes: 28 additions & 9 deletions assets/javascripts/discourse/lib/mod-note-notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@ export default function modNoteNotificationRenderer(NotificationTypeBase) {
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`).
// "note" (default) vs "reply" — every reply in the note thread gets
// its own notification row, so the renderer keys off this to label
// and describe each one distinctly. Pre-`mod_note_kind` rows (set
// before this field existed) are treated as the original note.
get modNoteKind() {
return this.notification.data?.mod_note_kind || "note";
}

// Link straight to the moderator note (or the specific reply) on the
// topic — anchored with a `#mod-private-note[-reply-<id>]` hash that
// the note component scrolls into view on insert.
get linkHref() {
if (this.isModNote && this.notification.data?.url) {
return this.notification.data.url;
Expand All @@ -26,7 +35,9 @@ export default function modNoteNotificationRenderer(NotificationTypeBase) {

get linkTitle() {
if (this.isModNote) {
return i18n("discourse_mod_categories.note_notification_title");
return this.modNoteKind === "reply"
? i18n("discourse_mod_categories.note_reply_notification_title")
: i18n("discourse_mod_categories.note_notification_title");
}
// Core `custom.js` behavior.
if (this.notification.data?.title) {
Expand All @@ -45,20 +56,28 @@ export default function modNoteNotificationRenderer(NotificationTypeBase) {
return `notification.${this.notification.data?.message}`;
}

// Accurate, self-describing label naming the acting moderator.
// Accurate, self-describing label naming the acting moderator —
// "added a moderator note" vs "replied to a moderator note".
get label() {
if (this.isModNote) {
return i18n("discourse_mod_categories.note_notification", {
username: this.username,
});
return this.modNoteKind === "reply"
? i18n("discourse_mod_categories.note_reply_notification", {
username: this.username,
})
: i18n("discourse_mod_categories.note_notification", {
username: this.username,
});
}
return super.label;
}

// Second line: the topic the note is on.
// Second line: the reply excerpt (so stacked reply notifications are
// self-describing) when available, falling back to the topic title.
get description() {
if (this.isModNote) {
return this.notification.data?.topic_title;
return (
this.notification.data?.excerpt || this.notification.data?.topic_title
);
}
return super.description;
}
Expand Down
2 changes: 2 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ en:
empty: No moderator notes yet.
note_notification: '%{username} added a moderator note'
note_notification_title: Moderator note
note_reply_notification: '%{username} replied to a moderator note'
note_reply_notification_title: Moderator note reply
audience:
label: Show this prompt to
all: Everyone
Expand Down
1 change: 1 addition & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,4 @@ en:

discourse_mod_categories:
note_notification_alert: '%{username} added a moderator note on "%{topic}"'
note_reply_notification_alert: '%{username} replied to a moderator note on "%{topic}"'
4 changes: 2 additions & 2 deletions spec/requests/mod_messages_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -703,15 +703,15 @@ def seed_thread
expect(response.status).to eq(403)
end

it "links each note to the topic's last post" do
it "links each note to the topic's last post anchored at the mod-private-note section" 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})
expect(url).to match(%r{/#{topic.id}/\d+#mod-private-note\z})
end
end

Expand Down
52 changes: 50 additions & 2 deletions spec/requests/mod_note_notifications_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ def set_note(raw = "Heads up, staff.")
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}")
expect(data["url"]).to eq(
"#{topic.relative_url}/#{topic.reload.highest_post_number}#mod-private-note",
)
end

it "publishes a live pop-up alert to every other staff member" do
Expand All @@ -107,7 +109,9 @@ def set_note(raw = "Heads up, staff.")
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[:post_url]).to eq(
"#{topic.relative_url}/#{topic.reload.highest_post_number}#mod-private-note",
)
expect(payload[:translated_title]).to include(moderator.username)
expect(payload[:translated_title]).to include(topic.title)
end
Expand Down Expand Up @@ -171,6 +175,50 @@ def set_note(raw = "Heads up, staff.")
expect(alerted).to include("/notification-alert/#{admin.id}")
end

it "marks each reply notification as a reply with the excerpt and anchored URL" do
sign_in(moderator)

post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json",
params: {
raw: "Following up on this thread.",
}

data = JSON.parse(custom_notifications(admin).first.data)
expect(data["mod_note"]).to eq(true)
expect(data["mod_note_kind"]).to eq("reply")
expect(data["excerpt"]).to eq("Following up on this thread.")
expect(data["reply_id"]).to be_present
expect(data["message"]).to eq("discourse_mod_categories.note_reply_notification")
expect(data["url"]).to eq(
"#{topic.relative_url}/#{topic.reload.highest_post_number}#mod-private-note-reply-#{data["reply_id"]}",
)
end

it "creates a distinct notification per reply so they stack in the bell" 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.",
}
post "/discourse-mod-categories/topic/#{topic.id}/note-reply.json",
params: {
raw: "Third reply.",
}

rows = custom_notifications(admin).order(:id)
expect(rows.count).to eq(3)

excerpts = rows.map { |n| JSON.parse(n.data)["excerpt"] }
reply_ids = rows.map { |n| JSON.parse(n.data)["reply_id"] }
expect(excerpts).to eq(["First reply.", "Second reply.", "Third reply."])
expect(reply_ids.uniq.size).to eq(3)
end

it "does not notify the moderator who wrote the reply" do
sign_in(other_moderator)

Expand Down
Loading