DEV: Clean up hashtag code (#25397)

* Delete dead code
* Split up hashtag-autocomplete into more logical modules
This commit is contained in:
Martin Brennan 2024-01-29 09:48:56 +10:00 committed by GitHub
parent ef87629526
commit c7860173c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 214 additions and 187 deletions

View File

@ -10,7 +10,7 @@ import { ajax } from "discourse/lib/ajax";
import { import {
fetchUnseenHashtagsInContext, fetchUnseenHashtagsInContext,
linkSeenHashtagsInContext, linkSeenHashtagsInContext,
} from "discourse/lib/hashtag-autocomplete"; } from "discourse/lib/hashtag-decorator";
import { import {
fetchUnseenMentions, fetchUnseenMentions,
linkSeenMentions, linkSeenMentions,

View File

@ -11,10 +11,8 @@ import { Promise } from "rsvp";
import InsertHyperlink from "discourse/components/modal/insert-hyperlink"; import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { SKIP } from "discourse/lib/autocomplete"; import { SKIP } from "discourse/lib/autocomplete";
import { import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
linkSeenHashtagsInContext, import { linkSeenHashtagsInContext } from "discourse/lib/hashtag-decorator";
setupHashtagAutocomplete,
} from "discourse/lib/hashtag-autocomplete";
import { wantsNewWindow } from "discourse/lib/intercept-click"; import { wantsNewWindow } from "discourse/lib/intercept-click";
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts"; import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
import { linkSeenMentions } from "discourse/lib/link-mentions"; import { linkSeenMentions } from "discourse/lib/link-mentions";

View File

@ -1,4 +1,4 @@
import { getHashtagTypeClasses } from "discourse/lib/hashtag-autocomplete"; import { getHashtagTypeClasses } from "discourse/lib/hashtag-type-registry";
export default { export default {
after: "category-color-css-generator", after: "category-color-css-generator",

View File

@ -1,4 +1,4 @@
import { decorateHashtags } from "discourse/lib/hashtag-autocomplete"; import { decorateHashtags } from "discourse/lib/hashtag-decorator";
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
export default { export default {

View File

@ -2,6 +2,17 @@ import { cancel } from "@ember/runloop";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete"; import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
import {
decorateHashtags as decorateHashtagsNew,
fetchUnseenHashtagsInContext as fetchUnseenHashtagsInContextNew,
generatePlaceholderHashtagHTML as generatePlaceholderHashtagHTMLNew,
linkSeenHashtagsInContext as linkSeenHashtagsInContextNew,
} from "discourse/lib/hashtag-decorator";
import {
cleanUpHashtagTypeClasses as cleanUpHashtagTypeClassesNew,
getHashtagTypeClasses as getHashtagTypeClassesNew,
registerHashtagType as registerHashtagTypeNew,
} from "discourse/lib/hashtag-type-registry";
import { emojiUnescape } from "discourse/lib/text"; import { emojiUnescape } from "discourse/lib/text";
import { import {
caretPosition, caretPosition,
@ -10,58 +21,88 @@ import {
} from "discourse/lib/utilities"; } from "discourse/lib/utilities";
import { INPUT_DELAY, isTesting } from "discourse-common/config/environment"; import { INPUT_DELAY, isTesting } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import domFromString from "discourse-common/lib/dom-from-string"; import deprecated from "discourse-common/lib/deprecated";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import { findRawTemplate } from "discourse-common/lib/raw-templates"; import { findRawTemplate } from "discourse-common/lib/raw-templates";
let hashtagTypeClasses = {}; // TODO (martin) Remove this once plugins have changed to use hashtag-decorator and
export function registerHashtagType(type, typeClassInstance) { // hashtag-type-registry imports
hashtagTypeClasses[type] = typeClassInstance; export function fetchUnseenHashtagsInContext() {
deprecated(
`fetchUnseenHashtagsInContext is has been moved to the module 'discourse/lib/hashtag-decorator'`,
{
id: "discourse.hashtag.fetchUnseenHashtagsInContext",
since: "3.2.0.beta5-dev",
dropFrom: "3.2.1",
} }
export function cleanUpHashtagTypeClasses() { );
hashtagTypeClasses = {}; return fetchUnseenHashtagsInContextNew(...arguments);
}
export function linkSeenHashtagsInContext() {
deprecated(
`linkSeenHashtagsInContext is has been moved to the module 'discourse/lib/hashtag-decorator'`,
{
id: "discourse.hashtag.linkSeenHashtagsInContext",
since: "3.2.0.beta5-dev",
dropFrom: "3.2.1",
}
);
return linkSeenHashtagsInContextNew(...arguments);
}
export function generatePlaceholderHashtagHTML() {
deprecated(
`generatePlaceholderHashtagHTML is has been moved to the module 'discourse/lib/hashtag-decorator'`,
{
id: "discourse.hashtag.generatePlaceholderHashtagHTML",
since: "3.2.0.beta5-dev",
dropFrom: "3.2.1",
}
);
return generatePlaceholderHashtagHTMLNew(...arguments);
}
export function decorateHashtags() {
deprecated(
`decorateHashtags is has been moved to the module 'discourse/lib/hashtag-decorator'`,
{
id: "discourse.hashtag.decorateHashtags",
since: "3.2.0.beta5-dev",
dropFrom: "3.2.1",
}
);
return decorateHashtagsNew(...arguments);
} }
export function getHashtagTypeClasses() { export function getHashtagTypeClasses() {
return hashtagTypeClasses; deprecated(
`getHashtagTypeClasses is has been moved to the module 'discourse/lib/hashtag-type-registry'`,
{
id: "discourse.hashtag.getHashtagTypeClasses",
since: "3.2.0.beta5-dev",
dropFrom: "3.2.1",
} }
export function decorateHashtags(element, site) {
element.querySelectorAll(".hashtag-cooked").forEach((hashtagEl) => {
// Replace the empty icon placeholder span with actual icon HTML.
const iconPlaceholderEl = hashtagEl.querySelector(
".hashtag-icon-placeholder"
); );
const hashtagType = hashtagEl.dataset.type; return getHashtagTypeClassesNew(...arguments);
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]);
} }
export function registerHashtagType() {
// Add an aria-label to the hashtag element so that screen readers deprecated(
// can read the hashtag text. `registerHashtagType is has been moved to the module 'discourse/lib/hashtag-type-registry'`,
hashtagEl.setAttribute("aria-label", `${hashtagEl.innerText.trim()}`); {
}); id: "discourse.hashtag.registerHashtagType",
since: "3.2.0.beta5-dev",
dropFrom: "3.2.1",
} }
);
export function generatePlaceholderHashtagHTML(type, spanEl, data) { return registerHashtagTypeNew(...arguments);
// NOTE: When changing the HTML structure here, you must also change }
// it in the hashtag-autocomplete markdown rule, and vice-versa. export function cleanUpHashtagTypeClasses() {
const link = document.createElement("a"); deprecated(
link.classList.add("hashtag-cooked"); `cleanUpHashtagTypeClasses is has been moved to the module 'discourse/lib/hashtag-type-registry'`,
link.href = data.relative_url; {
link.dataset.type = type; id: "discourse.hashtag.cleanUpHashtagTypeClasses",
link.dataset.id = data.id; since: "3.2.0.beta5-dev",
link.dataset.slug = data.slug; dropFrom: "3.2.1",
const hashtagTypeClass = new getHashtagTypeClasses()[type]; }
link.innerHTML = `${hashtagTypeClass.generateIconHTML( );
data return cleanUpHashtagTypeClassesNew(...arguments);
)}<span>${emojiUnescape(data.text)}</span>`;
spanEl.replaceWith(link);
} }
/** /**
@ -107,57 +148,6 @@ export function hashtagTriggerRule(textarea) {
return true; return true;
} }
const checkedHashtags = new Set();
let seenHashtags = {};
// NOTE: For future maintainers, the hashtag lookup here does not take
// into account mixed contexts -- for instance, a chat quote inside a post
// or a post quote inside a chat message, so this may
// not provide an accurate priority lookup for hashtags without a ::type suffix in those
// cases.
export function fetchUnseenHashtagsInContext(
contextualHashtagConfiguration,
slugs
) {
return ajax("/hashtags", {
data: { slugs, order: contextualHashtagConfiguration },
}).then((response) => {
Object.keys(response).forEach((type) => {
seenHashtags[type] = seenHashtags[type] || {};
response[type].forEach((item) => {
seenHashtags[type][item.ref] = seenHashtags[type][item.ref] || item;
});
});
slugs.forEach(checkedHashtags.add, checkedHashtags);
});
}
export function linkSeenHashtagsInContext(
contextualHashtagConfiguration,
elem
) {
const hashtagSpans = [...(elem?.querySelectorAll("span.hashtag-raw") || [])];
if (hashtagSpans.length === 0) {
return [];
}
const slugs = [
...hashtagSpans.map((span) => span.innerText.replace("#", "")),
];
hashtagSpans.forEach((hashtagSpan, index) => {
_findAndReplaceSeenHashtagPlaceholder(
slugs[index],
contextualHashtagConfiguration,
hashtagSpan
);
});
return slugs
.map((slug) => slug.toLowerCase())
.uniq()
.filter((slug) => !checkedHashtags.has(slug));
}
function _setup( function _setup(
contextualHashtagConfiguration, contextualHashtagConfiguration,
$textArea, $textArea,
@ -236,7 +226,7 @@ function _searchRequest(term, contextualHashtagConfiguration, resultFunc) {
// Convert :emoji: in the result text to HTML safely. // Convert :emoji: in the result text to HTML safely.
result.text = htmlSafe(emojiUnescape(escapeExpression(result.text))); result.text = htmlSafe(emojiUnescape(escapeExpression(result.text)));
const hashtagType = getHashtagTypeClasses()[result.type]; const hashtagType = getHashtagTypeClassesNew()[result.type];
result.icon = hashtagType.generateIconHTML({ result.icon = hashtagType.generateIconHTML({
icon: result.icon, icon: result.icon,
id: result.id, id: result.id,
@ -249,17 +239,3 @@ function _searchRequest(term, contextualHashtagConfiguration, resultFunc) {
}); });
return currentSearch; return currentSearch;
} }
function _findAndReplaceSeenHashtagPlaceholder(
slugRef,
contextualHashtagConfiguration,
hashtagSpan
) {
contextualHashtagConfiguration.forEach((type) => {
// Replace raw span for the hashtag with a cooked one
const matchingSeenHashtag = seenHashtags[type]?.[slugRef];
if (matchingSeenHashtag) {
generatePlaceholderHashtagHTML(type, hashtagSpan, matchingSeenHashtag);
}
});
}

View File

@ -0,0 +1,109 @@
import { ajax } from "discourse/lib/ajax";
import { getHashtagTypeClasses } from "discourse/lib/hashtag-type-registry";
import { emojiUnescape } from "discourse/lib/text";
import domFromString from "discourse-common/lib/dom-from-string";
const checkedHashtags = new Set();
let seenHashtags = {};
// NOTE: For future maintainers, the hashtag lookup here does not take
// into account mixed contexts -- for instance, a chat quote inside a post
// or a post quote inside a chat message, so this may
// not provide an accurate priority lookup for hashtags without a ::type suffix in those
// cases.
export function fetchUnseenHashtagsInContext(
contextualHashtagConfiguration,
slugs
) {
return ajax("/hashtags", {
data: { slugs, order: contextualHashtagConfiguration },
}).then((response) => {
Object.keys(response).forEach((type) => {
seenHashtags[type] = seenHashtags[type] || {};
response[type].forEach((item) => {
seenHashtags[type][item.ref] = seenHashtags[type][item.ref] || item;
});
});
slugs.forEach(checkedHashtags.add, checkedHashtags);
});
}
export function linkSeenHashtagsInContext(
contextualHashtagConfiguration,
elem
) {
const hashtagSpans = [...(elem?.querySelectorAll("span.hashtag-raw") || [])];
if (hashtagSpans.length === 0) {
return [];
}
const slugs = [
...hashtagSpans.map((span) => span.innerText.replace("#", "")),
];
hashtagSpans.forEach((hashtagSpan, index) => {
_findAndReplaceSeenHashtagPlaceholder(
slugs[index],
contextualHashtagConfiguration,
hashtagSpan
);
});
return slugs
.map((slug) => slug.toLowerCase())
.uniq()
.filter((slug) => !checkedHashtags.has(slug));
}
function _findAndReplaceSeenHashtagPlaceholder(
slugRef,
contextualHashtagConfiguration,
hashtagSpan
) {
contextualHashtagConfiguration.forEach((type) => {
// Replace raw span for the hashtag with a cooked one
const matchingSeenHashtag = seenHashtags[type]?.[slugRef];
if (matchingSeenHashtag) {
generatePlaceholderHashtagHTML(type, hashtagSpan, matchingSeenHashtag);
}
});
}
export function generatePlaceholderHashtagHTML(type, spanEl, data) {
// NOTE: When changing the HTML structure here, you must also change
// it in the hashtag-autocomplete markdown rule, and vice-versa.
const link = document.createElement("a");
link.classList.add("hashtag-cooked");
link.href = data.relative_url;
link.dataset.type = type;
link.dataset.id = data.id;
link.dataset.slug = data.slug;
const hashtagTypeClass = new getHashtagTypeClasses()[type];
link.innerHTML = `${hashtagTypeClass.generateIconHTML(
data
)}<span>${emojiUnescape(data.text)}</span>`;
spanEl.replaceWith(link);
}
export function decorateHashtags(element, site) {
element.querySelectorAll(".hashtag-cooked").forEach((hashtagEl) => {
// Replace the empty icon placeholder span with actual icon HTML.
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]);
}
// Add an aria-label to the hashtag element so that screen readers
// can read the hashtag text.
hashtagEl.setAttribute("aria-label", `${hashtagEl.innerText.trim()}`);
});
}

View File

@ -0,0 +1,10 @@
let hashtagTypeClasses = {};
export function registerHashtagType(type, typeClassInstance) {
hashtagTypeClasses[type] = typeClassInstance;
}
export function cleanUpHashtagTypeClasses() {
hashtagTypeClasses = {};
}
export function getHashtagTypeClasses() {
return hashtagTypeClasses;
}

View File

@ -1,66 +0,0 @@
// TODO (martin) Delete this after core PR and any other PRs that depend
// on this file (e.g. discourse-encrypt) are merged.
import $ from "jquery";
import { ajax } from "discourse/lib/ajax";
import { replaceSpan } from "discourse/lib/category-hashtags";
import { TAG_HASHTAG_POSTFIX } from "discourse/lib/tag-hashtags";
import deprecated from "discourse-common/lib/deprecated";
const categoryHashtags = {};
const tagHashtags = {};
const checkedHashtags = new Set();
export function linkSeenHashtags(elem) {
if (elem instanceof $) {
elem = elem[0];
deprecated("linkSeenHashtags now expects a DOM node as first parameter", {
since: "2.8.0.beta7",
dropFrom: "2.9.0.beta1",
id: "discourse.link-hashtags.dom-node",
});
}
const hashtags = [...(elem?.querySelectorAll("span.hashtag") || [])];
if (hashtags.length === 0) {
return [];
}
const slugs = [...hashtags.map((hashtag) => hashtag.innerText.slice(1))];
hashtags.forEach((hashtag, index) => {
let slug = slugs[index];
const hasTagSuffix = slug.endsWith(TAG_HASHTAG_POSTFIX);
if (hasTagSuffix) {
slug = slug.slice(0, slug.length - TAG_HASHTAG_POSTFIX.length);
}
const lowerSlug = slug.toLowerCase();
if (categoryHashtags[lowerSlug] && !hasTagSuffix) {
replaceSpan($(hashtag), slug, categoryHashtags[lowerSlug]);
} else if (tagHashtags[lowerSlug]) {
replaceSpan($(hashtag), slug, tagHashtags[lowerSlug]);
}
});
return slugs
.map((slug) => slug.toLowerCase())
.uniq()
.filter((slug) => !checkedHashtags.has(slug));
}
export function fetchUnseenHashtags(slugs) {
return ajax("/hashtags", {
data: { slugs },
}).then((response) => {
Object.keys(response.categories).forEach((slug) => {
categoryHashtags[slug] = response.categories[slug];
});
Object.keys(response.tags).forEach((slug) => {
tagHashtags[slug] = response.tags[slug];
});
slugs.forEach(checkedHashtags.add, checkedHashtags);
});
}

View File

@ -46,7 +46,7 @@ import { addBeforeAuthCompleteCallback } from "discourse/instance-initializers/a
import { addPopupMenuOption } from "discourse/lib/composer/custom-popup-menu-options"; import { addPopupMenuOption } from "discourse/lib/composer/custom-popup-menu-options";
import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications"; import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications";
import { downloadCalendar } from "discourse/lib/download-calendar"; import { downloadCalendar } from "discourse/lib/download-calendar";
import { registerHashtagType } from "discourse/lib/hashtag-autocomplete"; import { registerHashtagType } from "discourse/lib/hashtag-type-registry";
import { import {
registerHighlightJSLanguage, registerHighlightJSLanguage,
registerHighlightJSPlugin, registerHighlightJSPlugin,

View File

@ -31,7 +31,7 @@ import { resetUsernameDecorators } from "discourse/helpers/decorate-username-sel
import { resetBeforeAuthCompleteCallbacks } from "discourse/instance-initializers/auth-complete"; import { resetBeforeAuthCompleteCallbacks } from "discourse/instance-initializers/auth-complete";
import { clearPopupMenuOptions } from "discourse/lib/composer/custom-popup-menu-options"; import { clearPopupMenuOptions } from "discourse/lib/composer/custom-popup-menu-options";
import { clearDesktopNotificationHandlers } from "discourse/lib/desktop-notifications"; import { clearDesktopNotificationHandlers } from "discourse/lib/desktop-notifications";
import { cleanUpHashtagTypeClasses } from "discourse/lib/hashtag-autocomplete"; import { cleanUpHashtagTypeClasses } from "discourse/lib/hashtag-type-registry";
import { import {
clearExtraKeyboardShortcutHelp, clearExtraKeyboardShortcutHelp,
PLATFORM_KEY_MODIFIER, PLATFORM_KEY_MODIFIER,

View File

@ -1,7 +1,7 @@
import $ from "jquery"; import $ from "jquery";
import { spinnerHTML } from "discourse/helpers/loading-spinner"; import { spinnerHTML } from "discourse/helpers/loading-spinner";
import { decorateGithubOneboxBody } from "discourse/instance-initializers/onebox-decorators"; import { decorateGithubOneboxBody } from "discourse/instance-initializers/onebox-decorators";
import { decorateHashtags } from "discourse/lib/hashtag-autocomplete"; import { decorateHashtags } from "discourse/lib/hashtag-decorator";
import highlightSyntax from "discourse/lib/highlight-syntax"; import highlightSyntax from "discourse/lib/highlight-syntax";
import loadScript from "discourse/lib/load-script"; import loadScript from "discourse/lib/load-script";
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";

View File

@ -1,4 +1,4 @@
import { generatePlaceholderHashtagHTML } from "discourse/lib/hashtag-autocomplete"; import { generatePlaceholderHashtagHTML } from "discourse/lib/hashtag-decorator";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
const domParser = new DOMParser(); const domParser = new DOMParser();