Skip to content

Commit

Permalink
FIX: Do not cook icon with hashtags (#21676)
Browse files Browse the repository at this point in the history
This commit makes some fundamental changes to how hashtag cooking and
icon generation works in the new experimental hashtag autocomplete mode.
Previously we cooked the appropriate SVG icon with the cooked hashtag,
though this has proved inflexible especially for theming purposes.

Instead, we now cook a data-ID attribute with the hashtag and add a new
span as an icon placeholder. This is replaced on the client side with an
icon (or a square span in the case of categories) on the client side via
the decorateCooked API for posts and chat messages.

This client side logic uses the generated hashtag, category, and channel
CSS classes added in a previous commit.

This is missing changes to the sidebar to use the new generated CSS
classes and also colors and the split square for categories in the
hashtag autocomplete menu -- I will tackle this in a separate PR so it
is clearer.
  • Loading branch information
martin-brennan committed May 23, 2023
1 parent ecb9a27 commit 0b3cf83
Show file tree
Hide file tree
Showing 36 changed files with 235 additions and 102 deletions.
Expand Up @@ -25,8 +25,7 @@ export default {

let generatedCssClasses = [];

Object.values(getHashtagTypeClasses()).forEach((hashtagTypeClass) => {
const hashtagType = new hashtagTypeClass(container);
Object.values(getHashtagTypeClasses()).forEach((hashtagType) => {
hashtagType.preloadedData.forEach((model) => {
generatedCssClasses = generatedCssClasses.concat(
hashtagType.generateColorCssClasses(model)
Expand Down
@@ -0,0 +1,24 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import { replaceHashtagIconPlaceholder } from "discourse/lib/hashtag-autocomplete";

export default {
name: "hashtag-post-decorations",
after: "hashtag-css-generator",

initialize(container) {
const siteSettings = container.lookup("service:site-settings");
const site = container.lookup("service:site");

withPluginApi("0.8.7", (api) => {
if (siteSettings.enable_experimental_hashtag_autocomplete) {
api.decorateCookedElement(
(post) => replaceHashtagIconPlaceholder(post, site),
{
onlyStream: true,
id: "hashtag-icons",
}
);
}
});
},
};
Expand Up @@ -6,10 +6,10 @@ export default {
name: "register-hashtag-types",
before: "hashtag-css-generator",

initialize() {
initialize(container) {
withPluginApi("0.8.7", (api) => {
api.registerHashtagType("category", CategoryHashtagType);
api.registerHashtagType("tag", TagHashtagType);
api.registerHashtagType("category", new CategoryHashtagType(container));
api.registerHashtagType("tag", new TagHashtagType(container));
});
},
};
2 changes: 1 addition & 1 deletion app/assets/javascripts/discourse/app/lib/click-track.js
Expand Up @@ -46,7 +46,7 @@ export function isValidLink(link) {
return (
link.classList.contains("track-link") ||
!link.closest(
".hashtag, .hashtag-cooked, .badge-category, .onebox-result, .onebox-body"
".hashtag, .hashtag-cooked, .hashtag-icon-placeholder, .badge-category, .onebox-result, .onebox-body"
)
);
}
Expand Down
37 changes: 30 additions & 7 deletions app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js
@@ -1,4 +1,5 @@
import { findRawTemplate } from "discourse-common/lib/raw-templates";
import domFromString from "discourse-common/lib/dom-from-string";
import discourseLater from "discourse-common/lib/later";
import { INPUT_DELAY, isTesting } from "discourse-common/config/environment";
import { cancel } from "@ember/runloop";
Expand All @@ -15,15 +16,33 @@ import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe } from "@ember/template";

let hashtagTypeClasses = {};
export function registerHashtagType(type, typeClass) {
hashtagTypeClasses[type] = typeClass;
export function registerHashtagType(type, typeClassInstance) {
hashtagTypeClasses[type] = typeClassInstance;
}
export function cleanUpHashtagTypeClasses() {
hashtagTypeClasses = {};
}
export function getHashtagTypeClasses() {
return hashtagTypeClasses;
}
export function replaceHashtagIconPlaceholder(element, site) {
element.querySelectorAll(".hashtag-cooked").forEach((hashtagEl) => {
const iconPlaceholderEl = hashtagEl.querySelector(
".hashtag-icon-placeholder"
);
const hashtagType = hashtagEl.dataset.type;
const hashtagTypeClass = getHashtagTypeClasses()[hashtagType];
if (iconPlaceholderEl && hashtagTypeClass) {
const hashtagIconHTML = hashtagTypeClass
.generateIconHTML({
icon: site.hashtag_icons[hashtagType],
id: hashtagEl.dataset.id,
})
.trim();
iconPlaceholderEl.replaceWith(domFromString(hashtagIconHTML)[0]);
}
});
}

/**
* Sets up a textarea using the jQuery autocomplete plugin, specifically
Expand Down Expand Up @@ -216,12 +235,12 @@ function _searchRequest(term, contextualHashtagConfiguration, resultFunc) {
data: { term, order: contextualHashtagConfiguration },
});
currentSearch
.then((r) => {
r.results?.forEach((result) => {
.then((response) => {
response.results?.forEach((result) => {
// Convert :emoji: in the result text to HTML safely.
result.text = htmlSafe(emojiUnescape(escapeExpression(result.text)));
});
resultFunc(r.results || CANCELLED_STATUS);
resultFunc(response.results || CANCELLED_STATUS);
})
.finally(() => {
currentSearch = null;
Expand All @@ -235,7 +254,7 @@ function _findAndReplaceSeenHashtagPlaceholder(
hashtagSpan
) {
contextualHashtagConfiguration.forEach((type) => {
// replace raw span for the hashtag with a cooked one
// Replace raw span for the hashtag with a cooked one
const matchingSeenHashtag = seenHashtags[type]?.[slugRef];
if (matchingSeenHashtag) {
// NOTE: When changing the HTML structure here, you must also change
Expand All @@ -244,8 +263,12 @@ function _findAndReplaceSeenHashtagPlaceholder(
link.classList.add("hashtag-cooked");
link.href = matchingSeenHashtag.relative_url;
link.dataset.type = type;
link.dataset.id = matchingSeenHashtag.id;
link.dataset.slug = matchingSeenHashtag.slug;
link.innerHTML = `<svg class="fa d-icon d-icon-${matchingSeenHashtag.icon} svg-icon svg-node"><use href="#${matchingSeenHashtag.icon}"></use></svg><span>${matchingSeenHashtag.text}</span>`;
const hashtagType = new getHashtagTypeClasses()[type];
link.innerHTML = `${hashtagType.generateIconHTML(
matchingSeenHashtag
)}<span>${emojiUnescape(matchingSeenHashtag.text)}</span>`;
hashtagSpan.replaceWith(link);
}
});
Expand Down
Expand Up @@ -16,4 +16,8 @@ export default class HashtagTypeBase {
generateColorCssClasses() {
throw "not implemented";
}

generateIconHTML() {
throw "not implemented";
}
}
16 changes: 10 additions & 6 deletions app/assets/javascripts/discourse/app/lib/hashtag-types/category.js
Expand Up @@ -12,21 +12,25 @@ export default class CategoryHashtagType extends HashtagTypeBase {
return this.site.categories || [];
}

generateColorCssClasses(model) {
generateColorCssClasses(category) {
const generatedCssClasses = [];
const backgroundGradient = [`var(--category-${model.id}-color) 50%`];
if (model.parentCategory) {
const backgroundGradient = [`var(--category-${category.id}-color) 50%`];
if (category.parentCategory) {
backgroundGradient.push(
`var(--category-${model.parentCategory.id}-color) 50%`
`var(--category-${category.parentCategory.id}-color) 50%`
);
} else {
backgroundGradient.push(`var(--category-${model.id}-color) 50%`);
backgroundGradient.push(`var(--category-${category.id}-color) 50%`);
}

generatedCssClasses.push(`.hashtag-color--category-${model.id} {
generatedCssClasses.push(`.hashtag-color--category-${category.id} {
background: linear-gradient(90deg, ${backgroundGradient.join(", ")});
}`);

return generatedCssClasses;
}

generateIconHTML(hashtag) {
return `<span class="hashtag-category-badge hashtag-color--${this.type}-${hashtag.id}"></span>`;
}
}
7 changes: 7 additions & 0 deletions app/assets/javascripts/discourse/app/lib/hashtag-types/tag.js
@@ -1,4 +1,5 @@
import HashtagTypeBase from "./base";
import { iconHTML } from "discourse-common/lib/icon-library";

export default class TagHashtagType extends HashtagTypeBase {
get type() {
Expand All @@ -12,4 +13,10 @@ export default class TagHashtagType extends HashtagTypeBase {
generateColorCssClasses() {
return [];
}

generateIconHTML(hashtag) {
return iconHTML(hashtag.icon, {
class: `hashtag-color--${this.type}-${hashtag.id}`,
});
}
}
7 changes: 4 additions & 3 deletions app/assets/javascripts/discourse/app/lib/plugin-api.js
Expand Up @@ -2226,10 +2226,11 @@ class PluginApi {
* 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.
* @param {Class} typeClassInstance - The initialized class of the hashtag type, which
* needs the `container`.
*/
registerHashtagType(type, typeClass) {
registerHashtagType(type, typeClass);
registerHashtagType(type, typeClassInstance) {
registerHashtagType(type, typeClassInstance);
}
}

Expand Down
Expand Up @@ -699,7 +699,7 @@ export default {
],
displayed_about_plugin_stat_groups: ["chat_messages"],
hashtag_configurations: { "topic-composer": ["category", "tag"] },
hashtag_icons: ["folder", "tag"],
hashtag_icons: { "category": "folder", "tag": "tag" },
anonymous_sidebar_sections: [
{
id: 111,
Expand Down
Expand Up @@ -29,6 +29,7 @@ function addHashtag(buffer, matches, state) {
["href", result.relative_url],
["data-type", result.type],
["data-slug", result.slug],
["data-id", result.id],
];

// Most cases these will be the exact same, one standout is categories
Expand All @@ -40,20 +41,7 @@ function addHashtag(buffer, matches, state) {
token.block = false;
buffer.push(token);

token = new state.Token("svg_open", "svg", 1);
token.block = false;
token.attrs = [
["class", `fa d-icon d-icon-${result.icon} svg-icon svg-node`],
];
buffer.push(token);

token = new state.Token("use_open", "use", 1);
token.block = false;
token.attrs = [["href", `#${result.icon}`]];
buffer.push(token);

buffer.push(new state.Token("use_close", "use", -1));
buffer.push(new state.Token("svg_close", "svg", -1));
addIconPlaceholder(buffer, state);

token = new state.Token("span_open", "span", 1);
token.block = false;
Expand Down Expand Up @@ -82,24 +70,22 @@ function addHashtag(buffer, matches, state) {
}
}

export function setup(helper) {
const opts = helper.getOptions();

// we do this because plugins can register their own hashtag data
// sources which specify an icon, and each icon must be allowlisted
// or it will not render in the markdown pipeline
const hashtagIconAllowList = opts.hashtagIcons
? opts.hashtagIcons
.concat(["hashtag"])
.map((icon) => {
return [
`svg[class=fa d-icon d-icon-${icon} svg-icon svg-node]`,
`use[href=#${icon}]`,
];
})
.flat()
: [];
// The svg icon is not baked into the HTML because we want
// to be able to use icon replacement via renderIcon, and
// because different hashtag types may render icons/CSS
// classes differently.
//
// Instead, the UI will dynamically replace these where hashtags
// are rendered, like within posts, using decorateCooked* APIs.
function addIconPlaceholder(buffer, state) {
const token = new state.Token("span_open", "span", 1);
token.block = false;
token.attrs = [["class", "hashtag-icon-placeholder"]];
buffer.push(token);
buffer.push(new state.Token("span_close", "span", -1));
}

export function setup(helper) {
helper.registerPlugin((md) => {
if (
md.options.discourse.limitedSiteSettings
Expand All @@ -114,13 +100,13 @@ export function setup(helper) {
}
});

helper.allowList(
hashtagIconAllowList.concat([
"a.hashtag-cooked",
"span.hashtag-raw",
"a[data-type]",
"a[data-slug]",
"a[data-ref]",
])
);
helper.allowList([
"a.hashtag-cooked",
"span.hashtag-raw",
"span.hashtag-icon-placeholder",
"a[data-type]",
"a[data-slug]",
"a[data-ref]",
"a[data-id]",
]);
}
Expand Up @@ -43,6 +43,7 @@ const NONE = 0;
const MENTION = 1;
const HASHTAG_LINK = 2;
const HASHTAG_SPAN = 3;
const HASHTAG_ICON_SPAN = 4;

export function setup(helper) {
const opts = helper.getOptions();
Expand Down Expand Up @@ -97,6 +98,7 @@ export function setup(helper) {
// We scan once to mark tokens that must be skipped because they are
// mentions or hashtags
let lastType = NONE;
let currentType = NONE;
for (let i = 0; i < tokens.length; ++i) {
const currentToken = tokens[i];

Expand All @@ -109,22 +111,36 @@ export function setup(helper) {
currentToken.attrs.some(
(attr) =>
attr[0] === "class" &&
(attr[1].includes("hashtag") ||
attr[1].includes("hashtag-cooked"))
(attr[1] === "hashtag" ||
attr[1] === "hashtag-cooked" ||
attr[1] === "hashtag-raw")
)
) {
lastType =
currentToken.type === "link_open" ? HASHTAG_LINK : HASHTAG_SPAN;
}

if (
currentToken.type === "span_open" &&
currentToken.attrs &&
currentToken.attrs.some(
(attr) =>
attr[0] === "class" && attr[1] === "hashtag-icon-placeholder"
)
) {
currentType = HASHTAG_ICON_SPAN;
}

if (lastType !== NONE) {
currentToken.skipReplace = true;
}

if (
(lastType === MENTION && currentToken.type === "mention_close") ||
(lastType === HASHTAG_LINK && currentToken.type === "link_close") ||
(lastType === HASHTAG_SPAN && currentToken.type === "span_close")
(lastType === HASHTAG_SPAN &&
currentToken.type === "span_close" &&
currentType !== HASHTAG_ICON_SPAN)
) {
lastType = NONE;
}
Expand Down
8 changes: 8 additions & 0 deletions app/assets/stylesheets/common/components/hashtag.scss
Expand Up @@ -37,6 +37,14 @@ a.hashtag-cooked {
svg {
display: inline;
}

.hashtag-category-badge {
flex: 0 0 auto;
width: 9px;
height: 9px;
margin-right: 5px;
display: inline-block;
}
}

.hashtag-autocomplete {
Expand Down
2 changes: 1 addition & 1 deletion app/serializers/site_serializer.rb
Expand Up @@ -241,7 +241,7 @@ def hashtag_configurations
end

def hashtag_icons
HashtagAutocompleteService.data_source_icons
HashtagAutocompleteService.data_source_icon_map
end

def displayed_about_plugin_stat_groups
Expand Down

0 comments on commit 0b3cf83

Please sign in to comment.