From d5519068970d6a2c4aacf10bf361084bf72dbfe4 Mon Sep 17 00:00:00 2001
From: Renato Atilio
Date: Mon, 23 Feb 2026 10:31:18 -0300
Subject: [PATCH 001/168] DEV: restrict everyone on whisper groups via a new
disallowed_groups attr (#37515)
Introduces support for a new `disallowed_groups` option for site
settings of type `group_list`.
This allows a site setting to specify groups that should be hidden from
the group selector in the UI and stripped from values set via the API.
---
config/site_settings.yml | 4 ++
.../components/site-settings/group-list.gjs | 11 ++--
.../components/group-list-setting-test.gjs | 54 +++++++++++++++++++
lib/site_setting_extension.rb | 11 ++++
lib/site_settings/type_supervisor.rb | 1 +
spec/lib/site_setting_extension_spec.rb | 18 +++++++
6 files changed, 95 insertions(+), 4 deletions(-)
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 401b66d49823d..b26acfbd97f6b 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -16,6 +16,9 @@
# The key should be as the same as possible value of default_locale.
# mandatory_values - A list of mandatory values that must be included in the setting, these cannot be changed or removed
# in the UI.
+# disallowed_groups - A list of group IDs (pipe-separated) that should be hidden from the group selector.
+# These groups cannot be selected and will be stripped if set via API.
+# Only applies to group_list type settings.
# requires_confirmation - A string that indicates if the setting requires confirmation before it can be changed.
# Only valid value here is "simple" which will display a confirmation dialog when the setting
# is changed.
@@ -449,6 +452,7 @@ basic:
default: ""
allow_any: false
refresh: true
+ disallowed_groups: "0"
area: "group_permissions"
hidden_post_visible_groups:
type: group_list
diff --git a/frontend/discourse/admin/components/site-settings/group-list.gjs b/frontend/discourse/admin/components/site-settings/group-list.gjs
index a10192021f508..b9127111a3ae1 100644
--- a/frontend/discourse/admin/components/site-settings/group-list.gjs
+++ b/frontend/discourse/admin/components/site-settings/group-list.gjs
@@ -10,11 +10,14 @@ export default class GroupList extends Component {
nameProperty = "name";
valueProperty = "id";
- @computed("site.groups")
+ @computed("site.groups", "setting.disallowed_groups")
get groupChoices() {
- return (this.site.groups || []).map((g) => {
- return { name: g.name, id: g.id.toString() };
- });
+ const disallowed = (this.setting?.disallowed_groups || "")
+ .split("|")
+ .filter(Boolean);
+ return (this.site.groups || [])
+ .filter((g) => !disallowed.includes(g.id.toString()))
+ .map((g) => ({ name: g.name, id: g.id.toString() }));
}
@computed("value")
diff --git a/frontend/discourse/tests/integration/components/group-list-setting-test.gjs b/frontend/discourse/tests/integration/components/group-list-setting-test.gjs
index 465fc8b0f461b..912eef74b7199 100644
--- a/frontend/discourse/tests/integration/components/group-list-setting-test.gjs
+++ b/frontend/discourse/tests/integration/components/group-list-setting-test.gjs
@@ -102,4 +102,58 @@ module("Integration | Component | group-list site-setting", function (hooks) {
assert.dom(".selected-content button").hasClass("disabled");
});
+
+ test("disallowed groups", async function (assert) {
+ this.site.groups = [
+ {
+ id: 0,
+ name: "everyone",
+ },
+ {
+ id: 1,
+ name: "Donuts",
+ },
+ {
+ id: 2,
+ name: "Cheese cake",
+ },
+ ];
+
+ this.set(
+ "setting",
+ SiteSetting.create({
+ category: "foo",
+ default: "",
+ description: "Choose groups",
+ placeholder: null,
+ preview: null,
+ secret: false,
+ setting: "foo_bar",
+ type: "group_list",
+ disallowed_groups: "0",
+ value: "",
+ })
+ );
+
+ await render(
+
+ );
+
+ const subject = selectKit(".list-setting");
+
+ await subject.expand();
+
+ assert.false(
+ subject.rowByValue("0").exists(),
+ "disallowed group is not in the list"
+ );
+ assert.true(
+ subject.rowByValue("1").exists(),
+ "allowed group is in the list"
+ );
+ assert.true(
+ subject.rowByValue("2").exists(),
+ "allowed group is in the list"
+ );
+ });
});
diff --git a/lib/site_setting_extension.rb b/lib/site_setting_extension.rb
index f7dd93b2ec140..41632988f5059 100644
--- a/lib/site_setting_extension.rb
+++ b/lib/site_setting_extension.rb
@@ -138,6 +138,10 @@ def mandatory_values
@mandatory_values ||= {}
end
+ def disallowed_groups
+ @disallowed_groups ||= {}
+ end
+
def shadowed_settings
@shadowed_settings ||= Set.new
end
@@ -466,6 +470,7 @@ def all_settings(
secret: secret_settings.include?(s),
placeholder: placeholder(s),
mandatory_values: mandatory_values[s],
+ disallowed_groups: disallowed_groups[s],
requires_confirmation: requires_confirmation_settings[s],
upcoming_change: only_upcoming_changes ? upcoming_change_metadata[s] : nil,
themeable: themeable[s],
@@ -697,6 +702,11 @@ def add_override!(name, val)
(mandatory_values[name.to_sym].split("|") | sanitized_val.to_s.split("|")).join("|")
end
+ if disallowed_groups[name.to_sym]
+ disallowed = disallowed_groups[name.to_sym].split("|")
+ sanitized_val = sanitized_val.to_s.split("|").reject { |v| disallowed.include?(v) }.join("|")
+ end
+
provider.save(name, sanitized_val, type)
current[name] = type_supervisor.to_rb_value(name, sanitized_val)
modified[name] = current[name]
@@ -1110,6 +1120,7 @@ def setting(name_arg, default = nil, opts = {})
defaults.load_setting(name, default, opts.delete(:locale_default))
mandatory_values[name] = opts[:mandatory_values] if opts[:mandatory_values]
+ disallowed_groups[name] = opts[:disallowed_groups] if opts[:disallowed_groups]
requires_confirmation_settings[name] = (
if SiteSettings::TypeSupervisor::REQUIRES_CONFIRMATION_TYPES.values.include?(
diff --git a/lib/site_settings/type_supervisor.rb b/lib/site_settings/type_supervisor.rb
index 7820b8ff517b0..36e623408b716 100644
--- a/lib/site_settings/type_supervisor.rb
+++ b/lib/site_settings/type_supervisor.rb
@@ -26,6 +26,7 @@ class SiteSettings::TypeSupervisor
depends_behavior
authorized_extensions
max_file_size_kb
+ disallowed_groups
].freeze
VALIDATOR_OPTS = %i[min max regex hidden regex_error json_schema schema].freeze
diff --git a/spec/lib/site_setting_extension_spec.rb b/spec/lib/site_setting_extension_spec.rb
index bbe496bab33af..8f130e7247cef 100644
--- a/spec/lib/site_setting_extension_spec.rb
+++ b/spec/lib/site_setting_extension_spec.rb
@@ -1300,6 +1300,24 @@ def self.translate_names?
end
end
+ describe "disallowed_groups for group list settings" do
+ it "strips disallowed groups when setting a value" do
+ SiteSetting.whispers_allowed_groups = "0|1|2"
+ expect(SiteSetting.whispers_allowed_groups).to eq("1|2")
+
+ SiteSetting.whispers_allowed_groups = "0"
+ expect(SiteSetting.whispers_allowed_groups).to eq("")
+
+ SiteSetting.whispers_allowed_groups = "1|0|2|0"
+ expect(SiteSetting.whispers_allowed_groups).to eq("1|2")
+ end
+
+ it "is included in all_settings output" do
+ setting = SiteSetting.all_settings.find { |s| s[:setting] == :whispers_allowed_groups }
+ expect(setting[:disallowed_groups]).to eq("0")
+ end
+ end
+
describe "requires_confirmation settings" do
it "returns 'simple' for settings that require confirmation with 'simple' type" do
expect(
From 0e138f0ac5f01fd6900add7094a311c11f459678 Mon Sep 17 00:00:00 2001
From: Renato Atilio
Date: Mon, 23 Feb 2026 10:31:34 -0300
Subject: [PATCH 002/168] FEATURE: add infinite loading to site texts (#37512)
Enhances the admin site text search page by adding support for infinite
"load more" results.
---
.../controllers/admin-site-text/index.js | 80 +++++++++++--------
.../admin/routes/admin-site-text/index.js | 15 +---
.../admin/templates/admin-site-text/index.gjs | 29 ++++---
spec/system/admin_site_texts_spec.rb | 12 +++
4 files changed, 79 insertions(+), 57 deletions(-)
diff --git a/frontend/discourse/admin/controllers/admin-site-text/index.js b/frontend/discourse/admin/controllers/admin-site-text/index.js
index ff470d843c91e..c225d80c00cc6 100644
--- a/frontend/discourse/admin/controllers/admin-site-text/index.js
+++ b/frontend/discourse/admin/controllers/admin-site-text/index.js
@@ -2,6 +2,7 @@ import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
+import { TrackedArray } from "@ember-compat/tracked-built-ins";
import ReseedModal from "discourse/admin/components/modal/reseed";
import discourseDebounce from "discourse/lib/debounce";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
@@ -26,6 +27,7 @@ export default class AdminSiteTextIndexController extends Controller {
@tracked searching = false;
@tracked preferred = false;
+ @tracked canLoadMore = true;
queryParams = [
"q",
@@ -36,6 +38,17 @@ export default class AdminSiteTextIndexController extends Controller {
"onlySelectedLocale",
];
+ #page = 0;
+ #results = new TrackedArray();
+
+ get siteTexts() {
+ return this.#results.flat();
+ }
+
+ get extras() {
+ return this.model?.extras ?? {};
+ }
+
get resolvedOverridden() {
return [true, "true"].includes(this.overridden) ?? false;
}
@@ -72,12 +85,28 @@ export default class AdminSiteTextIndexController extends Controller {
locale: this.resolvedLocale,
untranslated: this.resolvedUntranslated,
only_selected_locale: this.resolvedOnlySelectedLocale,
+ page: this.#page,
});
+
+ if (this.#page === 0) {
+ this.#results.length = 0;
+ }
+
+ this.#results.push(this.model.content);
+ this.canLoadMore = this.model.extras?.has_more ?? false;
} finally {
this.searching = false;
}
}
+ resetSearch() {
+ this.#page = 0;
+ this.#results.length = 0;
+ this.canLoadMore = true;
+ this.searching = true;
+ this._performSearch();
+ }
+
get availableLocales() {
return this.siteSettings.available_locales;
}
@@ -101,64 +130,51 @@ export default class AdminSiteTextIndexController extends Controller {
@action
toggleOverridden() {
- if (this.resolvedOverridden) {
- this.overridden = null;
- } else {
- this.overridden = true;
- }
- this.searching = true;
- discourseDebounce(this, this._performSearch, 400);
+ this.overridden = this.resolvedOverridden ? null : true;
+ this.resetSearch();
}
@action
toggleOutdated() {
- if (this.resolvedOutdated) {
- this.outdated = null;
- } else {
- this.outdated = true;
- }
- this.searching = true;
- discourseDebounce(this, this._performSearch, 400);
+ this.outdated = this.resolvedOutdated ? null : true;
+ this.resetSearch();
}
@action
toggleUntranslated() {
- if (this.resolvedUntranslated) {
- this.untranslated = null;
- } else {
- this.untranslated = true;
- }
- this.searching = true;
- discourseDebounce(this, this._performSearch, 400);
+ this.untranslated = this.resolvedUntranslated ? null : true;
+ this.resetSearch();
}
@action
toggleOnlySelectedLocale() {
- if (this.resolvedOnlySelectedLocale) {
- this.onlySelectedLocale = null;
- } else {
- this.onlySelectedLocale = true;
- }
- this.searching = true;
- discourseDebounce(this, this._performSearch, 400);
+ this.onlySelectedLocale = this.resolvedOnlySelectedLocale ? null : true;
+ this.resetSearch();
}
@action
search() {
const q = this.q;
if (q !== lastSearch) {
- this.searching = true;
- discourseDebounce(this, this._performSearch, 400);
lastSearch = q;
+ discourseDebounce(this, this.resetSearch, 400);
}
}
@action
updateLocale(value) {
- this.searching = true;
this.locale = value;
+ this.resetSearch();
+ }
- discourseDebounce(this, this._performSearch, 400);
+ @action
+ loadMore() {
+ if (this.searching || !this.canLoadMore) {
+ return;
+ }
+ this.#page += 1;
+ this.searching = true;
+ this._performSearch();
}
@action
diff --git a/frontend/discourse/admin/routes/admin-site-text/index.js b/frontend/discourse/admin/routes/admin-site-text/index.js
index 77c6b223399ba..7945c3d5d07ac 100644
--- a/frontend/discourse/admin/routes/admin-site-text/index.js
+++ b/frontend/discourse/admin/routes/admin-site-text/index.js
@@ -1,12 +1,8 @@
import Route from "@ember/routing/route";
-import { service } from "@ember/service";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
@disableImplicitInjections
export default class AdminSiteTextIndexRoute extends Route {
- @service siteSettings;
- @service store;
-
queryParams = {
q: { replace: true },
overridden: { replace: true },
@@ -15,14 +11,7 @@ export default class AdminSiteTextIndexRoute extends Route {
locale: { replace: true },
};
- model(params) {
- return this.store.find("site-text", {
- q: params.q,
- overridden: params.overridden ?? false,
- outdated: params.outdated ?? false,
- untranslated: params.untranslated ?? false,
- locale: params.locale ?? this.siteSettings.default_locale,
- only_selected_locale: params.onlySelectedLocale ?? false,
- });
+ setupController(controller) {
+ controller.resetSearch();
}
}
diff --git a/frontend/discourse/admin/templates/admin-site-text/index.gjs b/frontend/discourse/admin/templates/admin-site-text/index.gjs
index 4dcca172a6ded..82e1bf48a08e6 100644
--- a/frontend/discourse/admin/templates/admin-site-text/index.gjs
+++ b/frontend/discourse/admin/templates/admin-site-text/index.gjs
@@ -3,6 +3,7 @@ import { on } from "@ember/modifier";
import SiteTextSummary from "discourse/admin/components/site-text-summary";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
+import LoadMore from "discourse/components/load-more";
import TextField from "discourse/components/text-field";
import ComboBox from "discourse/select-kit/components/combo-box";
import { i18n } from "discourse-i18n";
@@ -84,24 +85,28 @@ export default
-
- {{#if @controller.model.extras.recommended}}
- {{i18n "admin.site_text.recommended"}}
- {{/if}}
+ {{#if @controller.extras.recommended}}
+ {{i18n "admin.site_text.recommended"}}
+ {{/if}}
- {{#each @controller.model.content as |siteText|}}
+
+ {{#each @controller.siteTexts as |siteText|}}
{{else}}
- {{i18n "admin.site_text.no_results"}}
+ {{#unless @controller.searching}}
+ {{i18n "admin.site_text.no_results"}}
+ {{/unless}}
{{/each}}
-
- {{#if @controller.model.extras.has_more}}
- {{i18n "admin.site_text.more_than_50_results"}}
- {{/if}}
-
+
+
diff --git a/spec/system/admin_site_texts_spec.rb b/spec/system/admin_site_texts_spec.rb
index d8332718f4fd3..514a9595f730d 100644
--- a/spec/system/admin_site_texts_spec.rb
+++ b/spec/system/admin_site_texts_spec.rb
@@ -128,4 +128,16 @@
expect(page.all(".modal label span").map(&:text)).to eq(["Uncategorized"])
end
+
+ it "can load more results" do
+ site_texts_page.visit
+ site_texts_page.search("e")
+
+ expect(page).to have_css(".site-text", minimum: 50)
+ initial_count = page.all(".site-text").count
+
+ page.execute_script("window.scrollTo(0, document.body.scrollHeight)")
+
+ expect(page).to have_css(".site-text", minimum: initial_count + 1)
+ end
end
From af3cca4462d5dd64463b057f3a7bff05317fbde4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?=
Date: Mon, 23 Feb 2026 16:05:49 +0100
Subject: [PATCH 003/168] FIX: Suppress post revision notifications during
silent bulk category moves (#37832)
When performing a bulk category move with the "Notify users of this
change" option unchecked, topic authors were still receiving "edited"
notifications. This happened because the silent flag was not propagated
to the PostRevision `after_create` callback.
The original implementation (7a099ebb) correctly suppressed the
`notify_category_change` job (PostAlerter path) but missed the second
notification path: `PostActionNotifier.after_create_post_revision`,
which fires via the PostRevision `after_create` callback when
`create_revision_on_bulk_topic_moves` is enabled.
This fix adds a transient `silent` attr_accessor to PostRevision, set by
PostRevisor before saving, and checked as an early return guard in
`PostActionNotifier.after_create_post_revision`.
The specs are also updated to properly enable PostActionNotifier (which
is disabled by default in tests) so the revision notification path is
actually exercised.
Ref - t/174237
---
app/models/post_revision.rb | 2 +
app/services/post_action_notifier.rb | 1 +
lib/post_revisor.rb | 4 +-
spec/lib/topics_bulk_action_spec.rb | 85 ++++++++--------------------
4 files changed, 29 insertions(+), 63 deletions(-)
diff --git a/app/models/post_revision.rb b/app/models/post_revision.rb
index 921267d3a0811..9c9c32363c3e9 100644
--- a/app/models/post_revision.rb
+++ b/app/models/post_revision.rb
@@ -4,6 +4,8 @@ class PostRevision < ActiveRecord::Base
belongs_to :post
belongs_to :user
+ attr_accessor :silent
+
serialize :modifications, type: Hash, coder: YAML
after_create :create_notification
diff --git a/app/services/post_action_notifier.rb b/app/services/post_action_notifier.rb
index c90fa520349f0..698d2362935fb 100644
--- a/app/services/post_action_notifier.rb
+++ b/app/services/post_action_notifier.rb
@@ -105,6 +105,7 @@ def self.after_create_post_revision(post_revision)
return if post_revision.user.blank?
return if post.topic.blank?
return if post.topic.private_message?
+ return if post_revision.silent
return if notification_is_disabled?(post_revision)
user_ids = []
diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb
index ae85f470b0551..1f4ef6e5b5fba 100644
--- a/lib/post_revisor.rb
+++ b/lib/post_revisor.rb
@@ -625,13 +625,15 @@ def create_revision
end
@post_revision =
- PostRevision.create!(
+ PostRevision.new(
user_id: @post.last_editor_id,
post_id: @post.id,
number: @post.version,
modifications:,
hidden: @hidden_revision,
)
+ @post_revision.silent = @silent
+ @post_revision.save!
end
def update_revision
diff --git a/spec/lib/topics_bulk_action_spec.rb b/spec/lib/topics_bulk_action_spec.rb
index 9787eef2dc8d9..43d91464372c3 100644
--- a/spec/lib/topics_bulk_action_spec.rb
+++ b/spec/lib/topics_bulk_action_spec.rb
@@ -160,84 +160,45 @@
fab!(:first_post) { Fabricate(:post, topic: topic) }
describe "silent option" do
- fab!(:topic_watcher, :user)
- fab!(:category_watcher, :user)
fab!(:admin)
before do
Jobs.run_immediately!
+ PostActionNotifier.enable
+ SiteSetting.create_revision_on_bulk_topic_moves = true
TopicUser.change(
- topic_watcher,
+ Fabricate(:user),
topic.id,
notification_level: TopicUser.notification_levels[:watching],
)
CategoryUser.set_notification_level_for_category(
- category_watcher,
+ Fabricate(:user),
CategoryUser.notification_levels[:watching_first_post],
category.id,
)
end
- shared_examples "silent option suppresses notifications" do
- it "notifies topic watchers when silent is false" do
- expect do
- TopicsBulkAction.new(
- admin,
- [topic.id],
- type: "change_category",
- category_id: category.id,
- ).perform!
- end.to change { Notification.where(user: topic_watcher).count }
- end
-
- it "does not notify topic watchers when silent is true" do
- expect do
- TopicsBulkAction.new(
- admin,
- [topic.id],
- type: "change_category",
- category_id: category.id,
- silent: true,
- ).perform!
- end.to not_change { Notification.where(user: topic_watcher).count }
- end
-
- it "notifies category watchers when silent is false" do
- expect do
- TopicsBulkAction.new(
- admin,
- [topic.id],
- type: "change_category",
- category_id: category.id,
- ).perform!
- end.to change { Notification.where(user: category_watcher).count }.by(1)
-
- expect(Notification.where(user: category_watcher).last.notification_type).to eq(
- Notification.types[:watching_first_post],
- )
- end
-
- it "does not notify category watchers when silent is true" do
- expect do
- TopicsBulkAction.new(
- admin,
- [topic.id],
- type: "change_category",
- category_id: category.id,
- silent: true,
- ).perform!
- end.to not_change { Notification.where(user: category_watcher).count }
- end
- end
-
- context "when create_revision_on_bulk_topic_moves is enabled" do
- before { SiteSetting.create_revision_on_bulk_topic_moves = true }
- include_examples "silent option suppresses notifications"
+ it "does not create any notifications when silent is true" do
+ expect do
+ TopicsBulkAction.new(
+ admin,
+ [topic.id],
+ type: "change_category",
+ category_id: category.id,
+ silent: true,
+ ).perform!
+ end.to not_change { Notification.count }
end
- context "when create_revision_on_bulk_topic_moves is disabled" do
- before { SiteSetting.create_revision_on_bulk_topic_moves = false }
- include_examples "silent option suppresses notifications"
+ it "creates notifications when silent is false" do
+ expect do
+ TopicsBulkAction.new(
+ admin,
+ [topic.id],
+ type: "change_category",
+ category_id: category.id,
+ ).perform!
+ end.to change { Notification.count }
end
end
From abb64e8b113f8dd9a9b6559a5a400a8e4e127703 Mon Sep 17 00:00:00 2001
From: Jake Goldsborough
Date: Thu, 29 Jan 2026 14:28:19 -0800
Subject: [PATCH 004/168] FIX: Add nil guards to webhook signature validation
Prevents 500 errors when webhooks are called with missing or malformed
parameters. Now returns 406 Not Acceptable cleanly instead of crashing.
- Sendgrid: guard against nil error_code on non-blocked bounces
- Mailgun legacy: guard against nil params["error"]
- Sendgrid signature: early return if headers missing
- Mailjet/Postmark/Sparkpost: guard against missing token param
- Mandrill: guard against missing signature header
---
app/controllers/webhooks_controller.rb | 10 ++-
spec/requests/webhooks_controller_spec.rb | 97 +++++++++++++++++++++++
2 files changed, 105 insertions(+), 2 deletions(-)
diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb
index acd118650e885..763c31d07aa22 100644
--- a/app/controllers/webhooks_controller.rb
+++ b/app/controllers/webhooks_controller.rb
@@ -38,7 +38,7 @@ def sendgrid
# so we set the error code to 5.1.2 which translates to permanent failure bad destination system address.
error_code = "5.1.2" if !error_code && event["type"] == "blocked"
- if error_code[Email::SMTP_STATUS_TRANSIENT_FAILURE]
+ if error_code&.[](Email::SMTP_STATUS_TRANSIENT_FAILURE)
process_bounce(message_id, to_address, SiteSetting.soft_bounce_score, error_code)
else
process_bounce(message_id, to_address, SiteSetting.hard_bounce_score, error_code)
@@ -241,7 +241,7 @@ def handle_mailgun_legacy(params)
# only handle soft bounces, because hard bounces are also handled
# by the "dropped" event and we don't want to increase bounce score twice
# for the same message
- if event == "bounced" && params["error"][Email::SMTP_STATUS_TRANSIENT_FAILURE]
+ if event == "bounced" && params["error"]&.[](Email::SMTP_STATUS_TRANSIENT_FAILURE)
process_bounce(message_id, to_address, SiteSetting.soft_bounce_score, error_code)
elsif event == "dropped"
process_bounce(message_id, to_address, SiteSetting.hard_bounce_score, error_code)
@@ -280,6 +280,8 @@ def handle_mailgun_new(params)
def valid_sendgrid_signature?
signature = request.headers["X-Twilio-Email-Event-Webhook-Signature"]
timestamp = request.headers["X-Twilio-Email-Event-Webhook-Timestamp"]
+ return false if signature.blank? || timestamp.blank?
+
request.body.rewind
payload = request.body.read
@@ -297,11 +299,13 @@ def valid_sendgrid_signature?
end
def valid_mailjet_token?
+ return false if params[:t].blank?
ActiveSupport::SecurityUtils.secure_compare(params[:t], SiteSetting.mailjet_webhook_token)
end
def valid_mandrill_signature?
signature = request.headers["X-Mandrill-Signature"]
+ return false if signature.blank?
payload = "#{Discourse.base_url}/webhooks/mandrill"
params
@@ -322,10 +326,12 @@ def valid_mandrill_signature?
end
def valid_postmark_token?
+ return false if params[:t].blank?
ActiveSupport::SecurityUtils.secure_compare(params[:t], SiteSetting.postmark_webhook_token)
end
def valid_sparkpost_token?
+ return false if params[:t].blank?
ActiveSupport::SecurityUtils.secure_compare(params[:t], SiteSetting.sparkpost_webhook_token)
end
diff --git a/spec/requests/webhooks_controller_spec.rb b/spec/requests/webhooks_controller_spec.rb
index a4076d367e410..bd4a52ec0eee0 100644
--- a/spec/requests/webhooks_controller_spec.rb
+++ b/spec/requests/webhooks_controller_spec.rb
@@ -197,6 +197,35 @@
expect(response.status).to eq(406)
end
+
+ it "returns error if signature header is missing" do
+ SiteSetting.sendgrid_verification_key =
+ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE83T4O/n84iotIvIW4mdBgQ/7dAfSmpqIM8kF9mN1flpVKS3GRqe62gw+2fNNRaINXvVpiglSI8eNEc6wEA3F+g=="
+
+ post "/webhooks/sendgrid.json",
+ headers: {
+ "X-Twilio-Email-Event-Webhook-Timestamp" => "1600112492",
+ },
+ params:
+ "[{\"email\":\"hello@world.com\",\"event\":\"dropped\",\"reason\":\"Bounced Address\",\"sg_event_id\":\"ZHJvcC0xMDk5NDkxOS1MUnpYbF9OSFN0T0doUTRrb2ZTbV9BLTA\",\"sg_message_id\":\"LRzXl_NHStOGhQ4kofSm_A.filterdrecv-p3mdw1-756b745b58-kmzbl-18-5F5FC76C-9.0\",\"smtp-id\":\"\",\"timestamp\":1600112492}]\r\n"
+
+ expect(response.status).to eq(406)
+ end
+
+ it "returns error if timestamp header is missing" do
+ SiteSetting.sendgrid_verification_key =
+ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE83T4O/n84iotIvIW4mdBgQ/7dAfSmpqIM8kF9mN1flpVKS3GRqe62gw+2fNNRaINXvVpiglSI8eNEc6wEA3F+g=="
+
+ post "/webhooks/sendgrid.json",
+ headers: {
+ "X-Twilio-Email-Event-Webhook-Signature" =>
+ "MEUCIGHQVtGj+Y3LkG9fLcxf3qfI10QysgDWmMOVmxG0u6ZUAiEAyBiXDWzM+uOe5W0JuG+luQAbPIqHh89M15TluLtEZtM=",
+ },
+ params:
+ "[{\"email\":\"hello@world.com\",\"event\":\"dropped\",\"reason\":\"Bounced Address\",\"sg_event_id\":\"ZHJvcC0xMDk5NDkxOS1MUnpYbF9OSFN0T0doUTRrb2ZTbV9BLTA\",\"sg_message_id\":\"LRzXl_NHStOGhQ4kofSm_A.filterdrecv-p3mdw1-756b745b58-kmzbl-18-5F5FC76C-9.0\",\"smtp-id\":\"\",\"timestamp\":1600112492}]\r\n"
+
+ expect(response.status).to eq(406)
+ end
end
describe "#mailjet" do
@@ -253,6 +282,23 @@
expect(response.status).to eq(406)
expect(email_log.reload.bounced).to eq(false)
end
+
+ it "returns error if token param is missing" do
+ SiteSetting.mailjet_webhook_token = "foo"
+ user = Fabricate(:user, email: email)
+ email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email)
+
+ post "/webhooks/mailjet.json",
+ params: {
+ "event" => "bounce",
+ "email" => email,
+ "hard_bounce" => true,
+ "CustomID" => message_id,
+ }
+
+ expect(response.status).to eq(406)
+ expect(email_log.reload.bounced).to eq(false)
+ end
end
describe "#mailpace" do
@@ -367,6 +413,14 @@
expect(response.status).to eq(406)
end
+
+ it "returns error if signature header is missing" do
+ SiteSetting.mandrill_authentication_key = "wr_JeJNO9OI65RFDrvk3Zw"
+
+ post "/webhooks/mandrill.json", params: payload
+
+ expect(response.status).to eq(406)
+ end
end
describe "#mandrill_head" do
@@ -445,6 +499,22 @@
expect(response.status).to eq(406)
expect(email_log.reload.bounced).to eq(false)
end
+
+ it "returns error if token param is missing" do
+ SiteSetting.postmark_webhook_token = "foo"
+ user = Fabricate(:user, email: email)
+ email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email)
+
+ post "/webhooks/postmark.json",
+ params: {
+ "Type" => "HardBounce",
+ "MessageID" => message_id,
+ "Email" => email,
+ }
+
+ expect(response.status).to eq(406)
+ expect(email_log.reload.bounced).to eq(false)
+ end
end
describe "#sparkpost" do
@@ -530,6 +600,33 @@
expect(response.status).to eq(406)
expect(email_log.reload.bounced).to eq(false)
end
+
+ it "returns error if token param is missing" do
+ SiteSetting.sparkpost_webhook_token = "foo"
+ user = Fabricate(:user, email: email)
+ email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email)
+
+ post "/webhooks/sparkpost.json",
+ params: {
+ "_json" => [
+ {
+ "msys" => {
+ "message_event" => {
+ "bounce_class" => 10,
+ "error_code" => "554",
+ "rcpt_to" => email,
+ "rcpt_meta" => {
+ "message_id" => message_id,
+ },
+ },
+ },
+ },
+ ],
+ }
+
+ expect(response.status).to eq(406)
+ expect(email_log.reload.bounced).to eq(false)
+ end
end
describe "#aws" do
From b92b803cbc83d86e4e7f04d12b359b4bef2a5cc3 Mon Sep 17 00:00:00 2001
From: elRicharde <52285099+elRicharde@users.noreply.github.com>
Date: Mon, 23 Feb 2026 16:28:23 +0100
Subject: [PATCH 005/168] FIX: Poll option mention overlap (#37972)
Created first in own fork here:
https://github.com/elRicharde/discourse/pull/1
Poll option list items had no layout container, allowing @mention pills
to overlap the option marker or checkbox.
This change makes poll options flex rows so marker and content are
always laid out side by side.
CSS only, no behavior or markup changes.
Repro:
Create a poll with options that include @mentions (mention pills).
In the cooked topic view, the option marker/checkbox can overlap the
mention pill.
Fix:
Make poll option list items a flex row so the marker/checkbox and the
option content
are laid out side by side with consistent spacing.
Screenshots show the overlap before the change.
Screenshot 2026-02-08 093307 Screenshot 2026-02-08 093403 image
---------
Co-authored-by: Derek Rushforth
---
.../poll/assets/stylesheets/common/poll.scss | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/plugins/poll/assets/stylesheets/common/poll.scss b/plugins/poll/assets/stylesheets/common/poll.scss
index 102bf487a7bf2..5e175bc811c9c 100644
--- a/plugins/poll/assets/stylesheets/common/poll.scss
+++ b/plugins/poll/assets/stylesheets/common/poll.scss
@@ -34,11 +34,22 @@ div.poll-outer {
button {
background-color: transparent;
border: none;
- text-align: left;
- padding-left: 23px;
- text-indent: -23px;
width: 100%;
cursor: pointer;
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5em;
+ text-align: left;
+ }
+
+ button svg {
+ flex: 0 0 auto;
+ margin-top: 0.15em;
+ }
+
+ .option-text {
+ flex: 1 1 auto;
+ min-width: 0;
}
}
From d01d060a8b14684454329a2d64837eb32f893d3b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?=
Date: Mon, 23 Feb 2026 16:30:10 +0100
Subject: [PATCH 006/168] FIX: unify watched word boundary regex across Ruby
and JS engines (#37965)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The CJK fix (d7a53ada16) introduced separate boundary patterns for Ruby
and JS engines in `match_word_regexp`. The Ruby engine used `[:word:]`
(which includes digits), while the JS engine used `\P{L}` (non-Letter).
Since digits are not letters, the JS pattern treated them as valid word
boundaries — causing "123Test" to match as "3Test" and standalone number
watched words like "123" to match inside "abc123".
Replace both engine-specific patterns with a single unified pattern
using Unicode property classes (`\p{L}`, `\p{M}`, `\p{N}`, `\p{Pc}`)
that work identically in Ruby and JavaScript. This treats letters,
marks, numbers, and connector punctuation as word characters in boundary
checks, which fixes the number-matching bug for JS consumers while
preserving the existing correct behavior on the Ruby side.
Since `match_word_regexp` no longer branches on engine, remove the
now-dead `engine:` parameter from all 5 method signatures that threaded
it through (`match_word_regexp`, `word_to_regexp`, `regexps_for_action`,
`compiled_regexps_for_action`, `serialized_regexps_for_action`) and all
call sites passing `engine: :js` (serializers, pretty_text).
https://meta.discourse.org/t/396110
https://meta.discourse.org/t/396109
Follow-up to d7a53ada16 (#37844)
---
app/serializers/site_serializer.rb | 6 +-
.../watched_word_list_serializer.rb | 2 +-
app/serializers/watched_word_serializer.rb | 2 +-
app/services/word_watcher.rb | 43 +--
lib/pretty_text.rb | 6 +-
.../admin/watched_words_controller_spec.rb | 6 +-
spec/services/word_watcher_spec.rb | 308 ++++++------------
7 files changed, 122 insertions(+), 251 deletions(-)
diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb
index e628a72759f50..59edfa3149aaa 100644
--- a/app/serializers/site_serializer.rb
+++ b/app/serializers/site_serializer.rb
@@ -269,7 +269,7 @@ def topic_featured_link_allowed_category_ids
end
def censored_regexp
- WordWatcher.serialized_regexps_for_action(:censor, engine: :js)
+ WordWatcher.serialized_regexps_for_action(:censor)
end
def custom_emoji_translation
@@ -285,11 +285,11 @@ def include_shared_drafts_category_id?
end
def watched_words_replace
- WordWatcher.regexps_for_action(:replace, engine: :js)
+ WordWatcher.regexps_for_action(:replace)
end
def watched_words_link
- WordWatcher.regexps_for_action(:link, engine: :js)
+ WordWatcher.regexps_for_action(:link)
end
def categories
diff --git a/app/serializers/watched_word_list_serializer.rb b/app/serializers/watched_word_list_serializer.rb
index 42f89f326a6fe..68f3243b4192a 100644
--- a/app/serializers/watched_word_list_serializer.rb
+++ b/app/serializers/watched_word_list_serializer.rb
@@ -18,7 +18,7 @@ def words
def compiled_regular_expressions
expressions = {}
actions.each do |action|
- expressions[action] = WordWatcher.serialized_regexps_for_action(action, engine: :js)
+ expressions[action] = WordWatcher.serialized_regexps_for_action(action)
end
expressions
end
diff --git a/app/serializers/watched_word_serializer.rb b/app/serializers/watched_word_serializer.rb
index aff7cb7a7f719..a880a5cad2c47 100644
--- a/app/serializers/watched_word_serializer.rb
+++ b/app/serializers/watched_word_serializer.rb
@@ -11,7 +11,7 @@ class WatchedWordSerializer < ApplicationSerializer
:html
def regexp
- WordWatcher.word_to_regexp(word, engine: :js)
+ WordWatcher.word_to_regexp(word)
end
def action
diff --git a/app/services/word_watcher.rb b/app/services/word_watcher.rb
index c92be31723659..46282e0e8b9b8 100644
--- a/app/services/word_watcher.rb
+++ b/app/services/word_watcher.rb
@@ -53,15 +53,13 @@ def self.cached_words_for_action(action)
end
end
- def self.regexps_for_action(action, engine: :ruby)
- cached_words_for_action(action)&.to_h do |_, attrs|
- [word_to_regexp(attrs[:word], engine: engine), attrs]
- end
+ def self.regexps_for_action(action)
+ cached_words_for_action(action)&.to_h { |_, attrs| [word_to_regexp(attrs[:word]), attrs] }
end
# This regexp is run in miniracer, and the client JS app
# Make sure it is compatible with major browsers when changing
- def self.compiled_regexps_for_action(action, engine: :ruby, raise_errors: false)
+ def self.compiled_regexps_for_action(action, raise_errors: false)
words = cached_words_for_action(action)
return [] if words.blank?
@@ -92,11 +90,7 @@ def self.compiled_regexps_for_action(action, engine: :ruby, raise_errors: false)
next if regexp.blank?
# Add word boundaries to the regexp for regular watched words
- regexp =
- match_word_regexp(
- regexp,
- engine: engine,
- ) if !SiteSetting.watched_words_regular_expressions?
+ regexp = match_word_regexp(regexp) if !SiteSetting.watched_words_regular_expressions?
# Add case insensitive flag if needed
begin
@@ -112,13 +106,11 @@ def self.compiled_regexps_for_action(action, engine: :ruby, raise_errors: false)
.compact
end
- def self.serialized_regexps_for_action(action, engine: :ruby)
- compiled_regexps_for_action(action, engine: engine).map do |r|
- { r.source => { case_sensitive: !r.casefold? } }
- end
+ def self.serialized_regexps_for_action(action)
+ compiled_regexps_for_action(action).map { |r| { r.source => { case_sensitive: !r.casefold? } } }
end
- def self.word_to_regexp(word, engine: :ruby, match_word: true)
+ def self.word_to_regexp(word, match_word: true)
if SiteSetting.watched_words_regular_expressions?
regexp = word
regexp = "(#{regexp})" if match_word
@@ -132,7 +124,7 @@ def self.word_to_regexp(word, engine: :ruby, match_word: true)
# Convert wildcards to regexp
regexp = regexp.gsub("\\*", '\S*')
- regexp = match_word_regexp(regexp, engine: engine) if match_word
+ regexp = match_word_regexp(regexp) if match_word
regexp
end
end
@@ -294,19 +286,14 @@ def self.censor_text_with_regexp(text, regexp)
"Tibetan" => "\\u0F00-\\u0FFF",
}.values.join
- def self.match_word_regexp(regexp, engine: :ruby)
+ WORD_CHARS = "[\\p{L}\\p{M}\\p{N}\\p{Pc}]"
+
+ def self.match_word_regexp(regexp)
s = SPACELESS_SCRIPTS
- if engine == :js
- leading = "(?:[\\P{L}#{s}]|^|(?=[#{s}]))"
- trailing = "(?:(?=[\\P{L}#{s}]|$)|(?<=[#{s}]))"
- "#{leading}(#{regexp})#{trailing}"
- elsif engine == :ruby
- leading = "(?:(? word1.id,
"word" => word1.word,
- "regexp" => WordWatcher.word_to_regexp(word1.word, engine: :js),
+ "regexp" => WordWatcher.word_to_regexp(word1.word),
"case_sensitive" => false,
"action" => "block",
),
hash_including(
"id" => word4.id,
"word" => word4.word,
- "regexp" => WordWatcher.word_to_regexp(word4.word, engine: :js),
+ "regexp" => WordWatcher.word_to_regexp(word4.word),
"case_sensitive" => false,
"action" => "censor",
),
)
expect(watched_words["compiled_regular_expressions"]["block"].first).to eq(
- WordWatcher.serialized_regexps_for_action(:block, engine: :js).first.deep_stringify_keys,
+ WordWatcher.serialized_regexps_for_action(:block).first.deep_stringify_keys,
)
end
end
diff --git a/spec/services/word_watcher_spec.rb b/spec/services/word_watcher_spec.rb
index 7ee982e8be24f..a2cf184366b6f 100644
--- a/spec/services/word_watcher_spec.rb
+++ b/spec/services/word_watcher_spec.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
RSpec.describe WordWatcher do
- let(:raw) { <<~RAW.strip }
- Do you like liquorice?
-
+ def matches(text, action = :require_approval)
+ described_class.new(text).word_matches_for_action?(action)
+ end
- I really like them. One could even say that I am *addicted* to liquorice. And if
- you can mix it up with some anise, then I'm in heaven ;)
- RAW
+ def matches_all(text, action = :block)
+ described_class.new(text).word_matches_for_action?(action, all_matches: true)
+ end
after { Discourse.redis.flushdb }
@@ -63,39 +63,42 @@
end
context "when watched_words_regular_expressions = true" do
- it "returns the proper regexp" do
- SiteSetting.watched_words_regular_expressions = true
+ before { SiteSetting.watched_words_regular_expressions = true }
+
+ it "matches words and respects case sensitivity" do
regexps = described_class.compiled_regexps_for_action(:block)
- expect(regexps).to be_an(Array)
- expect(regexps.map(&:inspect)).to contain_exactly(
- "/(#{word1})|(#{word2})/i",
- "/(#{word3})|(#{word4})/",
- )
+ case_insensitive = regexps.find(&:casefold?)
+ case_sensitive = regexps.find { |r| !r.casefold? }
+
+ expect(case_insensitive).to match(word1)
+ expect(case_insensitive).to match(word2)
+ expect(case_insensitive).to match(word1.upcase)
+ expect(case_sensitive).to match(word3)
+ expect(case_sensitive).to match(word4)
+ expect(case_sensitive).not_to match(word3.swapcase)
end
end
context "when watched_words_regular_expressions = false" do
- it "returns the proper regexp" do
+ it "groups words by case sensitivity and wraps them with word boundaries" do
SiteSetting.watched_words_regular_expressions = false
regexps = described_class.compiled_regexps_for_action(:block)
- s = WordWatcher::SPACELESS_SCRIPTS
- leading = "(?:(?
Date: Thu, 12 Feb 2026 10:16:47 -0800
Subject: [PATCH 007/168] FIX: Add extension/original_filename to UploadData
struct
The UploadData struct introduced in 2bfeb870137 for S3 backup deduplication
was missing extension and original_filename attributes. These are required
by FileStore::BaseStore#get_path_for_upload when the URL does not match
the standard UPLOAD_PATH_REGEX pattern (original/\d+X/).
For uploads with non-standard URLs (migrated uploads, custom storage paths,
or corrupted URLs), the fallback path in get_path_for_upload would cause
NoMethodError when accessing upload.extension or upload.original_filename.
Now plucks these additional fields from the database and includes them in
the struct to ensure backups work regardless of URL format.
Also changes from Struct to Data.define for immutability, as suggested by
@ZogStriP - these value objects should not be mutated after creation.
---
lib/backup_restore/creator.rb | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/lib/backup_restore/creator.rb b/lib/backup_restore/creator.rb
index 9bcb02ba2c62e..ff3fcba943a54 100644
--- a/lib/backup_restore/creator.rb
+++ b/lib/backup_restore/creator.rb
@@ -300,8 +300,10 @@ def add_local_uploads_to_archive(tar_filename)
end
end
- # Lightweight struct to avoid holding full ActiveRecord objects in memory
- UploadData = Struct.new(:id, :url, :sha1, keyword_init: true)
+ # Lightweight immutable value object to avoid holding full ActiveRecord objects in memory
+ # Includes extension and original_filename for FileStore::BaseStore#get_path_for_upload
+ # fallback path when URL doesn't match UPLOAD_PATH_REGEX
+ UploadData = Data.define(:id, :url, :sha1, :extension, :original_filename)
def add_remote_uploads_to_archive(tar_filename)
if !SiteSetting.include_s3_uploads_in_backups
@@ -350,12 +352,20 @@ def group_remote_uploads_by_sha1
:id,
:url,
:sha1,
+ :extension,
+ :original_filename,
)
uploads_by_sha1 = {}
- upload_tuples.each do |sha1_key, id, url, sha1|
+ upload_tuples.each do |sha1_key, id, url, sha1, extension, original_filename|
uploads_by_sha1[sha1_key] ||= []
- uploads_by_sha1[sha1_key] << UploadData.new(id: id, url: url, sha1: sha1)
+ uploads_by_sha1[sha1_key] << UploadData.new(
+ id:,
+ url:,
+ sha1:,
+ extension:,
+ original_filename:,
+ )
end
uploads_by_sha1
end
From bba215f0c7d3a2d7944fdc73247bf29b2d259c0b Mon Sep 17 00:00:00 2001
From: David Taylor
Date: Mon, 23 Feb 2026 16:10:26 +0000
Subject: [PATCH 008/168] FIX: Correctly handle deletion of anonymous
master_user (#37983)
The silenced_till method would return an `undefined method '[]' for nil`
error, which would cause other parts of the application to fail (e.g.
CSV exports)
---
app/models/user.rb | 2 +-
spec/models/user_spec.rb | 12 ++++++++++++
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/app/models/user.rb b/app/models/user.rb
index 54ff9f5154088..a49128ca482a8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2197,7 +2197,7 @@ def self.first_login_admin_id
private
def main_user_record
- anonymous? ? master_user : self
+ (anonymous? && master_user) ? master_user : self
end
def set_default_sidebar_section_links(update: false)
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 53719a58c3aab..9101de6579b43 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -2544,6 +2544,18 @@ def hash(password, salt, algorithm = UserPassword::TARGET_PASSWORD_ALGORITHM)
it "delegates the value from the main user record" do
expect(anon.silenced_till).to be_within(1.second).of(main.silenced_till)
end
+
+ it "returns nil when the master user has been destroyed" do
+ main.destroy!
+ anon.reload
+ expect(anon.silenced_till).to be_nil
+ end
+
+ it "returns nil when the master user has been deleted without hooks" do
+ User.where(id: main.id).delete_all
+ anon.reload
+ expect(anon.silenced_till).to be_nil
+ end
end
end
From 637be82728781775a5236025ed78f5497e29b40f Mon Sep 17 00:00:00 2001
From: Jarek Radosz
Date: Mon, 23 Feb 2026 17:14:44 +0100
Subject: [PATCH 009/168] DEV: Inline emojiUrlFor in user notes (#37988)
Extracted from #35477
---
.../after-reviewable-post-user/show-user-notes-on-flags.gjs | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/plugins/discourse-user-notes/assets/javascripts/discourse/connectors/after-reviewable-post-user/show-user-notes-on-flags.gjs b/plugins/discourse-user-notes/assets/javascripts/discourse/connectors/after-reviewable-post-user/show-user-notes-on-flags.gjs
index d3bf4f297e2d6..d537be2d4374a 100644
--- a/plugins/discourse-user-notes/assets/javascripts/discourse/connectors/after-reviewable-post-user/show-user-notes-on-flags.gjs
+++ b/plugins/discourse-user-notes/assets/javascripts/discourse/connectors/after-reviewable-post-user/show-user-notes-on-flags.gjs
@@ -8,8 +8,6 @@ import { emojiUrlFor } from "discourse/lib/text";
import { i18n } from "discourse-i18n";
import { showUserNotes } from "../../lib/user-notes";
-const EMOJI_URL = emojiUrlFor("memo");
-
export default class ShowUserNotesOnFlags extends Component {
static shouldRender(args, { siteSettings }) {
return siteSettings.user_notes_enabled && args.user;
@@ -40,7 +38,7 @@ export default class ShowUserNotesOnFlags extends Component {
>
{{#if this.siteSettings.enable_emoji}}
Date: Mon, 23 Feb 2026 17:15:09 +0100
Subject: [PATCH 010/168] DEV: Update discourse-ai's requiredPlugins (#37987)
---
plugins/discourse-ai/about.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/plugins/discourse-ai/about.json b/plugins/discourse-ai/about.json
index 0d1911d8eecdb..3b4df718223e6 100644
--- a/plugins/discourse-ai/about.json
+++ b/plugins/discourse-ai/about.json
@@ -2,7 +2,8 @@
"tests": {
"requiredPlugins": [
"https://github.com/discourse/discourse-prometheus",
- "https://github.com/discourse/discourse-solved.git"
+ "discourse-solved",
+ "chat"
]
}
}
From d9e71d308c42f7f86979dd004a96271dfe8cd1e1 Mon Sep 17 00:00:00 2001
From: Bannon Tanner
Date: Mon, 23 Feb 2026 10:17:49 -0600
Subject: [PATCH 011/168] FEAT: combine upcoming events display for multi-day
events (#37612)
Improves the upcoming events list to properly display multi-day events
as one entry.
## Changes
### Multi-Day Event Display
- Ongoing multi-day events now appear at today's date instead of being
hidden
- Multi-day events that haven't started yet appear at their start date
- Extended the `after` parameter to look back 30 days to catch ongoing
events
### Date Formatting
- Improved date range formatting for multi-day events:
- Same month: `December 15-20, 2026`
- Different months, same year: `November 28 - December 3, 2026`
- Different years: `December 28, 2025 - January 3, 2026`
## Example
Before:
After:
---
.../components/upcoming-events-list.gjs | 68 +++--
.../common/upcoming-events-list.scss | 2 +
.../components/upcoming-events-list-test.gjs | 235 +++++++++++++++++-
3 files changed, 279 insertions(+), 26 deletions(-)
diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/upcoming-events-list.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/upcoming-events-list.gjs
index 96c1f2ed699c5..c14d9bc82531b 100644
--- a/plugins/discourse-calendar/assets/javascripts/discourse/components/upcoming-events-list.gjs
+++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/upcoming-events-list.gjs
@@ -104,7 +104,8 @@ export default class UpcomingEventsList extends Component {
const data = {
limit: this.count,
before: moment().add(this.upcomingDays, "days").toISOString(),
- after: moment().subtract(2, "hours").toISOString(),
+ // this enables showing ongoing multi-day events that started in the past but haven't ended yet
+ after: moment().subtract(30, "days").toISOString(),
};
if (this.includeSubcategories) {
@@ -129,9 +130,16 @@ export default class UpcomingEventsList extends Component {
}
@action
- formatTime({ starts_at, ends_at }) {
- return isNotFullDayEvent(moment(starts_at), moment(ends_at))
- ? moment(starts_at).format(this.timeFormat)
+ formatTime(event) {
+ if (this.isMultiDayEvent(event)) {
+ return this.formatDateRange(event);
+ }
+
+ const startsAt = moment(event.starts_at);
+ const endsAt = event.ends_at ? moment(event.ends_at) : null;
+
+ return isNotFullDayEvent(startsAt, endsAt)
+ ? startsAt.format(this.timeFormat)
: this.allDayLabel;
}
@@ -145,23 +153,55 @@ export default class UpcomingEventsList extends Component {
return moment(`${month}-${day}`).format("D");
}
+ isMultiDayEvent(event) {
+ if (!event.ends_at) {
+ return false;
+ }
+ const startDate = moment(event.starts_at);
+ const endDate = moment(event.ends_at);
+ return !startDate.isSame(endDate, "day");
+ }
+
+ formatDateRange(event) {
+ const start = new Date(event.starts_at);
+ const end = new Date(event.ends_at);
+ return new Intl.DateTimeFormat(moment.locale(), {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ }).formatRange(start, end);
+ // en-US: "June 5–10, 2025" or "June 5 – July 10, 2025"
+ // fr-FR: "5–10 juin 2025" or "5 juin – 10 juillet 2025"
+ // de-DE: "5.–10. Juni 2025"
+ }
+
groupByMonthAndDay(data) {
+ const today = moment();
let events = data.reduce((result, item) => {
const startDate = moment(item.starts_at);
const endDate = item.ends_at ? moment(item.ends_at) : null;
- const today = moment();
- if (!endDate) {
- addToResult(startDate, item, result);
- return result;
- }
-
- while (startDate.isSameOrBefore(endDate, "day")) {
- if (startDate.isAfter(today)) {
- addToResult(startDate, item, result);
+ let displayDate;
+ if (!this.isMultiDayEvent(item)) {
+ // Single-day event
+ displayDate = startDate.clone();
+ } else {
+ // Multi-day event
+ if (startDate.isAfter(today, "day")) {
+ // Future event - show at start date
+ displayDate = startDate.clone();
+ } else if (today.isSameOrBefore(endDate, "day")) {
+ // Ongoing event - show at today's date
+ displayDate = today.clone();
+ } else {
+ // Past event - skip it
+ return result;
}
+ }
- startDate.add(1, "day");
+ // Only add if display date is in the future or today
+ if (displayDate.isSameOrAfter(today, "day")) {
+ addToResult(displayDate, item, result);
}
return result;
diff --git a/plugins/discourse-calendar/assets/stylesheets/common/upcoming-events-list.scss b/plugins/discourse-calendar/assets/stylesheets/common/upcoming-events-list.scss
index ec8e62fa43b47..6d5226fcda0b9 100644
--- a/plugins/discourse-calendar/assets/stylesheets/common/upcoming-events-list.scss
+++ b/plugins/discourse-calendar/assets/stylesheets/common/upcoming-events-list.scss
@@ -60,6 +60,8 @@
font-size: var(--font-down-2);
font-weight: 400;
color: var(--primary-700);
+ overflow-wrap: break-word;
+ line-height: var(--line-height-small);
}
&__footer {
diff --git a/plugins/discourse-calendar/test/javascripts/integration/components/upcoming-events-list-test.gjs b/plugins/discourse-calendar/test/javascripts/integration/components/upcoming-events-list-test.gjs
index ccb436dd9ef30..7d3867cd4509a 100644
--- a/plugins/discourse-calendar/test/javascripts/integration/components/upcoming-events-list-test.gjs
+++ b/plugins/discourse-calendar/test/javascripts/integration/components/upcoming-events-list-test.gjs
@@ -14,6 +14,22 @@ const tomorrowAllDay = "2100-02-02T00:00:00";
const laterThisMonth = "2100-02-22T08:00:00";
const nextWeek = "2100-02-09T08:00:00";
+// Cross-month: Nov 28 - Dec 3
+const crossMonthStart = "2100-11-28T00:00:00";
+const crossMonthEnd = "2100-12-03T00:00:00";
+
+// Cross-year: Dec 28 - Jan 3
+const crossYearStart = "2100-12-28T00:00:00";
+const crossYearEnd = "2101-01-03T00:00:00";
+
+// Ongoing event: started yesterday, ends next week
+const ongoingStart = "2100-01-25T00:00:00";
+const ongoingEnd = "2100-02-09T00:00:00";
+
+// Past event: already ended
+const pastStart = "2100-01-01T00:00:00";
+const pastEnd = "2100-01-15T00:00:00";
+
module("Integration | Component | upcoming-events-list", function (hooks) {
setupRenderingTest(hooks, { stubRouter: true });
@@ -114,7 +130,7 @@ module("Integration | Component | upcoming-events-list", function (hooks) {
.exists("displays the view-all link");
});
- test("with multi-day events, standard formats", async function (assert) {
+ test("with multi-day events in same month", async function (assert) {
pretender.get("/discourse-post-event/events", multiDayEventResponseHandler);
await render();
@@ -123,23 +139,218 @@ module("Integration | Component | upcoming-events-list", function (hooks) {
await waitFor(".loading-container .spinner", { count: 0 });
+ assert
+ .dom(".upcoming-events-list__event")
+ .exists({ count: 1 }, "multi-day event appears only once");
+
assert.deepEqual(
[...queryAll(".upcoming-events-list__event-name")].map(
(el) => el.innerText
),
- [
- "Awesome Multiday Event",
- "Awesome Multiday Event",
- "Awesome Multiday Event",
- "Awesome Multiday Event",
- "Awesome Multiday Event",
- "Awesome Multiday Event",
- "Awesome Multiday Event",
- "Awesome Multiday Event",
- ],
+ ["Awesome Multiday Event"],
+ "displays the multiday event name once"
+ );
+
+ const eventTime = document.querySelector(
+ ".upcoming-events-list__event-time"
+ ).innerText;
+
+ // Feb 2 - Feb 9, 2100
+ assert.true(eventTime.includes("February"), "contains month name");
+ assert.true(eventTime.includes("2"), "contains start day");
+ assert.true(eventTime.includes("9"), "contains end day");
+ assert.true(eventTime.includes("2100"), "contains year");
+ assert.false(eventTime.includes(":"), "shows date range, not time");
+ });
+
+ test("with cross-month multi-day events", async function (assert) {
+ pretender.get("/discourse-post-event/events", () => {
+ return response({
+ events: [
+ {
+ id: 67504,
+ starts_at: crossMonthStart,
+ ends_at: crossMonthEnd,
+ timezone: "UTC",
+ post: {
+ id: 67504,
+ post_number: 1,
+ url: "/t/cross-month-event/18452/1",
+ topic: { id: 18452, title: "Cross month event" },
+ },
+ name: "Cross Month Event",
+ category_id: 1,
+ },
+ ],
+ });
+ });
+
+ await render();
+
+ this.appEvents.trigger("page:changed", { url: "/" });
+
+ await waitFor(".loading-container .spinner", { count: 0 });
+
+ const eventTime = document.querySelector(
+ ".upcoming-events-list__event-time"
+ ).innerText;
+
+ // Nov 28 - Dec 3, 2100
+ assert.true(eventTime.includes("November"), "contains start month");
+ assert.true(eventTime.includes("28"), "contains start day");
+ assert.true(eventTime.includes("December"), "contains end month");
+ assert.true(eventTime.includes("3"), "contains end day");
+ assert.true(eventTime.includes("2100"), "contains year");
+ });
+
+ test("with cross-year multi-day events", async function (assert) {
+ pretender.get("/discourse-post-event/events", () => {
+ return response({
+ events: [
+ {
+ id: 67505,
+ starts_at: crossYearStart,
+ ends_at: crossYearEnd,
+ timezone: "UTC",
+ post: {
+ id: 67505,
+ post_number: 1,
+ url: "/t/cross-year-event/18453/1",
+ topic: { id: 18453, title: "Cross year event" },
+ },
+ name: "Cross Year Event",
+ category_id: 1,
+ },
+ ],
+ });
+ });
+
+ await render();
+
+ this.appEvents.trigger("page:changed", { url: "/" });
+
+ await waitFor(".loading-container .spinner", { count: 0 });
+
+ const eventTime = document.querySelector(
+ ".upcoming-events-list__event-time"
+ ).innerText;
+
+ // Dec 28, 2100 - Jan 3, 2101
+ assert.true(eventTime.includes("December"), "contains start month");
+ assert.true(eventTime.includes("28"), "contains start day");
+ assert.true(eventTime.includes("2100"), "contains start year");
+ assert.true(eventTime.includes("January"), "contains end month");
+ assert.true(eventTime.includes("3"), "contains end day");
+ assert.true(eventTime.includes("2101"), "contains end year");
+ });
+
+ test("with ongoing multi-day events (started in past, ends in future)", async function (assert) {
+ pretender.get("/discourse-post-event/events", () => {
+ return response({
+ events: [
+ {
+ id: 67506,
+ starts_at: ongoingStart,
+ ends_at: ongoingEnd,
+ timezone: "UTC",
+ post: {
+ id: 67506,
+ post_number: 1,
+ url: "/t/ongoing-event/18454/1",
+ topic: { id: 18454, title: "Ongoing event" },
+ },
+ name: "Ongoing Event",
+ category_id: 1,
+ },
+ ],
+ });
+ });
+
+ await render();
+
+ this.appEvents.trigger("page:changed", { url: "/" });
+
+ await waitFor(".loading-container .spinner", { count: 0 });
- "displays the multiday event on all scheduled dates"
+ assert
+ .dom(".upcoming-events-list__event")
+ .exists({ count: 1 }, "ongoing event is displayed");
+
+ assert
+ .dom(".upcoming-events-list__event-name")
+ .hasText("Ongoing Event", "displays the ongoing event");
+
+ // Ongoing events should show at today's date
+ const displayedDay = document.querySelector(
+ ".upcoming-events-list__event-date .day"
+ ).innerText;
+ assert.strictEqual(
+ displayedDay,
+ moment(today).format("D"),
+ "ongoing event is shown at today's date"
);
+
+ const eventTime = document.querySelector(
+ ".upcoming-events-list__event-time"
+ ).innerText;
+
+ // Jan 25 - Feb 9, 2100
+ assert.true(eventTime.includes("January"), "contains start month");
+ assert.true(eventTime.includes("25"), "contains start day");
+ assert.true(eventTime.includes("February"), "contains end month");
+ assert.true(eventTime.includes("9"), "contains end day");
+ assert.true(eventTime.includes("2100"), "contains year");
+ });
+
+ test("filters out events that have already ended", async function (assert) {
+ pretender.get("/discourse-post-event/events", () => {
+ return response({
+ events: [
+ {
+ id: 67507,
+ starts_at: pastStart,
+ ends_at: pastEnd,
+ timezone: "UTC",
+ post: {
+ id: 67507,
+ post_number: 1,
+ url: "/t/past-event/18455/1",
+ topic: { id: 18455, title: "Past event" },
+ },
+ name: "Past Event",
+ category_id: 1,
+ },
+ {
+ id: 67508,
+ starts_at: tomorrowAllDay,
+ ends_at: null,
+ timezone: "UTC",
+ post: {
+ id: 67508,
+ post_number: 1,
+ url: "/t/future-event/18456/1",
+ topic: { id: 18456, title: "Future event" },
+ },
+ name: "Future Event",
+ category_id: 1,
+ },
+ ],
+ });
+ });
+
+ await render();
+
+ this.appEvents.trigger("page:changed", { url: "/" });
+
+ await waitFor(".loading-container .spinner", { count: 0 });
+
+ assert
+ .dom(".upcoming-events-list__event")
+ .exists({ count: 1 }, "only future event is displayed");
+
+ assert
+ .dom(".upcoming-events-list__event-name")
+ .hasText("Future Event", "past event is filtered out");
});
test("Uses custom category name from 'map_events_title'", async function (assert) {
From 5a1ab2b054fec48fb318068ad2bf9c03912d3040 Mon Sep 17 00:00:00 2001
From: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com>
Date: Mon, 23 Feb 2026 10:59:56 -0600
Subject: [PATCH 012/168] FIX: Prevent unnecessary revision when editing first
post with tagging enabled (#37989)
When editing the first post of a topic, we send `tags: []` via `PUT
/t/:id` even when the topic has no tags. Since `[].present?` is `false`,
the existing tag cleanup logic was skipped, leaving a no-op tags key in
the changes hash. This caused `PostRevisor#topic_changed?` to return
`true` (it checks key presence, not value), bypassing the grace period
and creating an empty hidden revision.
To fix this I updated the condition in `TopicsController#update` to use
`has_key?` instead of `present?`, and delete the tags key when both the
incoming tags and existing tags are empty. Also added matching
normalization in `PostRevisor#revise!`
---
app/controllers/topics_controller.rb | 32 ++++++++++++++-----------
lib/post_revisor.rb | 1 +
spec/lib/post_revisor_spec.rb | 11 +++++++++
spec/requests/topics_controller_spec.rb | 19 +++++++++++++++
4 files changed, 49 insertions(+), 14 deletions(-)
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index 162bf4688a0ca..1829cd9bcb3a8 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -449,21 +449,25 @@ def update
changes.delete(:title) if topic.title == changes[:title]
changes.delete(:category_id) if topic.category_id.to_i == changes[:category_id].to_i
- if Tag.include_tags? && changes[:tags].present?
- incoming = changes[:tags]
-
- if incoming.first.is_a?(String)
- Discourse.deprecate(
- "Passing tag names as strings to the tags param is deprecated, use tag objects ({id, name}) instead",
- since: "2026.01",
- drop_from: "2026.07",
- )
- changes.delete(:tags) if incoming.sort == topic.tags.map(&:name).sort
- else
- has_new = incoming.any? { |t| t[:id].blank? }
- if !has_new && incoming.filter_map { |t| t[:id]&.to_i }.sort == topic.tags.pluck(:id).sort
- changes.delete(:tags)
+ if Tag.include_tags? && changes.has_key?(:tags)
+ if changes[:tags].present?
+ incoming = changes[:tags]
+
+ if incoming.first.is_a?(String)
+ Discourse.deprecate(
+ "Passing tag names as strings to the tags param is deprecated, use tag objects ({id, name}) instead",
+ since: "2026.01",
+ drop_from: "2026.07",
+ )
+ changes.delete(:tags) if incoming.sort == topic.tags.map(&:name).sort
+ else
+ has_new = incoming.any? { |t| t[:id].blank? }
+ if !has_new && incoming.filter_map { |t| t[:id]&.to_i }.sort == topic.tags.pluck(:id).sort
+ changes.delete(:tags)
+ end
end
+ elsif topic.tags.empty?
+ changes.delete(:tags)
end
end
diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb
index 1f4ef6e5b5fba..b65bf9119624a 100644
--- a/lib/post_revisor.rb
+++ b/lib/post_revisor.rb
@@ -239,6 +239,7 @@ def revise!(editor, fields, opts = {})
@fields[:raw] = cleanup_whitespaces(@fields[:raw]) if @fields.has_key?(:raw)
@fields[:user_id] = @fields[:user_id].to_i if @fields.has_key?(:user_id)
@fields[:category_id] = @fields[:category_id].to_i if @fields.has_key?(:category_id)
+ @fields.delete(:tags) if @fields.has_key?(:tags) && @fields[:tags].blank? && @topic.tags.empty?
# always reset edit_reason unless provided, do not set to nil else
# previous reasons are lost
diff --git a/spec/lib/post_revisor_spec.rb b/spec/lib/post_revisor_spec.rb
index 57c78eef9c932..a61e343e5b89a 100644
--- a/spec/lib/post_revisor_spec.rb
+++ b/spec/lib/post_revisor_spec.rb
@@ -575,6 +575,17 @@
expect(post.public_version).to eq(1)
expect(post.revisions.size).to eq(0)
end
+
+ it "does not create a new version for tag-only topic change within grace period when tags unchanged" do
+ SiteSetting.tagging_enabled = true
+ SiteSetting.editing_grace_period = 1.minute
+
+ post_revisor.revise!(post.user, { tags: [] }, revised_at: post.updated_at + 10.seconds)
+ post.reload
+
+ expect(post.version).to eq(1)
+ expect(post.revisions.size).to eq(0)
+ end
end
describe "edit reasons" do
diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb
index 6d30a3fbeb8de..1da6ab3a158b0 100644
--- a/spec/requests/topics_controller_spec.rb
+++ b/spec/requests/topics_controller_spec.rb
@@ -2259,6 +2259,25 @@ def topic_user_post_timings_count(user, topic)
expect(response.status).to eq(200)
expect(topic.reload.tags).to contain_exactly(tag)
end
+
+ it "does not create a revision when tags param is empty and topic has no tags" do
+ expect do
+ put "/t/#{topic.slug}/#{topic.id}.json", params: { tags: [] }, as: :json
+ end.not_to change { topic.reload.first_post.revisions.count }
+
+ expect(response.status).to eq(200)
+ end
+
+ it "creates a revision when all tags are removed from a topic" do
+ topic.tags << tag
+
+ expect do
+ put "/t/#{topic.slug}/#{topic.id}.json", params: { tags: [] }, as: :json
+ end.to change { topic.reload.first_post.revisions.count }.by(1)
+
+ expect(response.status).to eq(200)
+ expect(topic.reload.tags).to be_empty
+ end
end
context "when topic is private" do
From 1a22ddb55e3a9af7091782b9fc2da6fb134bdca3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?=
Date: Mon, 23 Feb 2026 18:32:51 +0100
Subject: [PATCH 013/168] FIX: Strip subfolder prefix before SERVER_SIDE_ONLY
check in routeTo (#37996)
On subfolder setups, clicking server-side-only links (e.g. upload
download links on the admin user exports page) resulted in a "page
doesn't exist" error instead of downloading the file.
When Discourse runs in a subfolder (e.g. /forum), URLs like
`/forum/uploads/short-url/abc.csv.gz` were not matching the
SERVER_SIDE_ONLY regex patterns (e.g. `/^\/uploads\//`) because the
pathname still included the subfolder prefix at the point of the check.
This caused `routeTo` to hand the URL to Ember's router instead of
redirecting to the server, hitting the catch-all "unknown" route.
The fix strips the subfolder prefix using `withoutPrefix()` before
checking against SERVER_SIDE_ONLY patterns. The original path (with
prefix) is still passed to `redirectTo`, which correctly handles both
prefixed and unprefixed URLs via `getURL()`.
This only surfaced for the admin user exports page because most upload
links in post content go through `click-track.js` which calls
`redirectTo()` directly for `.attachment` links, bypassing `routeTo()`
and its SERVER_SIDE_ONLY check entirely.
https://meta.discourse.org/t/396783
---
frontend/discourse/app/lib/url.js | 5 +++-
frontend/discourse/tests/unit/lib/url-test.js | 24 +++++++++++++++++++
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/frontend/discourse/app/lib/url.js b/frontend/discourse/app/lib/url.js
index d6d2090b3e517..eab4a85e4d54f 100644
--- a/frontend/discourse/app/lib/url.js
+++ b/frontend/discourse/app/lib/url.js
@@ -238,7 +238,10 @@ class DiscourseURL extends EmberObject {
return this.redirectTo(path);
}
- const serverSide = SERVER_SIDE_ONLY.some((r) => pathname.match(r));
+ const pathnameWithoutPrefix = withoutPrefix(pathname);
+ const serverSide = SERVER_SIDE_ONLY.some((r) =>
+ pathnameWithoutPrefix.match(r)
+ );
if (serverSide) {
this.redirectTo(path);
return;
diff --git a/frontend/discourse/tests/unit/lib/url-test.js b/frontend/discourse/tests/unit/lib/url-test.js
index 27956c0447369..805a90a766efc 100644
--- a/frontend/discourse/tests/unit/lib/url-test.js
+++ b/frontend/discourse/tests/unit/lib/url-test.js
@@ -264,6 +264,30 @@ module("Unit | Utility | url", function (hooks) {
);
});
+ test("routeTo redirects server-side-only URLs on subfolder setup", async function (assert) {
+ setPrefix("/forum");
+ sinon.stub(DiscourseURL, "redirectTo");
+ sinon.stub(DiscourseURL, "handleURL");
+
+ DiscourseURL.routeTo("/forum/uploads/short-url/test.csv.gz");
+ assert.true(
+ DiscourseURL.redirectTo.calledWith(
+ "/forum/uploads/short-url/test.csv.gz"
+ ),
+ "uploads short-url on subfolder is redirected to server"
+ );
+
+ DiscourseURL.redirectTo.resetHistory();
+
+ DiscourseURL.routeTo("/forum/secure-uploads/original/1X/test.pdf");
+ assert.true(
+ DiscourseURL.redirectTo.calledWith(
+ "/forum/secure-uploads/original/1X/test.pdf"
+ ),
+ "secure-uploads on subfolder is redirected to server"
+ );
+ });
+
test("anchor handling", async function (assert) {
sinon.stub(DiscourseURL, "jumpToElement");
sinon.stub(DiscourseURL, "replaceState");
From c88a9c285a404aa23f9048e45eff86123a060f9a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?=
<1108771+megothss@users.noreply.github.com>
Date: Mon, 23 Feb 2026 15:44:16 -0300
Subject: [PATCH 014/168] DEV: replace static tracked property registry with
dynamic introspection (#37908)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Adds `enumerateTrackedKeys` and `enumerateTrackedEntries` utilities to
`tracked-tools.js` that dynamically discover `@tracked` properties by
walking Ember's internal meta chain
- Removes the `@trackedPostProperty` decorator and
`trackedPropertiesForPostUpdate` static registry from the Post model
- Improves `@trackedArray` error messages to include the property name
## Problem
Ember's `@tracked` decorator defines properties on the class prototype,
making them invisible to `Object.keys()`, spread syntax (`...obj`), and
`Object.assign()`. This caused two issues:
**1. `RestModel.create()` silently dropped tracked property values**
When a model instance was passed to `create()` (e.g.
`AdminUser.create(user)` where `user` is a User instance), tracked
properties were lost during the spread in `RestModel.create()`:
```js
// Before: tracked properties on `args` were invisible to spread
const createArgs = this.munge(args, args.store);
// args.username (tracked) → silently missing from createArgs
```
**2. `Post.updateFromPost()` required a fragile manual registry**
To work around the non-enumerability of tracked properties, every
tracked property on Post had to use a custom `@trackedPostProperty`
decorator instead of standard `@tracked`. This decorator registered
property names in a static Set. Forgetting to use it (using `@tracked`
instead) meant the property wouldn't be copied when the identity map
updated a post, leading to stale UI data.
## Solution
`enumerateTrackedKeys(obj)` walks the prototype's Ember meta chain and
collects all `TrackedDescriptor` entries, returning the property names.
This works with `@tracked`, `@dedupeTracked`, and `@trackedArray`.
- `Post.updateFromPost()` now dynamically discovers tracked keys instead
of relying on a static registry — all Post properties use standard
`@tracked`
- `RestModel.create()` now surfaces tracked property values alongside
regular enumerable properties before passing to `munge()`
---
frontend/discourse/app/lib/tracked-tools.js | 84 +++-
frontend/discourse/app/models/post.js | 154 +++---
frontend/discourse/app/models/rest.js | 6 +-
.../tests/unit/lib/tracked-tools-test.gjs | 451 +++++++++++++++++-
4 files changed, 601 insertions(+), 94 deletions(-)
diff --git a/frontend/discourse/app/lib/tracked-tools.js b/frontend/discourse/app/lib/tracked-tools.js
index cb5191b6c8540..fc5285ba4b5b0 100644
--- a/frontend/discourse/app/lib/tracked-tools.js
+++ b/frontend/discourse/app/lib/tracked-tools.js
@@ -1,5 +1,5 @@
import { tracked } from "@glimmer/tracking";
-import { meta as metaFor } from "@ember/-internals/meta";
+import { meta as metaFor, peekMeta } from "@ember/-internals/meta";
import { TrackedDescriptor } from "@ember/-internals/metal";
import { next } from "@ember/runloop";
import { TrackedArray, TrackedSet } from "@ember-compat/tracked-built-ins";
@@ -187,7 +187,7 @@ export class DeferredTrackedSet {
* or null/undefined if those values were passed
* @throws {Error} If value is not an array, TrackedArray, null or undefined
*/
-function ensureTrackedArray(value) {
+function ensureTrackedArray(value, propertyName) {
if (typeof value === "undefined" || value === null) {
return value;
}
@@ -201,7 +201,7 @@ function ensureTrackedArray(value) {
}
throw new Error(
- `Expected an array, TrackedArray, null, or undefined, got ${typeof value}. Value received: ${value}`
+ `[${propertyName}]: Expected an array, TrackedArray, null, or undefined, got ${typeof value}. Value received: ${value}`
);
}
@@ -224,7 +224,7 @@ export function trackedArray(target, key, desc) {
const originalInitializer = desc.initializer;
desc.initializer = function () {
const initialValue = originalInitializer.apply(this);
- return ensureTrackedArray(initialValue);
+ return ensureTrackedArray(initialValue, key);
};
}
@@ -236,7 +236,7 @@ export function trackedArray(target, key, desc) {
});
function trackedArraySetter(value) {
- set.call(this, ensureTrackedArray(value));
+ set.call(this, ensureTrackedArray(value, key));
}
// When using EmberObject.create(...), Ember accesses the tracked properties directly
@@ -258,3 +258,77 @@ export function trackedArray(target, key, desc) {
isTracked: true,
};
}
+
+/**
+ * Enumerates all tracked property keys from an object instance.
+ *
+ * **Warning:** This function uses Ember internal APIs (`peekMeta`, `TrackedDescriptor`)
+ * which are not part of the public API and may change without notice in future Ember versions.
+ *
+ * @param {Object} obj - The object instance to enumerate tracked keys from
+ * @returns {Array} An array of tracked property keys.
+ * Returns an empty array if the object has no prototype or no tracked properties.
+ *
+ * @example
+ * class MyClass {
+ * @tracked name = "Alice";
+ * @tracked age = 30;
+ * regularProp = "not tracked";
+ * }
+ *
+ * const instance = new MyClass();
+ * const trackedKeys = enumerateTrackedKeys(instance);
+ * // Returns: ["name", "age"]
+ */
+export function enumerateTrackedKeys(obj) {
+ const prototype = obj?.constructor?.prototype;
+ if (!prototype) {
+ return [];
+ }
+
+ const result = new Set();
+
+ // Walk the prototype chain to collect tracked keys from parent classes too
+ let meta = peekMeta(prototype);
+ while (meta) {
+ const descriptors = meta._descriptors;
+
+ if (descriptors) {
+ for (const [key, desc] of descriptors) {
+ if (desc instanceof TrackedDescriptor) {
+ result.add(key);
+ }
+ }
+ }
+
+ meta = meta.parent;
+ }
+
+ return [...result];
+}
+
+/**
+ * Enumerates all tracked property entries from an object instance.
+ *
+ * **Warning:** This function uses Ember internal APIs (`peekMeta`, `TrackedDescriptor`)
+ * which are not part of the public API and may change without notice in future Ember versions.
+ *
+ * @param {Object} obj - The object instance to enumerate tracked entries from
+ * @returns {Array<[string, any]>} An array of [key, value] pairs for all tracked properties.
+ * Returns an empty array if the object has no prototype or no tracked properties.
+ *
+ * @example
+ * class MyClass {
+ * @tracked name = "Alice";
+ * @tracked age = 30;
+ * regularProp = "not tracked";
+ * }
+ *
+ * const instance = new MyClass();
+ * const trackedEntries = enumerateTrackedEntries(instance);
+ * // Returns: [["name", "Alice"], ["age", 30]]
+ */
+export function enumerateTrackedEntries(obj) {
+ const keys = enumerateTrackedKeys(obj);
+ return keys.map((key) => [key, obj[key]]);
+}
diff --git a/frontend/discourse/app/models/post.js b/frontend/discourse/app/models/post.js
index c53542ee127ee..ae3863b3af488 100644
--- a/frontend/discourse/app/models/post.js
+++ b/frontend/discourse/app/models/post.js
@@ -11,7 +11,10 @@ import { propertyEqual } from "discourse/lib/computed";
import discourseComputed from "discourse/lib/decorators";
import { cook } from "discourse/lib/text";
import { fancyTitle } from "discourse/lib/topic-fancy-title";
-import { defineTrackedProperty } from "discourse/lib/tracked-tools";
+import {
+ defineTrackedProperty,
+ enumerateTrackedKeys,
+} from "discourse/lib/tracked-tools";
import { userPath } from "discourse/lib/url";
import { postUrl } from "discourse/lib/utilities";
import ActionSummary from "discourse/models/action-summary";
@@ -23,7 +26,6 @@ import User from "discourse/models/user";
import { i18n } from "discourse-i18n";
const pluginTrackedProperties = new Set();
-const trackedPropertiesForPostUpdate = new Set();
/**
* @internal
@@ -46,22 +48,6 @@ export function clearAddedTrackedPostProperties() {
pluginTrackedProperties.clear();
}
-/**
- * Decorator to mark a property as post property as tracked.
- *
- * It extends the standard Ember @tracked behavior to also keep track of the fields
- * that need to be copied when using `post.updateFromPost`.
- *
- * @param {Object} target - The target object.
- * @param {string} propertyKey - The key of the property to track.
- * @param {PropertyDescriptor} descriptor - The property descriptor.
- * @returns {PropertyDescriptor} The updated property descriptor.
- */
-function trackedPostProperty(target, propertyKey, descriptor) {
- trackedPropertiesForPostUpdate.add(propertyKey);
- return tracked(target, propertyKey, descriptor);
-}
-
export default class Post extends RestModel {
static munge(json) {
json.likeAction ??= null;
@@ -150,75 +136,69 @@ export default class Post extends RestModel {
@service currentUser;
@service site;
+ @tracked action_code;
+ @tracked action_code_path;
+ @tracked action_code_who;
+ @tracked actions_summary;
+ @tracked admin;
+ @tracked badges_granted;
+ @tracked bookmarked;
+ @tracked can_delete;
+ @tracked can_edit;
+ @tracked can_permanently_delete;
+ @tracked can_recover;
+ @tracked can_see_hidden_post;
+ @tracked can_view_edit_history;
+ @tracked cooked;
+ @tracked cooked_hidden;
+ @tracked created_at;
@tracked customShare = null;
-
- // Use @trackedPostProperty here instead of Glimmer's @tracked because we need to know which properties are tracked
- // in order to correctly update the post in the updateFromPost method. Currently this is not possible using only
- // the standard tracked method because these properties are added to the class prototype and are not enumarated by
- // object.keys().
- // See https://github.com/emberjs/ember.js/issues/18220
- @trackedPostProperty action_code;
- @trackedPostProperty action_code_path;
- @trackedPostProperty action_code_who;
- @trackedPostProperty actions_summary;
- @trackedPostProperty admin;
- @trackedPostProperty badges_granted;
- @trackedPostProperty bookmarked;
- @trackedPostProperty can_delete;
- @trackedPostProperty can_edit;
- @trackedPostProperty can_permanently_delete;
- @trackedPostProperty can_recover;
- @trackedPostProperty can_see_hidden_post;
- @trackedPostProperty can_view_edit_history;
- @trackedPostProperty cooked;
- @trackedPostProperty cooked_hidden;
- @trackedPostProperty created_at;
- @trackedPostProperty deleted_at;
- @trackedPostProperty deleted_by;
- @trackedPostProperty excerpt;
- @trackedPostProperty expandedExcerpt;
- @trackedPostProperty group_moderator;
- @trackedPostProperty hasGap;
- @trackedPostProperty hidden;
- @trackedPostProperty id;
- @trackedPostProperty is_auto_generated;
- @trackedPostProperty last_wiki_edit;
- @trackedPostProperty likeAction;
- @trackedPostProperty link_counts;
- @trackedPostProperty locked;
- @trackedPostProperty moderator;
- @trackedPostProperty name;
- @trackedPostProperty notice;
- @trackedPostProperty notice_created_by_user;
- @trackedPostProperty post_number;
- @trackedPostProperty post_type;
- @trackedPostProperty primary_group_name;
- @trackedPostProperty quoted;
- @trackedPostProperty read;
- @trackedPostProperty readers_count;
- @trackedPostProperty reply_count;
- @trackedPostProperty reply_to_user;
- @trackedPostProperty staff;
- @trackedPostProperty staged;
- @trackedPostProperty title_is_group;
- @trackedPostProperty topic;
- @trackedPostProperty topic_id;
- @trackedPostProperty trust_level;
- @trackedPostProperty updated_at;
- @trackedPostProperty user_deleted;
- @trackedPostProperty user_id;
- @trackedPostProperty user_suspended;
- @trackedPostProperty user_title;
- @trackedPostProperty username;
- @trackedPostProperty version;
- @trackedPostProperty via_email;
- @trackedPostProperty wiki;
- @trackedPostProperty yours;
- @trackedPostProperty user_custom_fields;
- @trackedPostProperty post_localizations;
- @trackedPostProperty is_localized;
- @trackedPostProperty language;
- @trackedPostProperty localization_outdated;
+ @tracked deleted_at;
+ @tracked deleted_by;
+ @tracked excerpt;
+ @tracked expandedExcerpt;
+ @tracked group_moderator;
+ @tracked hasGap;
+ @tracked hidden;
+ @tracked id;
+ @tracked is_auto_generated;
+ @tracked is_localized;
+ @tracked language;
+ @tracked last_wiki_edit;
+ @tracked likeAction;
+ @tracked link_counts;
+ @tracked localization_outdated;
+ @tracked locked;
+ @tracked moderator;
+ @tracked name;
+ @tracked notice;
+ @tracked notice_created_by_user;
+ @tracked post_localizations;
+ @tracked post_number;
+ @tracked post_type;
+ @tracked primary_group_name;
+ @tracked quoted;
+ @tracked read;
+ @tracked readers_count;
+ @tracked reply_count;
+ @tracked reply_to_user;
+ @tracked staff;
+ @tracked staged;
+ @tracked title_is_group;
+ @tracked topic;
+ @tracked topic_id;
+ @tracked trust_level;
+ @tracked updated_at;
+ @tracked user_custom_fields;
+ @tracked user_deleted;
+ @tracked user_id;
+ @tracked user_suspended;
+ @tracked user_title;
+ @tracked username;
+ @tracked version;
+ @tracked via_email;
+ @tracked wiki;
+ @tracked yours;
@alias("can_edit") canEdit; // for compatibility with existing code
@equal("trust_level", 0) new_user;
@@ -631,7 +611,7 @@ export default class Post extends RestModel {
updateFromPost(otherPost) {
[
...Object.keys(otherPost),
- ...trackedPropertiesForPostUpdate,
+ ...enumerateTrackedKeys(otherPost),
...pluginTrackedProperties,
].forEach((key) => {
let value = otherPost[key],
diff --git a/frontend/discourse/app/models/rest.js b/frontend/discourse/app/models/rest.js
index d3e99ccbdcce5..035e75f4fef31 100644
--- a/frontend/discourse/app/models/rest.js
+++ b/frontend/discourse/app/models/rest.js
@@ -5,6 +5,7 @@ import { equal } from "@ember/object/computed";
import { getOwner, setOwner } from "@ember/owner";
import { Promise } from "rsvp";
import { getOwnerWithFallback } from "discourse/lib/get-owner";
+import { enumerateTrackedEntries } from "discourse/lib/tracked-tools";
export default class RestModel extends EmberObject {
// Overwrite and JSON will be passed through here before `create` and `update`
@@ -16,7 +17,10 @@ export default class RestModel extends EmberObject {
args = args || {};
args.__munge = this.munge;
- const createArgs = this.munge(args, args.store);
+ const createArgs = this.munge(
+ { ...args, ...Object.fromEntries(enumerateTrackedEntries(args)) },
+ args.store
+ );
// Some Discourse code calls `model.create()` directly without going through the
// store. In that case the owner is not set, and injections will fail. This workaround ensures
diff --git a/frontend/discourse/tests/unit/lib/tracked-tools-test.gjs b/frontend/discourse/tests/unit/lib/tracked-tools-test.gjs
index 3db4a9881c0f3..9ffacf7492a33 100644
--- a/frontend/discourse/tests/unit/lib/tracked-tools-test.gjs
+++ b/frontend/discourse/tests/unit/lib/tracked-tools-test.gjs
@@ -1,4 +1,4 @@
-import { cached } from "@glimmer/tracking";
+import { cached, tracked } from "@glimmer/tracking";
import { run } from "@ember/runloop";
import { settled } from "@ember/test-helpers";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
@@ -6,6 +6,8 @@ import { module, test } from "qunit";
import {
dedupeTracked,
DeferredTrackedSet,
+ enumerateTrackedEntries,
+ enumerateTrackedKeys,
trackedArray,
} from "discourse/lib/tracked-tools";
@@ -314,4 +316,451 @@ module("Unit | tracked-tools", function () {
);
});
});
+
+ module("enumerateTrackedKeys", function () {
+ test("returns tracked property keys from an object", function (assert) {
+ class Person {
+ @tracked name = "Alice";
+ @tracked age = 30;
+ regularProp = "not tracked";
+ }
+
+ const instance = new Person();
+ const result = enumerateTrackedKeys(instance);
+
+ assert.deepEqual(
+ result,
+ ["name", "age"],
+ "returns only tracked property keys"
+ );
+ assert.false(
+ result.includes("regularProp"),
+ "non-tracked property is not included"
+ );
+ });
+
+ test("returns empty array for null or undefined", function (assert) {
+ const resultNull = enumerateTrackedKeys(null);
+ const resultUndefined = enumerateTrackedKeys(undefined);
+
+ assert.deepEqual(resultNull, [], "returns empty array for null");
+ assert.deepEqual(
+ resultUndefined,
+ [],
+ "returns empty array for undefined"
+ );
+ });
+
+ test("returns empty array for objects without tracked properties", function (assert) {
+ class SimpleClass {
+ prop1 = "value1";
+ prop2 = "value2";
+ }
+
+ const instance = new SimpleClass();
+ const result = enumerateTrackedKeys(instance);
+
+ assert.deepEqual(
+ result,
+ [],
+ "returns empty array for non-tracked properties"
+ );
+ });
+
+ test("handles objects with only tracked properties", function (assert) {
+ class AllTracked {
+ @tracked first = "one";
+ @tracked second = "two";
+ @tracked third = "three";
+ }
+
+ const instance = new AllTracked();
+ const result = enumerateTrackedKeys(instance);
+
+ assert.deepEqual(
+ result.sort(),
+ ["first", "second", "third"].sort(),
+ "returns all tracked property keys"
+ );
+ assert.strictEqual(result.length, 3, "returns correct number of keys");
+ });
+
+ test("includes @dedupeTracked properties", function (assert) {
+ class TestClass {
+ @tracked normalTracked = "normal";
+ @dedupeTracked dedupedTracked = "deduped";
+ regularProp = "regular";
+ }
+
+ const instance = new TestClass();
+ const result = enumerateTrackedKeys(instance);
+
+ assert.true(
+ result.includes("normalTracked"),
+ "includes @tracked property"
+ );
+ assert.true(
+ result.includes("dedupedTracked"),
+ "includes @dedupeTracked property"
+ );
+ assert.false(result.includes("regularProp"), "excludes regular property");
+ });
+
+ test("includes @trackedArray properties", function (assert) {
+ class TestClass {
+ @tracked name = "test";
+ @trackedArray items = ["a", "b", "c"];
+ regularProp = "regular";
+ }
+
+ const instance = new TestClass();
+ const result = enumerateTrackedKeys(instance);
+
+ assert.true(result.includes("name"), "includes @tracked property");
+ assert.true(result.includes("items"), "includes @trackedArray property");
+ assert.false(result.includes("regularProp"), "excludes regular property");
+ });
+
+ test("handles inherited tracked properties", function (assert) {
+ class Parent {
+ @tracked parentProp = "parent";
+ }
+
+ class Child extends Parent {
+ @tracked childProp = "child";
+ }
+
+ const instance = new Child();
+ const result = enumerateTrackedKeys(instance);
+
+ assert.true(
+ result.includes("parentProp"),
+ "includes parent tracked property"
+ );
+ assert.true(
+ result.includes("childProp"),
+ "includes child tracked property"
+ );
+ assert.strictEqual(
+ result.length,
+ 2,
+ "includes both parent and child properties"
+ );
+ });
+
+ test("handles multi-level inheritance", function (assert) {
+ class GrandParent {
+ @tracked grandProp = "grand";
+ }
+
+ class Parent extends GrandParent {
+ @tracked parentProp = "parent";
+ }
+
+ class Child extends Parent {
+ @tracked childProp = "child";
+ }
+
+ const instance = new Child();
+ const result = enumerateTrackedKeys(instance);
+
+ assert.true(
+ result.includes("grandProp"),
+ "includes grandparent property"
+ );
+ assert.true(result.includes("parentProp"), "includes parent property");
+ assert.true(result.includes("childProp"), "includes child property");
+ assert.strictEqual(result.length, 3, "includes all inherited properties");
+ });
+
+ test("returns keys regardless of current property values", function (assert) {
+ class TestClass {
+ @tracked nullProp = null;
+ @tracked undefinedProp = undefined;
+ @tracked stringProp = "value";
+ }
+
+ const instance = new TestClass();
+ const result = enumerateTrackedKeys(instance);
+
+ assert.true(
+ result.includes("nullProp"),
+ "includes property with null value"
+ );
+ assert.true(
+ result.includes("undefinedProp"),
+ "includes property with undefined value"
+ );
+ assert.true(
+ result.includes("stringProp"),
+ "includes property with string value"
+ );
+ assert.strictEqual(
+ result.length,
+ 3,
+ "returns all keys regardless of values"
+ );
+ });
+
+ test("handles objects with various tracked value types", function (assert) {
+ class MixedTypes {
+ @tracked string = "text";
+ @tracked number = 42;
+ @tracked boolean = true;
+ @tracked object = { nested: "value" };
+ @tracked array = [1, 2, 3];
+ }
+
+ const instance = new MixedTypes();
+ const result = enumerateTrackedKeys(instance);
+
+ assert.deepEqual(
+ result.sort(),
+ ["array", "boolean", "number", "object", "string"].sort(),
+ "returns keys for all value types"
+ );
+ });
+ });
+
+ module("enumerateTrackedEntries", function () {
+ test("returns tracked property entries from an object", function (assert) {
+ class Person {
+ @tracked name = "Alice";
+ @tracked age = 30;
+ regularProp = "not tracked";
+ }
+
+ const instance = new Person();
+ const result = enumerateTrackedEntries(instance);
+
+ assert.deepEqual(
+ result,
+ [
+ ["name", "Alice"],
+ ["age", 30],
+ ],
+ "returns entries for tracked properties"
+ );
+ });
+
+ test("returns empty array for null or undefined", function (assert) {
+ const resultNull = enumerateTrackedEntries(null);
+ const resultUndefined = enumerateTrackedEntries(undefined);
+
+ assert.deepEqual(resultNull, [], "returns empty array for null");
+ assert.deepEqual(
+ resultUndefined,
+ [],
+ "returns empty array for undefined"
+ );
+ });
+
+ test("returns empty array for objects without tracked properties", function (assert) {
+ class SimpleClass {
+ prop1 = "value1";
+ prop2 = "value2";
+ }
+
+ const instance = new SimpleClass();
+ const result = enumerateTrackedEntries(instance);
+
+ assert.deepEqual(
+ result,
+ [],
+ "returns empty array for non-tracked properties"
+ );
+ });
+
+ test("handles objects with only tracked properties", function (assert) {
+ class AllTracked {
+ @tracked first = "one";
+ @tracked second = "two";
+ @tracked third = "three";
+ }
+
+ const instance = new AllTracked();
+ const result = enumerateTrackedEntries(instance);
+
+ assert.strictEqual(result.length, 3, "returns three entries");
+ const obj = Object.fromEntries(result);
+ assert.strictEqual(obj.first, "one");
+ assert.strictEqual(obj.second, "two");
+ assert.strictEqual(obj.third, "three");
+ });
+
+ test("includes @dedupeTracked properties", function (assert) {
+ class TestClass {
+ @tracked normalTracked = "normal";
+ @dedupeTracked dedupedTracked = "deduped";
+ regularProp = "regular";
+ }
+
+ const instance = new TestClass();
+ const result = enumerateTrackedEntries(instance);
+
+ const obj = Object.fromEntries(result);
+ assert.strictEqual(obj.normalTracked, "normal");
+ assert.strictEqual(obj.dedupedTracked, "deduped");
+ assert.strictEqual(obj.regularProp, undefined);
+ });
+
+ test("includes @trackedArray properties", function (assert) {
+ class TestClass {
+ @tracked name = "test";
+ @trackedArray items = ["a", "b", "c"];
+ regularProp = "regular";
+ }
+
+ const instance = new TestClass();
+ const result = enumerateTrackedEntries(instance);
+
+ const obj = Object.fromEntries(result);
+ assert.strictEqual(obj.name, "test");
+ assert.true(obj.items instanceof TrackedArray);
+ assert.deepEqual(Array.from(obj.items), ["a", "b", "c"]);
+ assert.strictEqual(obj.regularProp, undefined);
+ });
+
+ test("handles inherited tracked properties", function (assert) {
+ class Parent {
+ @tracked parentProp = "parent";
+ }
+
+ class Child extends Parent {
+ @tracked childProp = "child";
+ }
+
+ const instance = new Child();
+ const result = enumerateTrackedEntries(instance);
+
+ assert.strictEqual(
+ result.length,
+ 2,
+ "includes both parent and child properties"
+ );
+ const obj = Object.fromEntries(result);
+ assert.strictEqual(
+ obj.parentProp,
+ "parent",
+ "includes parent tracked property"
+ );
+ assert.strictEqual(
+ obj.childProp,
+ "child",
+ "includes child tracked property"
+ );
+ });
+
+ test("handles multi-level inheritance", function (assert) {
+ class GrandParent {
+ @tracked grandProp = "grand";
+ }
+
+ class Parent extends GrandParent {
+ @tracked parentProp = "parent";
+ }
+
+ class Child extends Parent {
+ @tracked childProp = "child";
+ }
+
+ const instance = new Child();
+ const result = enumerateTrackedEntries(instance);
+
+ assert.strictEqual(result.length, 3);
+ const obj = Object.fromEntries(result);
+ assert.strictEqual(obj.grandProp, "grand");
+ assert.strictEqual(obj.parentProp, "parent");
+ assert.strictEqual(obj.childProp, "child");
+ });
+
+ test("reflects current property values", function (assert) {
+ class Counter {
+ @tracked count = 0;
+ }
+
+ const instance = new Counter();
+ assert.strictEqual(
+ Object.fromEntries(enumerateTrackedEntries(instance)).count,
+ 0,
+ "initial value is 0"
+ );
+
+ instance.count = 5;
+ assert.strictEqual(
+ Object.fromEntries(enumerateTrackedEntries(instance)).count,
+ 5,
+ "updated value is reflected"
+ );
+
+ instance.count = 100;
+ assert.strictEqual(
+ Object.fromEntries(enumerateTrackedEntries(instance)).count,
+ 100,
+ "further updates are reflected"
+ );
+ });
+
+ test("handles tracked properties with null/undefined values", function (assert) {
+ class TestClass {
+ @tracked nullProp = null;
+ @tracked undefinedProp = undefined;
+ @tracked stringProp = "value";
+ }
+
+ const instance = new TestClass();
+ const result = enumerateTrackedEntries(instance);
+
+ const obj = Object.fromEntries(result);
+ assert.strictEqual(obj.nullProp, null, "null value is preserved");
+ assert.strictEqual(
+ obj.undefinedProp,
+ undefined,
+ "undefined value is preserved"
+ );
+ assert.strictEqual(obj.stringProp, "value", "string value is correct");
+ assert.true("nullProp" in obj, "null property key exists in result");
+ assert.true(
+ "undefinedProp" in obj,
+ "undefined property key exists in result"
+ );
+ });
+
+ test("handles objects with various tracked value types", function (assert) {
+ class MixedTypes {
+ @tracked string = "text";
+ @tracked number = 42;
+ @tracked boolean = true;
+ @tracked object = { nested: "value" };
+ @tracked array = [1, 2, 3];
+ }
+
+ const instance = new MixedTypes();
+ const result = enumerateTrackedEntries(instance);
+
+ const obj = Object.fromEntries(result);
+ assert.strictEqual(obj.string, "text");
+ assert.strictEqual(obj.number, 42);
+ assert.true(obj.boolean);
+ assert.deepEqual(obj.object, { nested: "value" });
+ assert.deepEqual(obj.array, [1, 2, 3]);
+ });
+
+ test("can be converted to object with Object.fromEntries", function (assert) {
+ class TestClass {
+ @tracked firstName = "Alice";
+ @tracked lastName = "Smith";
+ }
+
+ const instance = new TestClass();
+ const entries = enumerateTrackedEntries(instance);
+ const obj = Object.fromEntries(entries);
+
+ assert.deepEqual(
+ obj,
+ { firstName: "Alice", lastName: "Smith" },
+ "entries can be converted back to object"
+ );
+ });
+ });
});
From 61fac8a6a8a088dc27257a90d5c19ede7feb5d1e Mon Sep 17 00:00:00 2001
From: Canapin
Date: Mon, 23 Feb 2026 19:48:23 +0100
Subject: [PATCH 015/168] FIX: user card cloak not disappearing on scroll (or
long press) on mobile (#37973)
This commit fixes the issue by replacing the `mousedown` event with `pointerdown`.
`pointerdown` wasn't compatible with safari until 2019 but I guess it's safe to use now.
Related meta topic:
https://meta.discourse.org/t/usercard-cloak-not-disappears-on-touchstart/319061?u=canapin
---
frontend/discourse/app/components/card-contents-base.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/discourse/app/components/card-contents-base.js b/frontend/discourse/app/components/card-contents-base.js
index eb9c801d2b804..44c55479a6479 100644
--- a/frontend/discourse/app/components/card-contents-base.js
+++ b/frontend/discourse/app/components/card-contents-base.js
@@ -106,7 +106,7 @@ export default class CardContentsBase extends Component {
didInsertElement() {
super.didInsertElement(...arguments);
- document.addEventListener("mousedown", this._clickOutsideHandler);
+ document.addEventListener("pointerdown", this._clickOutsideHandler);
document.addEventListener("keyup", this._escListener);
_cardClickListenerSelectors.forEach((selector) => {
@@ -273,7 +273,7 @@ export default class CardContentsBase extends Component {
willDestroyElement() {
super.willDestroyElement(...arguments);
- document.removeEventListener("mousedown", this._clickOutsideHandler);
+ document.removeEventListener("pointerdown", this._clickOutsideHandler);
document.removeEventListener("keyup", this._escListener);
_cardClickListenerSelectors.forEach((selector) => {
From efa0dbecff2778efd2ea5e88c2dab15af007f37d Mon Sep 17 00:00:00 2001
From: Kris
Date: Mon, 23 Feb 2026 13:59:27 -0500
Subject: [PATCH 016/168] UX: allow post controls to overflow when needed
(#37997)
We were already allowing this overflow on smaller screens, but we should
always allow it to avoid overflow like this:
When there are many controls, scrolling is better
Thanks to @small-lovely-cat for the suggestion in
https://github.com/discourse/discourse/pull/37400 (this is just a
slightly different approach)
---
app/assets/stylesheets/common/base/topic-post.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss
index 9e5f81a50d93e..11088d7e13640 100644
--- a/app/assets/stylesheets/common/base/topic-post.scss
+++ b/app/assets/stylesheets/common/base/topic-post.scss
@@ -410,6 +410,7 @@ nav.post-controls {
justify-content: space-between;
color: var(--d-post-control-text-color);
user-select: none;
+ overflow-x: auto;
.fade-out {
.discourse-no-touch & {
@@ -428,7 +429,6 @@ nav.post-controls {
--control-space: 0.58em;
--control-space-small: calc(var(--control-space) / 2);
--control-space-large: calc(var(--control-space) * 1.3);
- overflow-x: auto;
}
.actions {
From 1bda9a7b449d35efaf4baf4970e8081556b84e6f Mon Sep 17 00:00:00 2001
From: Jarek Radosz
Date: Mon, 23 Feb 2026 19:59:43 +0100
Subject: [PATCH 017/168] DEV: Use optionalRequire to import chat in d-ai
(#37990)
---
.../components/reviewable-ai-chat-message.gjs | 16 +++++++++++++---
.../components/reviewable/ai-chat-message.gjs | 16 +++++++++++++---
2 files changed, 26 insertions(+), 6 deletions(-)
diff --git a/plugins/discourse-ai/assets/javascripts/discourse/components/reviewable-ai-chat-message.gjs b/plugins/discourse-ai/assets/javascripts/discourse/components/reviewable-ai-chat-message.gjs
index cdef8c29fb48c..f2e4a38fa7dd0 100644
--- a/plugins/discourse-ai/assets/javascripts/discourse/components/reviewable-ai-chat-message.gjs
+++ b/plugins/discourse-ai/assets/javascripts/discourse/components/reviewable-ai-chat-message.gjs
@@ -4,9 +4,8 @@ import { LinkTo } from "@ember/routing";
import { htmlSafe } from "@ember/template";
import ReviewableCreatedBy from "discourse/components/reviewable-created-by";
import ReviewablePostHeader from "discourse/components/reviewable-post-header";
+import { optionalRequire } from "discourse/lib/utilities";
import { or } from "discourse/truth-helpers";
-import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
-import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import ModelAccuracies from "./model-accuracies";
export default class ReviewableAiChatMessage extends Component {
@@ -14,9 +13,20 @@ export default class ReviewableAiChatMessage extends Component {
if (!this.args.reviewable.chat_channel) {
return;
}
+
+ const ChatChannel = optionalRequire(
+ "discourse/plugins/chat/discourse/models/chat-channel"
+ );
+
return ChatChannel.create(this.args.reviewable.chat_channel);
}
+ get ChatChannelTitle() {
+ return optionalRequire(
+ "discourse/plugins/chat/discourse/components/chat-channel-title"
+ );
+ }
+
{{#if this.chatChannel}}
{{/if}}
diff --git a/plugins/discourse-ai/assets/javascripts/discourse/components/reviewable/ai-chat-message.gjs b/plugins/discourse-ai/assets/javascripts/discourse/components/reviewable/ai-chat-message.gjs
index 8ce1a37a74402..35c9bb29d5d5b 100644
--- a/plugins/discourse-ai/assets/javascripts/discourse/components/reviewable/ai-chat-message.gjs
+++ b/plugins/discourse-ai/assets/javascripts/discourse/components/reviewable/ai-chat-message.gjs
@@ -5,9 +5,8 @@ import { LinkTo } from "@ember/routing";
import ReviewableCreatedBy from "discourse/components/reviewable/created-by";
import ReviewableTopicLink from "discourse/components/reviewable/topic-link";
import highlightWatchedWords from "discourse/lib/highlight-watched-words";
+import { optionalRequire } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
-import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
-import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import ModelAccuracies from "../model-accuracies";
export default class ReviewableRefreshAiChatMessage extends Component {
@@ -16,6 +15,11 @@ export default class ReviewableRefreshAiChatMessage extends Component {
if (!this.args.reviewable.chat_channel) {
return;
}
+
+ const ChatChannel = optionalRequire(
+ "discourse/plugins/chat/discourse/models/chat-channel"
+ );
+
return ChatChannel.create(this.args.reviewable.chat_channel);
}
@@ -26,6 +30,12 @@ export default class ReviewableRefreshAiChatMessage extends Component {
);
}
+ get ChannelTitle() {
+ return optionalRequire(
+ "discourse/plugins/chat/discourse/components/channel-title"
+ );
+ }
+