mirror of
https://github.com/discourse/discourse.git
synced 2024-12-01 11:53:44 +08:00
967010e545
This feature will allow sites to define which emoji are not allowed. Emoji in this list should be excluded from the set we show in the core emoji picker used in the composer for posts when emoji are enabled. And they should not be allowed to be chosen to be added to messages or as reactions in chat. This feature prevents denied emoji from appearing in the following scenarios: - topic title and page title - private messages (topic title and body) - inserting emojis into a chat - reacting to chat messages - using the emoji picker (composer, user status etc) - using search within emoji picker It also takes into account the various ways that emojis can be accessed, such as: - emoji autocomplete suggestions - emoji favourites (auto populates when adding to emoji deny list for example) - emoji inline translations - emoji skintones (ie. for certain hand gestures)
390 lines
8.8 KiB
JavaScript
390 lines
8.8 KiB
JavaScript
import { buildEmojiUrl, isCustomEmoji } from "pretty-text/emoji";
|
|
import { translations } from "pretty-text/emoji/data";
|
|
|
|
const MAX_NAME_LENGTH = 60;
|
|
|
|
let translationTree = null;
|
|
|
|
export function resetTranslationTree() {
|
|
translationTree = null;
|
|
}
|
|
|
|
// This allows us to efficiently search for aliases
|
|
// We build a data structure that allows us to quickly
|
|
// search through our N next chars to see if any match
|
|
// one of our alias emojis.
|
|
function buildTranslationTree(customEmojiTranslation) {
|
|
let tree = [];
|
|
let lastNode;
|
|
|
|
const allTranslations = Object.assign(
|
|
{},
|
|
translations,
|
|
customEmojiTranslation || {}
|
|
);
|
|
|
|
Object.keys(allTranslations).forEach((key) => {
|
|
let node = tree;
|
|
|
|
for (let i = 0; i < key.length; i++) {
|
|
let code = key.charCodeAt(i);
|
|
let found = false;
|
|
|
|
for (let j = 0; j < node.length; j++) {
|
|
if (node[j][0] === code) {
|
|
node = node[j][1];
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
// code, children, value
|
|
let tmp = [code, []];
|
|
node.push(tmp);
|
|
lastNode = tmp;
|
|
node = tmp[1];
|
|
}
|
|
}
|
|
|
|
lastNode[2] = allTranslations[key];
|
|
});
|
|
|
|
return tree;
|
|
}
|
|
|
|
function imageFor(code, opts) {
|
|
code = code.toLowerCase();
|
|
const url = buildEmojiUrl(code, opts);
|
|
if (url) {
|
|
const title = `:${code}:`;
|
|
const classes = isCustomEmoji(code, opts) ? "emoji emoji-custom" : "emoji";
|
|
return { url, title, classes };
|
|
}
|
|
}
|
|
|
|
function getEmojiName(content, pos, state, inlineEmoji) {
|
|
if (content.charCodeAt(pos) !== 58) {
|
|
return;
|
|
}
|
|
|
|
if (pos > 0) {
|
|
let prev = content.charCodeAt(pos - 1);
|
|
if (
|
|
!inlineEmoji &&
|
|
!state.md.utils.isSpace(prev) &&
|
|
!state.md.utils.isPunctChar(String.fromCharCode(prev))
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
pos++;
|
|
if (content.charCodeAt(pos) === 58) {
|
|
return;
|
|
}
|
|
|
|
let length = 0;
|
|
while (length < MAX_NAME_LENGTH) {
|
|
length++;
|
|
|
|
if (content.charCodeAt(pos + length) === 58) {
|
|
// check for t2-t6
|
|
if (content.slice(pos + length + 1, pos + length + 4).match(/t[2-6]:/)) {
|
|
length += 3;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (pos + length > content.length) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (length === MAX_NAME_LENGTH) {
|
|
return;
|
|
}
|
|
|
|
return content.slice(pos, pos + length);
|
|
}
|
|
|
|
// straight forward :smile: to emoji image
|
|
function getEmojiTokenByName(name, state) {
|
|
let info;
|
|
if ((info = imageFor(name, state.md.options.discourse))) {
|
|
let token = new state.Token("emoji", "img", 0);
|
|
token.attrs = [
|
|
["src", info.url],
|
|
["title", info.title],
|
|
["class", info.classes],
|
|
["alt", info.title],
|
|
["loading", "lazy"],
|
|
["width", "20"],
|
|
["height", "20"],
|
|
];
|
|
|
|
return token;
|
|
}
|
|
}
|
|
|
|
function getEmojiTokenByTranslation(
|
|
content,
|
|
pos,
|
|
state,
|
|
customEmojiTranslation
|
|
) {
|
|
translationTree =
|
|
translationTree || buildTranslationTree(customEmojiTranslation);
|
|
|
|
let t = translationTree;
|
|
let start = pos;
|
|
let found = null;
|
|
|
|
while (t.length > 0 && pos < content.length) {
|
|
let matched = false;
|
|
let code = content.charCodeAt(pos);
|
|
|
|
for (let i = 0; i < t.length; i++) {
|
|
if (t[i][0] === code) {
|
|
matched = true;
|
|
found = t[i][2];
|
|
t = t[i][1];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!matched) {
|
|
return;
|
|
}
|
|
|
|
pos++;
|
|
}
|
|
|
|
if (!found) {
|
|
return;
|
|
}
|
|
|
|
// quick boundary check
|
|
if (start > 0) {
|
|
let leading = content.charAt(start - 1);
|
|
if (
|
|
!state.md.utils.isSpace(leading.charCodeAt(0)) &&
|
|
!state.md.utils.isPunctChar(leading)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// check trailing for punct or space
|
|
if (pos < content.length) {
|
|
let trailing = content.charCodeAt(pos);
|
|
if (!state.md.utils.isSpace(trailing)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
let token = getEmojiTokenByName(found, state);
|
|
if (token) {
|
|
return { pos, token };
|
|
}
|
|
}
|
|
|
|
function applyEmoji(
|
|
content,
|
|
state,
|
|
emojiUnicodeReplacer,
|
|
enableShortcuts,
|
|
inlineEmoji,
|
|
customEmojiTranslation,
|
|
watchedWordsReplacer,
|
|
emojiDenyList
|
|
) {
|
|
let result = null;
|
|
let start = 0;
|
|
|
|
if (emojiUnicodeReplacer) {
|
|
content = emojiUnicodeReplacer(content);
|
|
}
|
|
|
|
if (watchedWordsReplacer) {
|
|
const watchedWordRegex = Object.keys(watchedWordsReplacer);
|
|
|
|
watchedWordRegex.forEach((watchedWord) => {
|
|
if (content?.match(watchedWord)) {
|
|
const regex = new RegExp(watchedWord, "g");
|
|
const matches = content.match(regex);
|
|
const replacement = watchedWordsReplacer[watchedWord].replacement;
|
|
|
|
matches.forEach(() => {
|
|
const matchingRegex = regex.exec(content);
|
|
if (matchingRegex) {
|
|
content = content.replace(matchingRegex[1], replacement);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// prevent denied emoji and aliases from being rendered
|
|
if (emojiDenyList?.length > 0) {
|
|
emojiDenyList.forEach((emoji) => {
|
|
if (content?.match(emoji)) {
|
|
const regex = new RegExp(`:${emoji}:`, "g");
|
|
content = content.replace(regex, "");
|
|
}
|
|
});
|
|
}
|
|
|
|
let end = content.length;
|
|
|
|
for (let i = 0; i < content.length - 1; i++) {
|
|
let offset = 0;
|
|
let token = null;
|
|
|
|
const name = getEmojiName(content, i, state, inlineEmoji);
|
|
|
|
if (name) {
|
|
token = getEmojiTokenByName(name, state);
|
|
if (token) {
|
|
offset = name.length + 2;
|
|
}
|
|
}
|
|
|
|
if (enableShortcuts && !token) {
|
|
// handle aliases (note: we can't do this in inline cause ; is not a split point)
|
|
const info = getEmojiTokenByTranslation(
|
|
content,
|
|
i,
|
|
state,
|
|
customEmojiTranslation
|
|
);
|
|
|
|
if (info) {
|
|
offset = info.pos - i;
|
|
token = info.token;
|
|
}
|
|
}
|
|
|
|
if (token) {
|
|
result = result || [];
|
|
|
|
if (i - start > 0) {
|
|
let text = new state.Token("text", "", 0);
|
|
text.content = content.slice(start, i);
|
|
result.push(text);
|
|
}
|
|
|
|
result.push(token);
|
|
|
|
end = start = i + offset;
|
|
i += offset - 1;
|
|
}
|
|
}
|
|
|
|
if (end < content.length) {
|
|
let text = new state.Token("text", "", 0);
|
|
text.content = content.slice(end);
|
|
result.push(text);
|
|
}
|
|
|
|
// we check for a result <= 5 because we support maximum 3 large emojis
|
|
// EMOJI SPACE EMOJI SPACE EMOJI => 5 tokens
|
|
if (result && result.length > 0 && result.length <= 5) {
|
|
// we ensure line starts and ends with an emoji
|
|
// and has no more than 3 emojis
|
|
if (
|
|
result[0].type === "emoji" &&
|
|
result[result.length - 1].type === "emoji" &&
|
|
result.filter((r) => r.type === "emoji").length <= 3
|
|
) {
|
|
let onlyEmojiLine = true;
|
|
let index = 0;
|
|
|
|
const checkNextToken = (t) => {
|
|
if (!t) {
|
|
return;
|
|
}
|
|
|
|
if (!["emoji", "text"].includes(t.type)) {
|
|
onlyEmojiLine = false;
|
|
}
|
|
|
|
// a text token should always have an emoji before
|
|
// and be a space
|
|
if (
|
|
t.type === "text" &&
|
|
((result[index - 1] && result[index - 1].type !== "emoji") ||
|
|
t.content !== " ")
|
|
) {
|
|
onlyEmojiLine = false;
|
|
}
|
|
|
|
// exit as soon as possible
|
|
if (onlyEmojiLine) {
|
|
index += 1;
|
|
checkNextToken(result[index]);
|
|
}
|
|
};
|
|
|
|
checkNextToken(result[index]);
|
|
|
|
if (onlyEmojiLine) {
|
|
result.forEach((r) => {
|
|
if (r.type === "emoji") {
|
|
applyOnlyEmojiClass(r);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function applyOnlyEmojiClass(token) {
|
|
token.attrs.forEach((attr) => {
|
|
if (attr[0] === "class") {
|
|
attr[1] = `${attr[1]} only-emoji`;
|
|
}
|
|
});
|
|
}
|
|
|
|
export function setup(helper) {
|
|
helper.registerOptions((opts, siteSettings, state) => {
|
|
opts.features.emoji = !state.disableEmojis && !!siteSettings.enable_emoji;
|
|
opts.features.emojiShortcuts = !!siteSettings.enable_emoji_shortcuts;
|
|
opts.features.inlineEmoji = !!siteSettings.enable_inline_emoji_translation;
|
|
opts.emojiSet = siteSettings.emoji_set || "";
|
|
opts.customEmoji = state.customEmoji;
|
|
opts.emojiCDNUrl = siteSettings.external_emoji_url;
|
|
opts.emojiDenyList = state.emojiDenyList;
|
|
});
|
|
|
|
helper.registerPlugin((md) => {
|
|
md.core.ruler.push("emoji", (state) =>
|
|
md.options.discourse.helpers.textReplace(state, (c, s) =>
|
|
applyEmoji(
|
|
c,
|
|
s,
|
|
md.options.discourse.emojiUnicodeReplacer,
|
|
md.options.discourse.features.emojiShortcuts,
|
|
md.options.discourse.features.inlineEmoji,
|
|
md.options.discourse.customEmojiTranslation,
|
|
md.options.discourse.watchedWordsReplace,
|
|
md.options.discourse.emojiDenyList
|
|
)
|
|
)
|
|
);
|
|
});
|
|
|
|
helper.allowList([
|
|
"img[class=emoji]",
|
|
"img[class=emoji emoji-custom]",
|
|
"img[class=emoji emoji-custom only-emoji]",
|
|
"img[class=emoji only-emoji]",
|
|
"img[loading=lazy]",
|
|
"img[width=20]",
|
|
"img[height=20]",
|
|
]);
|
|
}
|