From 6ad9e4ad060f326ddb65887427d8589ebaac9f8d Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Wed, 5 Apr 2023 13:02:35 +1000 Subject: [PATCH] FEATURE: Add CSS class generation for category colors and hashtags (#20951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a system to generate CSS variables and classes for categories and hashtags, which will be used in an effort to remove baked icons for hashtags and add color to those icons. This is in two parts. First I added an initializer generate a category color CSS variable style tag in the head tag that looks like this: ```css :root { --category-1-color: #0088CC; --category-2-color: #808281; --category-3-color: #E45735; --category-4-color: #A461EF; --category-5-color: #ee56c9; --category-6-color: #da28c2; --category-7-color: #ab8b0a; --category-8-color: #45da37; ... } ``` The number is the category ID. This only generates CSS variables for categories the user can access based on `site.categories`. If you need the parent color variable you can just use the `category.parentCategory.id` to get it. Then, I added an initializer to generate a hashtag CSS style tag using these variables. Only the category and channel hashtags need this, the category one generates the background-gradient needed for the swatch, and the channel just generates a color for the icon. This is done in an extendable way using the new `api.registerHashtagType` JS plugin API: ```css hashtag-color--category-1 { background: linear-gradient(90deg, var(--category-1-color) 50%, var(--category-1-color) 50%); } hashtag-color--category-2 { background: linear-gradient(90deg, var(--category-2-color) 50%, var(--category-2-color) 50%); } hashtag-color--category-5 { background: linear-gradient(90deg, var(--category-5-color) 50%, var(--category-4-color) 50%); } ... .hashtag-color--channel-4 { color: var(--category-12-color); } .hashtag-color--channel-92 { color: var(--category-24-color); } ``` Note if a category has a parent, its color is used in the gradient correctly. The numbers here are again IDs (e.g. channel ID, category ID) and the channel’s chatable ID is used to find the category color variable. --- .../category-color-css-generator.js | 29 ++++++++++ .../app/initializers/hashtag-css-generator.js | 34 +++++++++++ .../initializers/register-hashtag-types.js | 15 +++++ .../discourse/app/lib/hashtag-autocomplete.js | 11 ++++ .../discourse/app/lib/hashtag-types/base.js | 19 +++++++ .../app/lib/hashtag-types/category.js | 32 +++++++++++ .../discourse/app/lib/hashtag-types/tag.js | 15 +++++ .../discourse/app/lib/plugin-api.js | 12 ++++ .../tests/acceptance/css-generator-test.js | 32 +++++++++++ .../discourse/tests/helpers/qunit-helpers.js | 8 +++ .../discourse/initializers/chat-setup.js | 4 ++ .../discourse/lib/hashtag-types/channel.js | 25 ++++++++ .../acceptance/hashtag-css-generator-test.js | 57 +++++++++++++++++++ 13 files changed, 293 insertions(+) create mode 100644 app/assets/javascripts/discourse/app/initializers/category-color-css-generator.js create mode 100644 app/assets/javascripts/discourse/app/initializers/hashtag-css-generator.js create mode 100644 app/assets/javascripts/discourse/app/initializers/register-hashtag-types.js create mode 100644 app/assets/javascripts/discourse/app/lib/hashtag-types/base.js create mode 100644 app/assets/javascripts/discourse/app/lib/hashtag-types/category.js create mode 100644 app/assets/javascripts/discourse/app/lib/hashtag-types/tag.js create mode 100644 app/assets/javascripts/discourse/tests/acceptance/css-generator-test.js create mode 100644 plugins/chat/assets/javascripts/discourse/lib/hashtag-types/channel.js create mode 100644 plugins/chat/test/javascripts/acceptance/hashtag-css-generator-test.js diff --git a/app/assets/javascripts/discourse/app/initializers/category-color-css-generator.js b/app/assets/javascripts/discourse/app/initializers/category-color-css-generator.js new file mode 100644 index 00000000000..1fe8f98a35e --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/category-color-css-generator.js @@ -0,0 +1,29 @@ +export default { + name: "category-color-css-generator", + after: "register-hashtag-types", + + /** + * This generates CSS variables for each category color, + * which can be used in themes to style category-specific elements. + * + * It is also used when styling hashtag icons, since they are colored + * based on the category color. + */ + initialize(container) { + this.site = container.lookup("service:site"); + + const generatedCssVariables = [ + ":root {", + ...this.site.categories.map( + (category) => `--category-${category.id}-color: #${category.color};` + ), + "}", + ]; + + const cssTag = document.createElement("style"); + cssTag.type = "text/css"; + cssTag.id = "category-color-css-generator"; + cssTag.innerHTML = generatedCssVariables.join("\n"); + document.head.appendChild(cssTag); + }, +}; diff --git a/app/assets/javascripts/discourse/app/initializers/hashtag-css-generator.js b/app/assets/javascripts/discourse/app/initializers/hashtag-css-generator.js new file mode 100644 index 00000000000..88fc930b2cb --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/hashtag-css-generator.js @@ -0,0 +1,34 @@ +import { getHashtagTypeClasses } from "discourse/lib/hashtag-autocomplete"; + +export default { + name: "hashtag-css-generator", + after: "category-color-css-generator", + + /** + * This generates CSS classes for each hashtag type, + * which are used to color the hashtag icons in the composer, + * cooked posts, and the sidebar. + * + * Each type has its own corresponding class, which is registered + * with the hastag type via api.registerHashtagType. The default + * ones in core are CategoryHashtagType and TagHashtagType. + */ + initialize(container) { + let generatedCssClasses = []; + + Object.values(getHashtagTypeClasses()).forEach((hashtagTypeClass) => { + const hashtagType = new hashtagTypeClass(container); + 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"); + document.head.appendChild(cssTag); + }, +}; diff --git a/app/assets/javascripts/discourse/app/initializers/register-hashtag-types.js b/app/assets/javascripts/discourse/app/initializers/register-hashtag-types.js new file mode 100644 index 00000000000..990ce2dab60 --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/register-hashtag-types.js @@ -0,0 +1,15 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import CategoryHashtagType from "discourse/lib/hashtag-types/category"; +import TagHashtagType from "discourse/lib/hashtag-types/tag"; + +export default { + name: "register-hashtag-types", + before: "hashtag-css-generator", + + initialize() { + withPluginApi("0.8.7", (api) => { + api.registerHashtagType("category", CategoryHashtagType); + api.registerHashtagType("tag", TagHashtagType); + }); + }, +}; diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js b/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js index 8e052c22196..d08e7646877 100644 --- a/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js @@ -14,6 +14,17 @@ import { search as searchCategoryTag } from "discourse/lib/category-tag-search"; import { emojiUnescape } from "discourse/lib/text"; import { htmlSafe } from "@ember/template"; +let hashtagTypeClasses = {}; +export function registerHashtagType(type, typeClass) { + hashtagTypeClasses[type] = typeClass; +} +export function cleanUpHashtagTypeClasses() { + hashtagTypeClasses = {}; +} +export function getHashtagTypeClasses() { + return hashtagTypeClasses; +} + /** * Sets up a textarea using the jQuery autocomplete plugin, specifically * to match on the hashtag (#) character for autocompletion of categories, diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-types/base.js b/app/assets/javascripts/discourse/app/lib/hashtag-types/base.js new file mode 100644 index 00000000000..17be5a0a022 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/hashtag-types/base.js @@ -0,0 +1,19 @@ +import { setOwner } from "@ember/application"; + +export default class HashtagTypeBase { + constructor(container) { + setOwner(this, container); + } + + get type() { + throw "not implemented"; + } + + get preloadedData() { + throw "not implemented"; + } + + generateColorCssClasses() { + throw "not implemented"; + } +} diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-types/category.js b/app/assets/javascripts/discourse/app/lib/hashtag-types/category.js new file mode 100644 index 00000000000..e03b07e4124 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/hashtag-types/category.js @@ -0,0 +1,32 @@ +import HashtagTypeBase from "./base"; +import { inject as service } from "@ember/service"; + +export default class CategoryHashtagType extends HashtagTypeBase { + @service site; + + get type() { + return "category"; + } + + get preloadedData() { + return this.site.categories; + } + + generateColorCssClasses(model) { + const generatedCssClasses = []; + const backgroundGradient = [`var(--category-${model.id}-color) 50%`]; + if (model.parentCategory) { + backgroundGradient.push( + `var(--category-${model.parentCategory.id}-color) 50%` + ); + } else { + backgroundGradient.push(`var(--category-${model.id}-color) 50%`); + } + + generatedCssClasses.push(`.hashtag-color--category-${model.id} { + background: linear-gradient(90deg, ${backgroundGradient.join(", ")}); +}`); + + return generatedCssClasses; + } +} diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-types/tag.js b/app/assets/javascripts/discourse/app/lib/hashtag-types/tag.js new file mode 100644 index 00000000000..9161f864adf --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/hashtag-types/tag.js @@ -0,0 +1,15 @@ +import HashtagTypeBase from "./base"; + +export default class TagHashtagType extends HashtagTypeBase { + get type() { + return "tag"; + } + + get preloadedData() { + return []; + } + + generateColorCssClasses() { + return []; + } +} diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index 0d456f64d48..aae7d031a61 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -112,6 +112,7 @@ import { registerUserMenuTab } from "discourse/lib/user-menu/tab"; import { registerModelTransformer } from "discourse/lib/model-transformers"; import { registerCustomUserNavMessagesDropdownRow } from "discourse/controllers/user-private-messages"; import { registerFullPageSearchType } from "discourse/controllers/full-page-search"; +import { registerHashtagType } from "discourse/lib/hashtag-autocomplete"; // If you add any methods to the API ensure you bump up the version number // based on Semantic Versioning 2.0.0. Please update the changelog at @@ -2137,6 +2138,17 @@ class PluginApi { addFullPageSearchType(translationKey, searchTypeId, searchFunc) { registerFullPageSearchType(translationKey, searchTypeId, searchFunc); } + + /** + * Registers a hastag type and its corresponding class. + * This is used when generating CSS classes in the hashtag-css-generator. + * + * @param {string} type - The type of the hashtag. + * @param {Class} typeClass - The class of the hashtag type. + */ + registerHashtagType(type, typeClass) { + registerHashtagType(type, typeClass); + } } // from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number diff --git a/app/assets/javascripts/discourse/tests/acceptance/css-generator-test.js b/app/assets/javascripts/discourse/tests/acceptance/css-generator-test.js new file mode 100644 index 00000000000..6d1d7fc1a82 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/css-generator-test.js @@ -0,0 +1,32 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { visit } from "@ember/test-helpers"; +import { test } from "qunit"; + +acceptance("CSS Generator", function (needs) { + needs.user(); + needs.site({ + categories: [ + { id: 1, color: "ff0000" }, + { id: 2, color: "333" }, + { id: 4, color: "2B81AF", parentCategory: { id: 1 } }, + ], + }); + + test("category CSS variables are generated", async function (assert) { + await visit("/"); + const cssTag = document.querySelector("style#category-color-css-generator"); + assert.equal( + cssTag.innerHTML, + ":root {\n--category-1-color: #ff0000;\n--category-2-color: #333;\n--category-4-color: #2B81AF;\n}" + ); + }); + + test("hashtag CSS classes are generated", async function (assert) { + await visit("/"); + const cssTag = document.querySelector("style#hashtag-css-generator"); + assert.equal( + cssTag.innerHTML, + ".hashtag-color--category-1 {\n background: linear-gradient(90deg, var(--category-1-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--category-2 {\n background: linear-gradient(90deg, var(--category-2-color) 50%, var(--category-2-color) 50%);\n}\n.hashtag-color--category-4 {\n background: linear-gradient(90deg, var(--category-4-color) 50%, var(--category-1-color) 50%);\n}" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 79d7b6453ef..fa0473c17d3 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -52,6 +52,7 @@ import { cleanUpComposerUploadMarkdownResolver, cleanUpComposerUploadPreProcessor, } from "discourse/components/composer-editor"; +import { cleanUpHashtagTypeClasses } from "discourse/lib/hashtag-autocomplete"; import { resetLastEditNotificationClick } from "discourse/models/post-stream"; import { clearAuthMethods } from "discourse/models/login-method"; import { clearTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown"; @@ -190,6 +191,7 @@ export function testCleanup(container, app) { clearTopicFooterDropdowns(); clearTopicFooterButtons(); clearDesktopNotificationHandlers(); + cleanUpHashtagTypeClasses(); resetLastEditNotificationClick(); clearAuthMethods(); setTestPresence(true); @@ -211,6 +213,12 @@ export function testCleanup(container, app) { resetModelTransformers(); resetMentions(); cleanupTemporaryModuleRegistrations(); + cleanupCssGeneratorTags(); +} + +function cleanupCssGeneratorTags() { + document.querySelector("style#category-color-css-generator")?.remove(); + document.querySelector("style#hashtag-css-generator")?.remove(); } export function discourseModule(name, options) { diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js index da4898ae44b..b34b2a4d94d 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js @@ -4,6 +4,7 @@ import { bind } from "discourse-common/utils/decorators"; import { getOwner } from "discourse-common/lib/get-owner"; import { MENTION_KEYWORDS } from "discourse/plugins/chat/discourse/components/chat-message"; import { clearChatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; +import ChannelHashtagType from "discourse/plugins/chat/discourse/lib/hashtag-types/channel"; import { replaceIcon } from "discourse-common/lib/icon-library"; let _lastForcedRefreshAt; @@ -13,6 +14,7 @@ replaceIcon("d-chat", "comment"); export default { name: "chat-setup", + before: "hashtag-css-generator", initialize(container) { this.chatService = container.lookup("service:chat"); @@ -25,6 +27,8 @@ export default { } withPluginApi("0.12.1", (api) => { + api.registerHashtagType("channel", ChannelHashtagType); + api.registerChatComposerButton({ id: "chat-upload-btn", icon: "far-image", diff --git a/plugins/chat/assets/javascripts/discourse/lib/hashtag-types/channel.js b/plugins/chat/assets/javascripts/discourse/lib/hashtag-types/channel.js new file mode 100644 index 00000000000..ba679704b96 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/hashtag-types/channel.js @@ -0,0 +1,25 @@ +import HashtagTypeBase from "discourse/lib/hashtag-types/base"; +import { inject as service } from "@ember/service"; + +export default class ChannelHashtagType extends HashtagTypeBase { + @service chatChannelsManager; + @service currentUser; + + get type() { + return "channel"; + } + + get preloadedData() { + if (this.currentUser) { + return this.chatChannelsManager.publicMessageChannels; + } else { + return []; + } + } + + generateColorCssClasses(model) { + return [ + `.hashtag-color--${this.type}-${model.id} { color: var(--category-${model.chatable.id}-color); }`, + ]; + } +} diff --git a/plugins/chat/test/javascripts/acceptance/hashtag-css-generator-test.js b/plugins/chat/test/javascripts/acceptance/hashtag-css-generator-test.js new file mode 100644 index 00000000000..a122b4ea0c1 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/hashtag-css-generator-test.js @@ -0,0 +1,57 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { visit } from "@ember/test-helpers"; +import { test } from "qunit"; + +acceptance("Chat | Hashtag CSS Generator", function (needs) { + const category1 = { id: 1, color: "ff0000" }; + const category2 = { id: 2, color: "333" }; + const category3 = { id: 4, color: "2B81AF", parentCategory: { id: 1 } }; + + needs.settings({ chat_enabled: true }); + needs.user({ + has_chat_enabled: true, + chat_channels: { + public_channels: [ + { + id: 44, + chatable_id: 1, + chatable_type: "Category", + meta: { message_bus_last_ids: {} }, + current_user_membership: { following: true }, + chatable: category1, + }, + { + id: 74, + chatable_id: 2, + chatable_type: "Category", + meta: { message_bus_last_ids: {} }, + current_user_membership: { following: true }, + chatable: category2, + }, + { + id: 88, + chatable_id: 4, + chatable_type: "Category", + meta: { message_bus_last_ids: {} }, + current_user_membership: { following: true }, + chatable: category3, + }, + ], + direct_message_channels: [], + meta: { message_bus_last_ids: {} }, + }, + }); + needs.site({ + categories: [category1, category2, category3], + }); + + test("hashtag CSS classes are generated", async function (assert) { + await visit("/"); + const cssTag = document.querySelector("style#hashtag-css-generator"); + assert.equal( + cssTag.innerHTML, + + ".hashtag-color--category-1 {\n background: linear-gradient(90deg, var(--category-1-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--category-2 {\n background: linear-gradient(90deg, var(--category-2-color) 50%, var(--category-2-color) 50%);\n}\n.hashtag-color--category-4 {\n background: linear-gradient(90deg, var(--category-4-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--channel-44 { color: var(--category-1-color); }\n.hashtag-color--channel-74 { color: var(--category-2-color); }\n.hashtag-color--channel-88 { color: var(--category-4-color); }" + ); + }); +});