diff --git a/app/assets/javascripts/discourse/app/instance-initializers/hashtag-css-generator.js b/app/assets/javascripts/discourse/app/instance-initializers/hashtag-css-generator.js
index c31ed86b3f8..e14fd3e6949 100644
--- a/app/assets/javascripts/discourse/app/instance-initializers/hashtag-css-generator.js
+++ b/app/assets/javascripts/discourse/app/instance-initializers/hashtag-css-generator.js
@@ -9,33 +9,19 @@ export default {
* cooked posts, and the sidebar.
*
* Each type has its own corresponding class, which is registered
- * with the hastag type via api.registerHashtagType. The default
+ * with the hashtag type via api.registerHashtagType. The default
* ones in core are CategoryHashtagType and TagHashtagType.
*/
initialize(owner) {
this.site = owner.lookup("service:site");
- // If the site is login_required and the user is anon there will be no categories
- // preloaded, so there will be no category color CSS variables generated by
- // the category-color-css-generator initializer.
- if (!this.site.categories?.length) {
- return;
- }
-
- let generatedCssClasses = [];
-
- Object.values(getHashtagTypeClasses()).forEach((hashtagType) => {
- hashtagType.preloadedData.forEach((model) => {
- generatedCssClasses = generatedCssClasses.concat(
- hashtagType.generateColorCssClasses(model)
- );
- });
- });
-
const cssTag = document.createElement("style");
cssTag.type = "text/css";
cssTag.id = "hashtag-css-generator";
- cssTag.innerHTML = generatedCssClasses.join("\n");
+ cssTag.innerHTML = Object.values(getHashtagTypeClasses())
+ .map((hashtagType) => hashtagType.generatePreloadedCssClasses())
+ .flat()
+ .join("\n");
document.head.appendChild(cssTag);
},
};
diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js b/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js
index 8f112a0c482..f36ba31a83b 100644
--- a/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js
+++ b/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js
@@ -228,6 +228,8 @@ function _searchRequest(term, contextualHashtagConfiguration, resultFunc) {
const hashtagType = getHashtagTypeClassesNew()[result.type];
result.icon = hashtagType.generateIconHTML({
+ preloaded: true,
+ colors: result.colors,
icon: result.icon,
id: result.id,
});
diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-decorator.js b/app/assets/javascripts/discourse/app/lib/hashtag-decorator.js
index c8584e653d4..5ab5df04b9f 100644
--- a/app/assets/javascripts/discourse/app/lib/hashtag-decorator.js
+++ b/app/assets/javascripts/discourse/app/lib/hashtag-decorator.js
@@ -63,7 +63,10 @@ function _findAndReplaceSeenHashtagPlaceholder(
// Replace raw span for the hashtag with a cooked one
const matchingSeenHashtag = seenHashtags[type]?.[slugRef];
if (matchingSeenHashtag) {
- generatePlaceholderHashtagHTML(type, hashtagSpan, matchingSeenHashtag);
+ generatePlaceholderHashtagHTML(type, hashtagSpan, {
+ preloaded: true,
+ ...matchingSeenHashtag,
+ });
}
});
}
diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-types/base.js b/app/assets/javascripts/discourse/app/lib/hashtag-types/base.js
index 4de8a448250..f8fe5fc1ebd 100644
--- a/app/assets/javascripts/discourse/app/lib/hashtag-types/base.js
+++ b/app/assets/javascripts/discourse/app/lib/hashtag-types/base.js
@@ -1,8 +1,37 @@
import { setOwner } from "@ember/application";
+import { debounce } from "@ember/runloop";
+import { ajax } from "discourse/lib/ajax";
+import { getHashtagTypeClasses } from "discourse/lib/hashtag-type-registry";
export default class HashtagTypeBase {
+ // Store a list of IDs that are currently being loaded globally to make it
+ // easier to batch requests for multiple types of hashtags
+ static loadingIds = {};
+
+ static async _load() {
+ const data = HashtagTypeBase.loadingIds;
+ HashtagTypeBase.loadingIds = {};
+
+ let hasData = false;
+ Object.keys(data).forEach((type) => {
+ hasData ||= data[type].size > 0;
+ data[type] = [...data[type]]; // Set to Array
+ });
+
+ if (!hasData) {
+ return;
+ }
+
+ const hashtags = await ajax("/hashtags/by-ids", { data });
+ const typeClasses = getHashtagTypeClasses();
+ Object.entries(typeClasses).forEach(([type, typeClass]) =>
+ hashtags[type]?.forEach((hashtag) => typeClass.onLoad(hashtag))
+ );
+ }
+
constructor(owner) {
setOwner(this, owner);
+ this.loadedIds = new Set();
}
get type() {
@@ -13,6 +42,15 @@ export default class HashtagTypeBase {
throw "not implemented";
}
+ generatePreloadedCssClasses() {
+ const cssClasses = [];
+ this.preloadedData.forEach((model) => {
+ this.loadedIds.add(model.id);
+ cssClasses.push(this.generateColorCssClasses(model));
+ });
+ return cssClasses.flat();
+ }
+
generateColorCssClasses() {
throw "not implemented";
}
@@ -20,4 +58,29 @@ export default class HashtagTypeBase {
generateIconHTML() {
throw "not implemented";
}
+
+ isLoaded(id) {
+ id = parseInt(id, 10);
+ return this.loadedIds.has(id);
+ }
+
+ load(id) {
+ id = parseInt(id, 10);
+ if (!this.isLoaded(id)) {
+ (HashtagTypeBase.loadingIds[this.type] ||= new Set()).add(id);
+ debounce(HashtagTypeBase, HashtagTypeBase._load, 100, false);
+ }
+ }
+
+ onLoad(hashtag) {
+ const hashtagId = parseInt(hashtag.id, 10);
+ if (!this.isLoaded(hashtagId)) {
+ this.loadedIds.add(hashtagId);
+
+ // Append the styles for the loaded hashtag to the CSS generated by the
+ // `hashtag-css-generator` initializer for preloaded models
+ document.querySelector("#hashtag-css-generator").innerHTML +=
+ "\n" + this.generateColorCssClasses(hashtag).join("\n");
+ }
+ }
}
diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-types/category.js b/app/assets/javascripts/discourse/app/lib/hashtag-types/category.js
index c362bf32564..24cac59c4a5 100644
--- a/app/assets/javascripts/discourse/app/lib/hashtag-types/category.js
+++ b/app/assets/javascripts/discourse/app/lib/hashtag-types/category.js
@@ -12,29 +12,50 @@ export default class CategoryHashtagType extends HashtagTypeBase {
return this.site.categories || [];
}
- generateColorCssClasses(category) {
- const generatedCssClasses = [];
- const backgroundGradient = [`var(--category-${category.id}-color) 50%`];
- if (category.parentCategory) {
- backgroundGradient.push(
- `var(--category-${category.parentCategory.id}-color) 50%`
- );
+ generatePreloadedCssClasses() {
+ return [
+ // Set a default color for category hashtags. This is added here instead
+ // of `hashtag.scss` because of the CSS precedence rules ( has a
+ // higher precedence than