FEATURE: allows multiple custom emoji groups (#9308)

Note: DBHelper would fail with a sql syntax error on columns like "group".

Co-authored-by: Jarek Radosz <jradosz@gmail.com>
This commit is contained in:
Joffrey JAFFEUX 2020-03-30 20:16:10 +02:00 committed by GitHub
parent fa5ba6beb8
commit 0996c3b7b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 428 additions and 138 deletions

View File

@ -1,37 +1,74 @@
import { sort } from "@ember/object/computed";
import EmberObject from "@ember/object";
import EmberObject, { action, computed } from "@ember/object";
import Controller from "@ember/controller";
import { ajax } from "discourse/lib/ajax";
const ALL_FILTER = "all";
export default Controller.extend({
sortedEmojis: sort("model", "emojiSorting"),
filter: null,
sorting: null,
init() {
this._super(...arguments);
this.emojiSorting = ["name"];
this.setProperties({
filter: ALL_FILTER,
sorting: ["group", "name"]
});
},
actions: {
emojiUploaded(emoji) {
emoji.url += "?t=" + new Date().getTime();
this.model.pushObject(EmberObject.create(emoji));
},
sortedEmojis: sort("filteredEmojis.[]", "sorting"),
destroy(emoji) {
return bootbox.confirm(
I18n.t("admin.emoji.delete_confirm", { name: emoji.get("name") }),
I18n.t("no_value"),
I18n.t("yes_value"),
destroy => {
if (destroy) {
return ajax("/admin/customize/emojis/" + emoji.get("name"), {
type: "DELETE"
}).then(() => {
this.model.removeObject(emoji);
});
}
}
);
emojiGroups: computed("model", {
get() {
return this.model.mapBy("group").uniq();
}
}),
sortingGroups: computed("emojiGroups.[]", {
get() {
return [ALL_FILTER].concat(this.emojiGroups);
}
}),
filteredEmojis: computed("model.[]", "filter", {
get() {
if (!this.filter || this.filter === ALL_FILTER) {
return this.model;
} else {
return this.model.filterBy("group", this.filter);
}
}
}),
@action
filterGroups(value) {
this.set("filter", value);
},
@action
emojiUploaded(emoji, group) {
emoji.url += "?t=" + new Date().getTime();
emoji.group = group;
this.model.pushObject(EmberObject.create(emoji));
},
@action
destroyEmoji(emoji) {
return bootbox.confirm(
I18n.t("admin.emoji.delete_confirm", { name: emoji.get("name") }),
I18n.t("no_value"),
I18n.t("yes_value"),
destroy => {
if (destroy) {
return ajax("/admin/customize/emojis/" + emoji.get("name"), {
type: "DELETE"
}).then(() => {
this.model.removeObject(emoji);
});
}
}
);
}
});

View File

@ -1,35 +1,49 @@
<div class='emoji'>
<h2>{{i18n 'admin.emoji.title'}}</h2>
<div class='admin-emojis'>
<h1>{{i18n 'admin.emoji.title'}}</h1>
<p class="desc">{{i18n 'admin.emoji.help'}}</p>
<p class="desc">{{i18n "admin.emoji.help"}}</p>
<p>{{emoji-uploader done=(action "emojiUploaded")}}</p>
{{emoji-uploader
emojiGroups=emojiGroups
done=(action "emojiUploaded")
}}
<hr>
{{#if sortedEmojis}}
<div>
<table id="custom_emoji">
<thead>
<table id="custom_emoji">
<thead>
<tr>
<th>{{i18n "admin.emoji.image"}}</th>
<th>{{i18n "admin.emoji.name"}}</th>
<th>
{{combo-box
value=filter
content=sortingGroups
nameProperty=null
valueProperty=null
onChange=(action "filterGroups")
}}
</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each sortedEmojis as |e|}}
<tr>
<th>{{i18n "admin.emoji.image"}}</th>
<th>{{i18n "admin.emoji.name"}}</th>
<th></th>
<th><img class="emoji emoji-custom" src={{e.url}} title={{e.name}}></th>
<th>:{{e.name}}:</th>
<th>{{e.group}}</th>
<th>
{{d-button
action=(action "destroyEmoji" e)
class="btn-danger"
icon="far-trash-alt"
}}
</th>
</tr>
</thead>
<tbody>
{{#each sortedEmojis as |e|}}
<tr>
<th><img class="emoji emoji-custom" src={{e.url}} title={{e.name}}></th>
<th>:{{e.name}}:</th>
<th>
{{d-button
action=(action "destroy" e)
class="btn-danger pull-right"
icon="far-trash-alt"}}
</th>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/each}}
</tbody>
</table>
{{/if}}
</div>

View File

@ -14,9 +14,26 @@ import ENV, { INPUT_DELAY } from "discourse-common/config/environment";
const { run } = Ember;
const PER_ROW = 11;
const customEmojis = _.keys(extendedEmojiList()).map(code => {
return { code, src: emojiUrlFor(code) };
});
function customEmojis() {
const list = extendedEmojiList();
const emojis = Object.keys(list)
.map(code => {
const { group } = list[code];
return {
code,
src: emojiUrlFor(code),
group,
key: `emoji_picker.${group || "default"}`
};
})
.reduce((acc, curr) => {
if (!acc[curr.group]) acc[curr.group] = [];
acc[curr.group].push(curr);
return acc;
}, {});
return Object.values(emojis);
}
export default Component.extend({
automaticPositioning: true,
@ -35,7 +52,9 @@ export default Component.extend({
},
show() {
const template = findRawTemplate("emoji-picker")({ customEmojis });
const template = findRawTemplate("emoji-picker")({
customEmojis: customEmojis()
});
this.$picker.html(template);
this.$filter = this.$picker.find(".filter");
@ -579,7 +598,7 @@ export default Component.extend({
this.$picker.width() -
this.$picker.find(".categories-column").width() -
this.$picker.find(".diversity-picker").width() -
32;
60;
this.$picker.find(".info").css("max-width", infoMaxWidth);
},

View File

@ -1,23 +1,53 @@
import { notEmpty, not } from "@ember/object/computed";
import { action } from "@ember/object";
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
import UploadMixin from "discourse/mixins/upload";
const DEFAULT_GROUP = "default";
export default Component.extend(UploadMixin, {
type: "emoji",
uploadUrl: "/admin/customize/emojis",
hasName: notEmpty("name"),
hasGroup: notEmpty("group"),
addDisabled: not("hasName"),
group: "default",
emojiGroups: null,
newEmojiGroups: null,
tagName: null,
uploadOptions() {
return {
sequentialUploads: true
};
didReceiveAttrs() {
this._super(...arguments);
this.set("newEmojiGroups", this.emojiGroups);
},
@discourseComputed("hasName", "name")
data(hasName, name) {
return hasName ? { name } : {};
uploadOptions() {
return { sequentialUploads: true };
},
@action
createEmojiGroup(group) {
this.setProperties({
newEmojiGroups: this.emojiGroups.concat([group]).uniq(),
group
});
},
@discourseComputed("hasName", "name", "hasGroup", "group")
data(hasName, name, hasGroup, group) {
const payload = {};
if (hasName) {
payload.name = name;
}
if (hasGroup && group !== DEFAULT_GROUP) {
payload.group = group;
}
return payload;
},
validateUploadedFilesOptions() {
@ -25,7 +55,7 @@ export default Component.extend(UploadMixin, {
},
uploadDone(upload) {
this.set("name", null);
this.done(upload);
this.done(upload, this.group);
this.setProperties({ name: null, group: DEFAULT_GROUP });
}
});

View File

@ -24,7 +24,7 @@ export default {
});
(PreloadStore.get("customEmoji") || []).forEach(emoji =>
registerEmoji(emoji.name, emoji.url)
registerEmoji(emoji.name, emoji.url, emoji.group)
);
}
};

View File

@ -1,11 +1,48 @@
{{text-field name="name" placeholderKey="admin.emoji.name" value=name}}
<label class="btn btn-primary {{if addDisabled 'disabled'}}">
{{d-icon "plus"}}
{{i18n "admin.emoji.add"}}
<input
class="hidden-upload-field"
disabled={{addDisabled}}
type="file"
accept=".png,.gif">
</label>
{{#conditional-loading-section isLoading=uploading}}
<div class="emoji-uploader">
<div class="control">
<span class="label">
{{i18n "admin.emoji.name"}}
</span>
<div class="input">
{{input
name="name"
placeholderKey="admin.emoji.name"
value=(readonly name)
input=(action (mut name) value="target.value")
}}
</div>
</div>
<div class="control">
<span class="label">
{{i18n "admin.emoji.group"}}
</span>
<div class="input">
{{combo-box
name="group"
value=group
content=newEmojiGroups
onChange=(action "createEmojiGroup")
valueProperty=null
nameProperty=null
options=(hash
allowAny=true
)
}}
</div>
</div>
<div class="control">
<div class="input">
<label class="btn btn-default btn-primary {{if addDisabled 'disabled'}}">
{{d-icon "plus"}}
{{i18n "admin.emoji.add"}}
<input
class="hidden-upload-field"
disabled={{addDisabled}}
type="file"
accept=".png,.gif">
</label>
</div>
</div>
</div>
{{/conditional-loading-section}}

View File

@ -9,10 +9,12 @@
</div>
<% end %>
<% if !Emoji.custom.blank? %>
<div class='category-icon'>
<button data-tabicon="<%= Emoji.custom.first.name %>" type="button" class="emoji" tabindex="-1" data-section="ungrouped" title="{{i18n 'emoji_picker.custom'}}"></button>
</div>
<% Emoji.custom.group_by { |emoji| emoji.group }.each do |group, emojis| %>
<% if emojis.present? %>
<div class='category-icon'>
<button data-tabicon="<%= emojis.first.name %>" type="button" class="emoji" tabindex="-1" data-section="custom-<%= group %>" title="{{i18n 'emoji_picker.<%= group %>'}}"></button>
</div>
<% end %>
<% end %>
</div>
@ -49,18 +51,22 @@
</div>
<% end %>
{{#if customEmojis.length}}
<div class='section' data-section='ungrouped'>
<div class='section-header'>
<span class="title">{{i18n 'emoji_picker.custom'}}</span>
{{#each customEmojis as |emojis|}}
{{#if emojis.length}}
<div class='section' data-section='custom-{{emojis.firstObject.group}}'>
<div class='section-header'>
<span class="title">
{{i18n emojis.firstObject.key}}
</span>
</div>
<div class='section-group'>
{{#each emojis as |emoji|}}
<button style="background-url: url("{{emoji.src}}")" type="button" class="emoji" tabindex="-1" title="{{emoji.code}}"></button>
{{/each}}
</div>
</div>
<div class='section-group'>
{{#each customEmojis as |emoji|}}
<button style="background-url: url("{{emoji.src}}")" type="button" class="emoji" tabindex="-1" title="{{emoji.code}}"></button>
{{/each}}
</div>
</div>
{{/if}}
{{/if}}
{{/each}}
</div>
<div class='footer'>
<div class='info'></div>

View File

@ -10,9 +10,9 @@ import { IMAGE_VERSION } from "pretty-text/emoji/version";
const extendedEmoji = {};
export function registerEmoji(code, url) {
export function registerEmoji(code, url, group) {
code = code.toLowerCase();
extendedEmoji[code] = url;
extendedEmoji[code] = { url, group };
}
export function extendedEmojiList() {
@ -92,7 +92,7 @@ function isReplacableInlineEmoji(string, index, inlineEmoji) {
}
export function performEmojiUnescape(string, opts) {
if (!string || typeof string !== "string") {
if (!string) {
return;
}
@ -126,6 +126,8 @@ export function performEmojiUnescape(string, opts) {
} alt='${emojiVal}' class='${classes}'>`
: m;
});
return string;
}
export function performEmojiEscape(string, opts) {
@ -143,6 +145,8 @@ export function performEmojiEscape(string, opts) {
return m;
});
return string;
}
export function isCustomEmoji(code, opts) {
@ -157,11 +161,11 @@ export function buildEmojiUrl(code, opts) {
let url;
code = String(code).toLowerCase();
if (extendedEmoji.hasOwnProperty(code)) {
url = extendedEmoji[code];
url = extendedEmoji[code].url;
}
if (opts && opts.customEmoji && opts.customEmoji[code]) {
url = opts.customEmoji[code];
url = opts.customEmoji[code].url || opts.customEmoji[code];
}
const noToneMatch = code.match(/([^:]+):?/);

View File

@ -979,3 +979,4 @@ a.inline-editable-field {
@import "common/admin/admin_report_table";
@import "common/admin/admin_report_inline_table";
@import "common/admin/admin_intro";
@import "common/admin/admin_emojis";

View File

@ -0,0 +1,50 @@
.admin-emojis {
#custom_emoji {
.select-kit {
width: 220px;
}
}
}
.emoji-uploader {
display: flex;
align-items: flex-end;
input,
.select-kit {
width: 220px;
margin: 0 1em 0 0;
}
.upload-container {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
}
.mobile-view {
.admin-emojis {
.emoji-uploader {
flex-direction: column;
.fields {
input,
.select-kit {
width: 100%;
}
}
.upload-container {
margin: 1em 0 0 0;
}
}
#custom_emoji {
.select-kit {
display: none;
}
}
}
}

View File

@ -556,7 +556,7 @@
}
#custom_emoji {
width: 27%;
width: 100%;
}
.modal-body .inputs .branch {

View File

@ -45,6 +45,8 @@ sup img.emoji {
justify-content: space-between;
border-right: 1px solid $primary-low;
min-width: 36px;
overflow-y: auto;
padding: 0.5em;
}
.emoji-picker .category-icon {

View File

@ -6,3 +6,7 @@
.emoji-picker .category-icon {
margin: 2px;
}
.emoji-picker .categories-column {
padding: 0;
}

View File

@ -9,6 +9,7 @@ class Admin::EmojisController < Admin::AdminController
def create
file = params[:file] || params[:files].first
name = params[:name] || File.basename(file.original_filename, ".*")
group = params[:group] ? params[:group].downcase : nil
hijack do
# fix the name
@ -26,11 +27,11 @@ class Admin::EmojisController < Admin::AdminController
data =
if upload.persisted?
custom_emoji = CustomEmoji.new(name: name, upload: upload)
custom_emoji = CustomEmoji.new(name: name, upload: upload, group: group)
if custom_emoji.save
Emoji.clear_cache
{ name: custom_emoji.name, url: custom_emoji.upload.url }
{ name: custom_emoji.name, url: custom_emoji.upload.url, group: group }
else
good = false
failed_json.merge(errors: custom_emoji.errors.full_messages)

View File

@ -14,6 +14,7 @@ end
# id :integer not null, primary key
# name :string not null
# upload_id :integer not null
# group :string
# created_at :datetime not null
# updated_at :datetime not null
#

View File

@ -6,9 +6,11 @@ class Emoji
FITZPATRICK_SCALE ||= [ "1f3fb", "1f3fc", "1f3fd", "1f3fe", "1f3ff" ]
DEFAULT_GROUP ||= "default"
include ActiveModel::SerializerSupport
attr_accessor :name, :url, :tonable
attr_accessor :name, :url, :tonable, :group
def self.all
Discourse.cache.fetch(cache_key("all_emojis")) { standard | custom }
@ -104,15 +106,19 @@ class Emoji
result << Emoji.new.tap do |e|
e.name = emoji.name
e.url = emoji.upload&.url
e.group = emoji.group || DEFAULT_GROUP
end
end
end
Plugin::CustomEmoji.emojis.each do |name, url|
result << Emoji.new.tap do |e|
e.name = name
url = (Discourse.base_uri + url) if url[/^\/[^\/]/]
e.url = url
Plugin::CustomEmoji.emojis.each do |group, emojis|
emojis.each do |name, url|
result << Emoji.new.tap do |e|
e.name = name
url = (Discourse.base_uri + url) if url[/^\/[^\/]/]
e.url = url
e.group = group || DEFAULT_GROUP
end
end
end

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true
class EmojiSerializer < ApplicationSerializer
attributes :name, :url
attributes :name, :url, :group
end

View File

@ -1651,7 +1651,6 @@ en:
objects: Objects
symbols: Symbols
flags: Flags
custom: Custom emojis
recent: Recently used
default_tone: No skin tone
light_tone: Light skin tone
@ -1659,6 +1658,7 @@ en:
medium_tone: Medium skin tone
medium_dark_tone: Medium dark skin tone
dark_tone: Dark skin tone
default: Custom emojis
shared_drafts:
title: "Shared Drafts"
@ -4556,6 +4556,7 @@ en:
help: "Add new emoji that will be available to everyone. (PROTIP: drag & drop multiple files at once)"
add: "Add New Emoji"
name: "Name"
group: "Group"
image: "Image"
delete_confirm: "Are you sure you want to delete the :%{name}: emoji?"

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddGroupToCustomEmojis < ActiveRecord::Migration[6.0]
def change
add_column :custom_emojis, :group, :string, null: true, limit: 20
end
end

View File

@ -29,17 +29,17 @@ class DbHelper
text_columns.each do |table, columns|
set = columns.map do |column|
replace = "REPLACE(#{column[:name]}, :from, :to)"
replace = "REPLACE(\"#{column[:name]}\", :from, :to)"
replace = truncate(replace, table, column)
"#{column[:name]} = #{replace}"
"\"#{column[:name]}\" = #{replace}"
end.join(", ")
where = columns.map do |column|
"#{column[:name]} IS NOT NULL AND #{column[:name]} LIKE :like"
"\"#{column[:name]}\" IS NOT NULL AND \"#{column[:name]}\" LIKE :like"
end.join(" OR ")
rows = DB.exec(<<~SQL, from: from, to: to, like: like)
UPDATE #{table}
UPDATE \"#{table}\"
SET #{set}
WHERE #{where}
SQL
@ -55,17 +55,17 @@ class DbHelper
text_columns.each do |table, columns|
set = columns.map do |column|
replace = "REGEXP_REPLACE(#{column[:name]}, :pattern, :replacement, :flags)"
replace = "REGEXP_REPLACE(\"#{column[:name]}\", :pattern, :replacement, :flags)"
replace = truncate(replace, table, column)
"#{column[:name]} = #{replace}"
"\"#{column[:name]}\" = #{replace}"
end.join(", ")
where = columns.map do |column|
"#{column[:name]} IS NOT NULL AND #{column[:name]} #{match} :pattern"
"\"#{column[:name]}\" IS NOT NULL AND \"#{column[:name]}\" #{match} :pattern"
end.join(" OR ")
rows = DB.exec(<<~SQL, pattern: pattern, replacement: replacement, flags: flags, match: match)
UPDATE #{table}
UPDATE \"#{table}\"
SET #{set}
WHERE #{where}
SQL
@ -84,9 +84,9 @@ class DbHelper
next if excluded_tables.include?(r.table_name)
rows = DB.query(<<~SQL, like: like)
SELECT #{r.column_name}
FROM #{r.table_name}
WHERE #{r.column_name} LIKE :like
SELECT \"#{r.column_name}\"
FROM \"#{r.table_name}\"
WHERE \""#{r.column_name}"\" LIKE :like
SQL
if rows.size > 0

View File

@ -6,21 +6,29 @@ require_dependency 'plugin/metadata'
require_dependency 'auth'
class Plugin::CustomEmoji
CACHE_KEY ||= "plugin-emoji"
def self.cache_key
@@cache_key ||= "plugin-emoji"
@@cache_key ||= CACHE_KEY
end
def self.emojis
@@emojis ||= {}
end
def self.register(name, url)
@@cache_key = Digest::SHA1.hexdigest(cache_key + name)[0..10]
emojis[name] = url
def self.clear_cache
@@cache_key = CACHE_KEY
@@emojis = {}
end
def self.unregister(name)
emojis.delete(name)
def self.register(name, url, group = Emoji::DEFAULT_GROUP)
@@cache_key = Digest::SHA1.hexdigest(cache_key + name + group)[0..10]
new_group = emojis[group] || {}
new_group[name] = url
emojis[group] = new_group
end
def self.unregister(name, group = Emoji::DEFAULT_GROUP)
emojis[group].delete(name)
end
def self.translations
@ -471,8 +479,9 @@ class Plugin::Instance
DiscoursePluginRegistry.register_seed_path_builder(&block)
end
def register_emoji(name, url)
Plugin::CustomEmoji.register(name, url)
def register_emoji(name, url, group = Emoji::DEFAULT_GROUP)
Plugin::CustomEmoji.register(name, url, group)
Emoji.clear_cache
end
def translate_emoji(from, to)

View File

@ -538,4 +538,30 @@ describe Plugin::Instance do
expect(Reviewable.types).to match_array(current_list << new_element)
end
end
describe '#register_emoji' do
before do
Plugin::CustomEmoji.clear_cache
end
it 'allows to register an emoji' do
Plugin::Instance.new.register_emoji("foo", "/foo/bar.png")
custom_emoji = Emoji.custom.first
expect(custom_emoji.name).to eq("foo")
expect(custom_emoji.url).to eq("/foo/bar.png")
expect(custom_emoji.group).to eq(Emoji::DEFAULT_GROUP)
end
it 'allows to register an emoji with a group' do
Plugin::Instance.new.register_emoji("bar", "/baz/bar.png", "baz")
custom_emoji = Emoji.custom.first
expect(custom_emoji.name).to eq("bar")
expect(custom_emoji.url).to eq("/baz/bar.png")
expect(custom_emoji.group).to eq("baz")
end
end
end

View File

@ -59,22 +59,38 @@ RSpec.describe Admin::EmojisController do
it 'should allow an admin to add a custom emoji' do
Emoji.expects(:clear_cache)
post "/admin/customize/emojis.json", params: {
name: 'test',
file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png")
}
post "/admin/customize/emojis.json", params: {
name: 'test',
file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png")
}
custom_emoji = CustomEmoji.last
upload = custom_emoji.upload
custom_emoji = CustomEmoji.last
upload = custom_emoji.upload
expect(upload.original_filename).to eq('logo.png')
expect(upload.original_filename).to eq('logo.png')
data = JSON.parse(response.body)
data = JSON.parse(response.body)
expect(response.status).to eq(200)
expect(data["errors"]).to eq(nil)
expect(data["name"]).to eq(custom_emoji.name)
expect(data["url"]).to eq(upload.url)
expect(custom_emoji.group).to eq(nil)
end
expect(response.status).to eq(200)
expect(data["errors"]).to eq(nil)
expect(data["name"]).to eq(custom_emoji.name)
expect(data["url"]).to eq(upload.url)
it 'should allow an admin to add a custom emoji with a custom group' do
Emoji.expects(:clear_cache)
post "/admin/customize/emojis.json", params: {
name: 'test',
group: 'Foo',
file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png")
}
custom_emoji = CustomEmoji.last
data = JSON.parse(response.body)
expect(response.status).to eq(200)
expect(custom_emoji.group).to eq("foo")
end
end

View File

@ -8,6 +8,7 @@ import {
deleteCachedInlineOnebox
} from "pretty-text/inline-oneboxer";
import { extractDataAttribute } from "pretty-text/engines/discourse-markdown-it";
import { registerEmoji } from "pretty-text/emoji";
QUnit.module("lib:pretty-text");
@ -1519,6 +1520,24 @@ QUnit.test("emoji - emojiSet", assert => {
);
});
QUnit.test("emoji - registerEmoji", assert => {
registerEmoji("foo", "/foo.png");
assert.cookedOptions(
":foo:",
{},
`<p><img src="/foo.png?v=${v}" title=":foo:" class="emoji emoji-custom only-emoji" alt=":foo:"></p>`
);
registerEmoji("bar", "/bar.png", "baz");
assert.cookedOptions(
":bar:",
{},
`<p><img src="/bar.png?v=${v}" title=":bar:" class="emoji emoji-custom only-emoji" alt=":bar:"></p>`
);
});
QUnit.test("extractDataAttribute", assert => {
assert.deepEqual(extractDataAttribute("foo="), ["data-foo", ""]);
assert.deepEqual(extractDataAttribute("foo=bar"), ["data-foo", "bar"]);