FEATURE: Add an emoji deny list site setting (#20929)

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)
This commit is contained in:
David Battersby 2023-04-13 15:38:54 +08:00 committed by GitHub
parent 5b1306cb54
commit 967010e545
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 316 additions and 15 deletions

View File

@ -530,7 +530,11 @@ export default Component.extend(TextareaTextManipulation, {
if (term === "") {
if (this.emojiStore.favorites.length) {
return resolve(this.emojiStore.favorites.slice(0, 5));
return resolve(
this.emojiStore.favorites
.filter((f) => !this.site.denied_emojis?.includes(f))
.slice(0, 5)
);
} else {
return resolve([
"slight_smile",
@ -555,12 +559,13 @@ export default Component.extend(TextareaTextManipulation, {
return resolve([allTranslations[full]]);
}
const emojiDenied = this.get("site.denied_emojis") || [];
const match = term.match(/^:?(.*?):t([2-6])?$/);
if (match) {
const name = match[1];
const scale = match[2];
if (isSkinTonableEmoji(name)) {
if (isSkinTonableEmoji(name) && !emojiDenied.includes(name)) {
if (scale) {
return resolve([`${name}:t${scale}`]);
} else {
@ -572,6 +577,7 @@ export default Component.extend(TextareaTextManipulation, {
const options = emojiSearch(term, {
maxResults: 5,
diversity: this.emojiStore.diversity,
exclude: emojiDenied,
});
return resolve(options);

View File

@ -380,6 +380,7 @@ export default Component.extend({
if (filter) {
results.innerHTML = emojiSearch(filter.toLowerCase(), {
diversity: this.emojiStore.diversity,
exclude: this.site.denied_emojis,
})
.map(this._replaceEmoji)
.join("");

View File

@ -19,6 +19,7 @@ function getOpts(opts) {
currentUser: context.currentUser,
censoredRegexp: context.site.censored_regexp,
customEmojiTranslation: context.site.custom_emoji_translation,
emojiDenyList: context.site.denied_emojis,
siteSettings: context.siteSettings,
formatUsername,
watchedWordsReplace: context.site.watched_words_replace,
@ -96,6 +97,7 @@ function createPrettyText(options) {
function emojiOptions() {
let siteSettings = helperContext().siteSettings;
let context = helperContext();
if (!siteSettings.enable_emoji) {
return;
}
@ -105,6 +107,7 @@ function emojiOptions() {
emojiSet: siteSettings.emoji_set,
enableEmojiShortcuts: siteSettings.enable_emoji_shortcuts,
inlineEmoji: siteSettings.enable_inline_emoji_translation,
emojiDenyList: context.site.denied_emojis,
emojiCDNUrl: siteSettings.external_emoji_url,
};
}

View File

@ -88,6 +88,11 @@ export function performEmojiUnescape(string, opts) {
classes += ` ${opts.class}`;
}
// hides denied emojis and aliases from the emoji picker
if (opts.emojiDenyList?.includes(emojiVal)) {
return "";
}
const isReplacable =
(isEmoticon || hasEndingColon || isUnicodeEmoticon) &&
isReplacableInlineEmoji(string, index, opts.inlineEmoji);
@ -184,6 +189,7 @@ let toSearch;
export function emojiSearch(term, options) {
const maxResults = options?.maxResults;
const diversity = options?.diversity;
const exclude = options?.exclude || [];
if (maxResults === 0) {
return [];
}
@ -200,7 +206,8 @@ export function emojiSearch(term, options) {
function addResult(t) {
const val = aliasMap.get(t) || t;
if (!results.includes(val)) {
// dont add skin tone variations or alias of denied emoji to search results
if (!results.includes(val) && !exclude.includes(val)) {
if (diversity && diversity > 1 && isSkinTonableEmoji(val)) {
results.push(`${val}:t${diversity}`);
} else {

View File

@ -43,6 +43,7 @@ export function buildOptions(state) {
customEmojiTranslation,
watchedWordsReplace,
watchedWordsLink,
emojiDenyList,
featuresOverride,
markdownItRules,
additionalOptions,
@ -88,6 +89,7 @@ export function buildOptions(state) {
disableEmojis,
watchedWordsReplace,
watchedWordsLink,
emojiDenyList,
featuresOverride,
markdownItRules,
additionalOptions,

View File

@ -196,7 +196,8 @@ function applyEmoji(
enableShortcuts,
inlineEmoji,
customEmojiTranslation,
watchedWordsReplacer
watchedWordsReplacer,
emojiDenyList
) {
let result = null;
let start = 0;
@ -224,6 +225,16 @@ function applyEmoji(
});
}
// 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++) {
@ -346,6 +357,7 @@ export function setup(helper) {
opts.emojiSet = siteSettings.emoji_set || "";
opts.customEmoji = state.customEmoji;
opts.emojiCDNUrl = siteSettings.external_emoji_url;
opts.emojiDenyList = state.emojiDenyList;
});
helper.registerPlugin((md) => {
@ -358,7 +370,8 @@ export function setup(helper) {
md.options.discourse.features.emojiShortcuts,
md.options.discourse.features.inlineEmoji,
md.options.discourse.customEmojiTranslation,
md.options.discourse.watchedWordsReplace
md.options.discourse.watchedWordsReplace,
md.options.discourse.emojiDenyList
)
)
);

View File

@ -28,6 +28,14 @@ class Emoji
Discourse.cache.fetch(cache_key("standard_emojis")) { load_standard }
end
def self.allowed
Discourse.cache.fetch(cache_key("allowed_emojis")) { load_allowed }
end
def self.denied
Discourse.cache.fetch(cache_key("denied_emojis")) { load_denied }
end
def self.aliases
db["aliases"]
end
@ -114,7 +122,7 @@ class Emoji
end
def self.clear_cache
%w[custom standard translations all].each do |key|
%w[custom standard translations allowed denied all].each do |key|
Discourse.cache.delete(cache_key("#{key}_emojis"))
end
global_emoji_cache.clear
@ -150,6 +158,26 @@ class Emoji
db["emojis"].map { |e| Emoji.create_from_db_item(e) }.compact
end
def self.load_allowed
denied_emojis = denied
all_emojis = load_standard + load_custom
if denied_emojis.present?
all_emojis.reject { |e| denied_emojis.include?(e.name) }
else
all_emojis
end
end
def self.load_denied
if SiteSetting.emoji_deny_list.present?
denied_emoji = SiteSetting.emoji_deny_list.split("|")
if denied_emoji.size > 0
denied_emoji.concat(denied_emoji.flat_map { |e| Emoji.aliases[e] }.compact)
end
end
end
def self.load_custom
result = []
@ -243,6 +271,8 @@ class Emoji
end
def self.lookup_unicode(name)
return "" if denied&.include?(name)
@reverse_map ||=
begin
map = {}

View File

@ -41,6 +41,7 @@ class SiteSerializer < ApplicationSerializer
:anonymous_default_sidebar_tags,
:anonymous_sidebar_sections,
:whispers_allowed_groups_names,
:denied_emojis,
)
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
@ -280,6 +281,14 @@ class SiteSerializer < ApplicationSerializer
scope.can_see_whispers?
end
def denied_emojis
@denied_emojis ||= Emoji.denied
end
def include_denied_emojis?
denied_emojis.present?
end
private
def ordered_flags(flags)

View File

@ -63,4 +63,6 @@ DiscourseEvent.on(:site_setting_changed) do |name, old_value, new_value|
if name == :reviewable_low_priority_threshold && Reviewable.min_score_for_priority(:medium) > 0
Reviewable.set_priorities(low: new_value)
end
Emoji.clear_cache && Discourse.request_refresh! if name == :emoji_deny_list
end

View File

@ -2264,6 +2264,7 @@ en:
emoji_set: "How would you like your emoji?"
emoji_autocomplete_min_chars: "Minimum number of characters required to trigger autocomplete emoji popup"
enable_inline_emoji_translation: "Enables translation for inline emojis (without any space or punctuation before)"
emoji_deny_list: "These emoji will not be available to use in menus or shortcodes."
approve_post_count: "The amount of posts from a new or basic user that must be approved"
approve_unless_trust_level: "Posts for users below this trust level must be approved"

View File

@ -1012,6 +1012,11 @@ posting:
zh_TW: true
ja: true
ko: true
emoji_deny_list:
type: emoji_list
default: ""
client: true
refresh: true
approve_post_count:
default: 0
approve_unless_trust_level:

View File

@ -202,6 +202,7 @@ module PrettyText
__optInput.customEmoji = #{custom_emoji.to_json};
__optInput.customEmojiTranslation = #{Plugin::CustomEmoji.translations.to_json};
__optInput.emojiUnicodeReplacer = __emojiUnicodeReplacer;
__optInput.emojiDenyList = #{Emoji.denied.to_json};
__optInput.lookupUploadUrls = __lookupUploadUrls;
__optInput.censoredRegexp = #{WordWatcher.serializable_word_matcher_regexp(:censor).to_json};
__optInput.watchedWordsReplace = #{WordWatcher.word_matcher_regexps(:replace).to_json};

View File

@ -3,7 +3,7 @@
module Chat
class EmojisController < ::Chat::BaseController
def index
emojis = Emoji.all.group_by(&:group)
emojis = Emoji.allowed.group_by(&:group)
render json: MultiJson.dump(emojis)
end
end

View File

@ -480,12 +480,13 @@ export default Component.extend(TextareaTextManipulation, {
return resolve([allTranslations[full]]);
}
const emojiDenied = this.get("site.denied_emojis") || [];
const match = term.match(/^:?(.*?):t([2-6])?$/);
if (match) {
const name = match[1];
const scale = match[2];
if (isSkinTonableEmoji(name)) {
if (isSkinTonableEmoji(name) && !emojiDenied.includes(name)) {
if (scale) {
return resolve([`${name}:t${scale}`]);
} else {
@ -497,6 +498,7 @@ export default Component.extend(TextareaTextManipulation, {
const options = emojiSearch(term, {
maxResults: 5,
diversity: this.chatEmojiReactionStore.diversity,
exclude: emojiDenied,
});
return resolve(options);

View File

@ -51,7 +51,9 @@ export default class ChatEmojiPicker extends Component {
get groups() {
const emojis = this.chatEmojiPickerManager.emojis;
const favorites = {
favorites: this.chatEmojiReactionStore.favorites.map((name) => {
favorites: this.chatEmojiReactionStore.favorites
.filter((f) => !this.site.denied_emojis?.includes(f))
.map((name) => {
return {
name,
group: "favorites",

View File

@ -102,6 +102,17 @@ RSpec.describe "Chat composer", type: :system, js: true do
expect(find(".chat-composer-input").value).to eq(":grimacing:")
end
it "removes denied emojis from insert emoji picker" do
SiteSetting.emoji_deny_list = "monkey|peach"
chat.visit_channel(channel_1)
channel.open_action_menu
channel.click_action_button("emoji")
expect(page).to have_no_selector("[data-emoji='monkey']")
expect(page).to have_no_selector("[data-emoji='peach']")
end
end
context "when adding an emoji through the autocomplete" do
@ -117,6 +128,17 @@ RSpec.describe "Chat composer", type: :system, js: true do
expect(find(".chat-composer-input").value).to eq(":grimacing: ")
end
it "doesn't suggest denied emojis and aliases" do
SiteSetting.emoji_deny_list = "peach|poop"
chat.visit_channel(channel_1)
find(".chat-composer-input").fill_in(with: ":peac")
expect(page).to have_no_selector(".emoji-shortname", text: "peach")
find(".chat-composer-input").fill_in(with: ":hank") # alias
expect(page).to have_no_selector(".emoji-shortname", text: "poop")
end
end
context "when opening emoji picker through more button of the autocomplete" do

View File

@ -111,6 +111,18 @@ RSpec.describe "React to message", type: :system, js: true do
expect(channel).to have_reaction(message_1, reaction_1.emoji)
end
it "removes denied emojis and aliases from reactions" do
SiteSetting.emoji_deny_list = "fu"
sign_in(current_user)
chat.visit_channel(category_channel_1)
channel.hover_message(message_1)
find(".chat-message-actions .react-btn").click
expect(page).to have_no_css(".chat-emoji-picker [data-emoji=\"fu\"]")
expect(page).to have_no_css(".chat-emoji-picker [data-emoji=\"middle_finger\"]")
end
end
context "when using frequent reactions" do

View File

@ -23,6 +23,11 @@ RSpec.describe Emoji do
end
describe ".lookup_unicode" do
before do
SiteSetting.emoji_deny_list = "peach"
Emoji.clear_cache
end
it "should return the emoji" do
expect(Emoji.lookup_unicode("blonde_man")).to eq("👱")
end
@ -34,6 +39,10 @@ RSpec.describe Emoji do
it "should return a skin toned emoji" do
expect(Emoji.lookup_unicode("blonde_woman:t6")).to eq("👱🏿‍♀️")
end
it "should not return a fu emoji when emoji is in emoji deny list site setting" do
expect(Emoji.lookup_unicode("peach")).not_to eq("🍑")
end
end
describe ".url_for" do

View File

@ -765,6 +765,9 @@
},
"whispers_allowed_groups_names" : {
"type": "array"
},
"denied_emojis" : {
"type": "array"
}
},
"required": [

View File

@ -0,0 +1,101 @@
# frozen_string_literal: true
describe "Emoji deny list", type: :system, js: true do
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:emoji_picker) { PageObjects::Components::EmojiPicker.new }
fab!(:admin) { Fabricate(:admin) }
before { sign_in(admin) }
describe "when editing admin settings" do
before { SiteSetting.emoji_deny_list = "" }
let(:site_settings_page) { PageObjects::Pages::AdminSettings.new }
it "should allow admin to update emoji deny list" do
site_settings_page.visit_category("posting")
site_settings_page.select_from_emoji_list("emoji_deny_list", "fu", false)
site_settings_page.select_from_emoji_list("emoji_deny_list", "poop")
expect(site_settings_page.values_in_list("emoji_deny_list")).to eq(%w[fu poop])
end
end
describe "when visiting topics" do
SiteSetting.emoji_deny_list = "monkey"
Emoji.clear_cache
fab!(:topic) { Fabricate(:topic, title: "Time for :monkey: business") }
fab!(:post) { Fabricate(:post, topic: topic, raw: "We have no time to :monkey: around!") }
it "should remove denied emojis from page title, heading and body" do
topic_page.visit_topic(topic)
expect(page.title).to eq("Time for business - Discourse")
expect(topic_page).to have_topic_title("Time for business")
expect(page).not_to have_css(".emoji[title=':monkey:']")
end
end
describe "when using composer" do
before do
SiteSetting.emoji_deny_list = "fu|poop"
Emoji.clear_cache && Discourse.request_refresh!
end
fab!(:topic) { Fabricate(:topic) }
fab!(:post) { Fabricate(:post, topic: topic) }
it "should remove denied emojis from emoji picker" do
topic_page.visit_topic_and_open_composer(topic)
expect(composer).to be_opened
composer.click_toolbar_button(10)
expect(composer.emoji_picker).to be_visible
expect(emoji_picker.has_emoji?("fu")).to eq(false)
end
it "should not show denied emojis and aliases in emoji autocomplete" do
topic_page.visit_topic_and_open_composer(topic)
composer.type_content(":poop") # shows no results
expect(composer).not_to have_emoji_autocomplete
composer.clear_content
composer.type_content(":middle") # middle_finger is alias
expect(composer).not_to have_emoji_suggestion("fu")
end
it "should not show denied emoji in preview" do
topic_page.visit_topic_and_open_composer(topic)
composer.fill_content(":wave:")
expect(composer).to have_emoji_preview("wave")
composer.clear_content
composer.fill_content(":fu:")
expect(composer).not_to have_emoji_preview("fu")
end
end
describe "when using private messages" do
before do
SiteSetting.emoji_deny_list = "pancakes|monkey"
Emoji.clear_cache && Discourse.request_refresh!
end
fab!(:topic) do
Fabricate(:private_message_topic, title: "Want to catch up for :pancakes: today?")
end
fab!(:post) { Fabricate(:post, topic: topic, raw: "Can we use the :monkey: emoji here?") }
it "should remove denied emojis from message title and body" do
topic_page.visit_topic(topic)
expect(topic_page).to have_topic_title("Want to catch up for today?")
expect(post).not_to have_css(".emoji[title=':monkey:']")
end
end
end

View File

@ -4,6 +4,7 @@ module PageObjects
module Components
class Composer < PageObjects::Components::Base
COMPOSER_ID = "#reply-control"
AUTOCOMPLETE_MENU = ".autocomplete.ac-emoji"
def opened?
page.has_css?("#{COMPOSER_ID}.open")
@ -14,6 +15,11 @@ module PageObjects
self
end
def click_toolbar_button(number)
find(".d-editor-button-bar button:nth-child(#{number})").click
self
end
def fill_title(title)
find("#{COMPOSER_ID} #reply-title").fill_in(with: title)
self
@ -54,6 +60,26 @@ module PageObjects
find("#{COMPOSER_ID} .btn-primary .d-button-label")
end
def emoji_picker
find("#{COMPOSER_ID} .emoji-picker")
end
def emoji_autocomplete
find(AUTOCOMPLETE_MENU)
end
def has_emoji_autocomplete?
has_css?(AUTOCOMPLETE_MENU)
end
def has_emoji_suggestion?(emoji)
has_css?("#{AUTOCOMPLETE_MENU} .emoji-shortname", text: emoji)
end
def has_emoji_preview?(emoji)
page.has_css?(".d-editor-preview .emoji[title=':#{emoji}:']")
end
def composer_input
find("#{COMPOSER_ID} .d-editor .d-editor-input")
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module PageObjects
module Components
class EmojiPicker < PageObjects::Components::Base
def select_emoji(emoji_name)
find(".emoji-picker .emoji[title='#{emoji_name}']").click
end
def search_emoji(emoji_name)
find(".emoji-picker .search input").fill_in(with: emoji_name)
end
def has_emoji?(emoji_name)
page.has_css?(".emoji-picker .emoji[title='#{emoji_name}']")
end
end
end
end

View File

@ -8,11 +8,32 @@ module PageObjects
self
end
def visit_category(category)
page.visit("/admin/site_settings/category/#{category}")
self
end
def toggle_setting(setting_name, text = "")
setting = find(".admin-detail .row.setting[data-setting='#{setting_name}']")
setting.find(".setting-value span", text: text).click
setting.find(".setting-controls button.ok").click
end
def select_from_emoji_list(setting_name, text = "", save_changes = true)
setting = find(".admin-detail .row.setting[data-setting='#{setting_name}']")
setting.find(".setting-value .value-list > .value button").click
setting.find(".setting-value .emoji-picker .emoji[title='#{text}']").click
setting.find(".setting-controls button.ok").click if save_changes
end
def values_in_list(setting_name)
vals = []
setting = find(".admin-detail .row.setting[data-setting='#{setting_name}']")
setting
.all(:css, ".setting-value .values .value .value-input span")
.map { |e| vals << e.text }
vals
end
end
end
end

View File

@ -30,6 +30,10 @@ module PageObjects
self
end
def has_topic_title?(text)
has_css?("h1 .fancy-title", text: text)
end
def has_post_content?(post)
post_by_number(post).has_content? post.raw
end