mirror of
https://github.com/discourse/discourse.git
synced 2025-06-07 14:57:19 +08:00
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:
@ -1,37 +1,74 @@
|
|||||||
import { sort } from "@ember/object/computed";
|
import { sort } from "@ember/object/computed";
|
||||||
import EmberObject from "@ember/object";
|
import EmberObject, { action, computed } from "@ember/object";
|
||||||
import Controller from "@ember/controller";
|
import Controller from "@ember/controller";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
|
||||||
|
const ALL_FILTER = "all";
|
||||||
|
|
||||||
export default Controller.extend({
|
export default Controller.extend({
|
||||||
sortedEmojis: sort("model", "emojiSorting"),
|
filter: null,
|
||||||
|
sorting: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
|
|
||||||
this.emojiSorting = ["name"];
|
this.setProperties({
|
||||||
|
filter: ALL_FILTER,
|
||||||
|
sorting: ["group", "name"]
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
sortedEmojis: sort("filteredEmojis.[]", "sorting"),
|
||||||
emojiUploaded(emoji) {
|
|
||||||
emoji.url += "?t=" + new Date().getTime();
|
|
||||||
this.model.pushObject(EmberObject.create(emoji));
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy(emoji) {
|
emojiGroups: computed("model", {
|
||||||
return bootbox.confirm(
|
get() {
|
||||||
I18n.t("admin.emoji.delete_confirm", { name: emoji.get("name") }),
|
return this.model.mapBy("group").uniq();
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1,35 +1,49 @@
|
|||||||
<div class='emoji'>
|
<div class='admin-emojis'>
|
||||||
<h2>{{i18n 'admin.emoji.title'}}</h2>
|
<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}}
|
{{#if sortedEmojis}}
|
||||||
<div>
|
<table id="custom_emoji">
|
||||||
<table id="custom_emoji">
|
<thead>
|
||||||
<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>
|
<tr>
|
||||||
<th>{{i18n "admin.emoji.image"}}</th>
|
<th><img class="emoji emoji-custom" src={{e.url}} title={{e.name}}></th>
|
||||||
<th>{{i18n "admin.emoji.name"}}</th>
|
<th>:{{e.name}}:</th>
|
||||||
<th></th>
|
<th>{{e.group}}</th>
|
||||||
|
<th>
|
||||||
|
{{d-button
|
||||||
|
action=(action "destroyEmoji" e)
|
||||||
|
class="btn-danger"
|
||||||
|
icon="far-trash-alt"
|
||||||
|
}}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
{{/each}}
|
||||||
<tbody>
|
</tbody>
|
||||||
{{#each sortedEmojis as |e|}}
|
</table>
|
||||||
<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>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,9 +14,26 @@ import ENV, { INPUT_DELAY } from "discourse-common/config/environment";
|
|||||||
const { run } = Ember;
|
const { run } = Ember;
|
||||||
|
|
||||||
const PER_ROW = 11;
|
const PER_ROW = 11;
|
||||||
const customEmojis = _.keys(extendedEmojiList()).map(code => {
|
function customEmojis() {
|
||||||
return { code, src: emojiUrlFor(code) };
|
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({
|
export default Component.extend({
|
||||||
automaticPositioning: true,
|
automaticPositioning: true,
|
||||||
@ -35,7 +52,9 @@ export default Component.extend({
|
|||||||
},
|
},
|
||||||
|
|
||||||
show() {
|
show() {
|
||||||
const template = findRawTemplate("emoji-picker")({ customEmojis });
|
const template = findRawTemplate("emoji-picker")({
|
||||||
|
customEmojis: customEmojis()
|
||||||
|
});
|
||||||
this.$picker.html(template);
|
this.$picker.html(template);
|
||||||
|
|
||||||
this.$filter = this.$picker.find(".filter");
|
this.$filter = this.$picker.find(".filter");
|
||||||
@ -579,7 +598,7 @@ export default Component.extend({
|
|||||||
this.$picker.width() -
|
this.$picker.width() -
|
||||||
this.$picker.find(".categories-column").width() -
|
this.$picker.find(".categories-column").width() -
|
||||||
this.$picker.find(".diversity-picker").width() -
|
this.$picker.find(".diversity-picker").width() -
|
||||||
32;
|
60;
|
||||||
this.$picker.find(".info").css("max-width", infoMaxWidth);
|
this.$picker.find(".info").css("max-width", infoMaxWidth);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -1,23 +1,53 @@
|
|||||||
import { notEmpty, not } from "@ember/object/computed";
|
import { notEmpty, not } from "@ember/object/computed";
|
||||||
|
import { action } from "@ember/object";
|
||||||
import Component from "@ember/component";
|
import Component from "@ember/component";
|
||||||
import discourseComputed from "discourse-common/utils/decorators";
|
import discourseComputed from "discourse-common/utils/decorators";
|
||||||
import UploadMixin from "discourse/mixins/upload";
|
import UploadMixin from "discourse/mixins/upload";
|
||||||
|
|
||||||
|
const DEFAULT_GROUP = "default";
|
||||||
|
|
||||||
export default Component.extend(UploadMixin, {
|
export default Component.extend(UploadMixin, {
|
||||||
type: "emoji",
|
type: "emoji",
|
||||||
uploadUrl: "/admin/customize/emojis",
|
uploadUrl: "/admin/customize/emojis",
|
||||||
hasName: notEmpty("name"),
|
hasName: notEmpty("name"),
|
||||||
|
hasGroup: notEmpty("group"),
|
||||||
addDisabled: not("hasName"),
|
addDisabled: not("hasName"),
|
||||||
|
group: "default",
|
||||||
|
emojiGroups: null,
|
||||||
|
newEmojiGroups: null,
|
||||||
|
tagName: null,
|
||||||
|
|
||||||
uploadOptions() {
|
didReceiveAttrs() {
|
||||||
return {
|
this._super(...arguments);
|
||||||
sequentialUploads: true
|
|
||||||
};
|
this.set("newEmojiGroups", this.emojiGroups);
|
||||||
},
|
},
|
||||||
|
|
||||||
@discourseComputed("hasName", "name")
|
uploadOptions() {
|
||||||
data(hasName, name) {
|
return { sequentialUploads: true };
|
||||||
return hasName ? { name } : {};
|
},
|
||||||
|
|
||||||
|
@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() {
|
validateUploadedFilesOptions() {
|
||||||
@ -25,7 +55,7 @@ export default Component.extend(UploadMixin, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
uploadDone(upload) {
|
uploadDone(upload) {
|
||||||
this.set("name", null);
|
this.done(upload, this.group);
|
||||||
this.done(upload);
|
this.setProperties({ name: null, group: DEFAULT_GROUP });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -24,7 +24,7 @@ export default {
|
|||||||
});
|
});
|
||||||
|
|
||||||
(PreloadStore.get("customEmoji") || []).forEach(emoji =>
|
(PreloadStore.get("customEmoji") || []).forEach(emoji =>
|
||||||
registerEmoji(emoji.name, emoji.url)
|
registerEmoji(emoji.name, emoji.url, emoji.group)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,11 +1,48 @@
|
|||||||
{{text-field name="name" placeholderKey="admin.emoji.name" value=name}}
|
{{#conditional-loading-section isLoading=uploading}}
|
||||||
|
<div class="emoji-uploader">
|
||||||
<label class="btn btn-primary {{if addDisabled 'disabled'}}">
|
<div class="control">
|
||||||
{{d-icon "plus"}}
|
<span class="label">
|
||||||
{{i18n "admin.emoji.add"}}
|
{{i18n "admin.emoji.name"}}
|
||||||
<input
|
</span>
|
||||||
class="hidden-upload-field"
|
<div class="input">
|
||||||
disabled={{addDisabled}}
|
{{input
|
||||||
type="file"
|
name="name"
|
||||||
accept=".png,.gif">
|
placeholderKey="admin.emoji.name"
|
||||||
</label>
|
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}}
|
||||||
|
@ -9,10 +9,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if !Emoji.custom.blank? %>
|
<% Emoji.custom.group_by { |emoji| emoji.group }.each do |group, emojis| %>
|
||||||
<div class='category-icon'>
|
<% if emojis.present? %>
|
||||||
<button data-tabicon="<%= Emoji.custom.first.name %>" type="button" class="emoji" tabindex="-1" data-section="ungrouped" title="{{i18n 'emoji_picker.custom'}}"></button>
|
<div class='category-icon'>
|
||||||
</div>
|
<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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -49,18 +51,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
{{#if customEmojis.length}}
|
{{#each customEmojis as |emojis|}}
|
||||||
<div class='section' data-section='ungrouped'>
|
{{#if emojis.length}}
|
||||||
<div class='section-header'>
|
<div class='section' data-section='custom-{{emojis.firstObject.group}}'>
|
||||||
<span class="title">{{i18n 'emoji_picker.custom'}}</span>
|
<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>
|
||||||
<div class='section-group'>
|
{{/if}}
|
||||||
{{#each customEmojis as |emoji|}}
|
{{/each}}
|
||||||
<button style="background-url: url("{{emoji.src}}")" type="button" class="emoji" tabindex="-1" title="{{emoji.code}}"></button>
|
|
||||||
{{/each}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class='footer'>
|
<div class='footer'>
|
||||||
<div class='info'></div>
|
<div class='info'></div>
|
||||||
|
@ -10,9 +10,9 @@ import { IMAGE_VERSION } from "pretty-text/emoji/version";
|
|||||||
|
|
||||||
const extendedEmoji = {};
|
const extendedEmoji = {};
|
||||||
|
|
||||||
export function registerEmoji(code, url) {
|
export function registerEmoji(code, url, group) {
|
||||||
code = code.toLowerCase();
|
code = code.toLowerCase();
|
||||||
extendedEmoji[code] = url;
|
extendedEmoji[code] = { url, group };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extendedEmojiList() {
|
export function extendedEmojiList() {
|
||||||
@ -92,7 +92,7 @@ function isReplacableInlineEmoji(string, index, inlineEmoji) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function performEmojiUnescape(string, opts) {
|
export function performEmojiUnescape(string, opts) {
|
||||||
if (!string || typeof string !== "string") {
|
if (!string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,6 +126,8 @@ export function performEmojiUnescape(string, opts) {
|
|||||||
} alt='${emojiVal}' class='${classes}'>`
|
} alt='${emojiVal}' class='${classes}'>`
|
||||||
: m;
|
: m;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function performEmojiEscape(string, opts) {
|
export function performEmojiEscape(string, opts) {
|
||||||
@ -143,6 +145,8 @@ export function performEmojiEscape(string, opts) {
|
|||||||
|
|
||||||
return m;
|
return m;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCustomEmoji(code, opts) {
|
export function isCustomEmoji(code, opts) {
|
||||||
@ -157,11 +161,11 @@ export function buildEmojiUrl(code, opts) {
|
|||||||
let url;
|
let url;
|
||||||
code = String(code).toLowerCase();
|
code = String(code).toLowerCase();
|
||||||
if (extendedEmoji.hasOwnProperty(code)) {
|
if (extendedEmoji.hasOwnProperty(code)) {
|
||||||
url = extendedEmoji[code];
|
url = extendedEmoji[code].url;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts && opts.customEmoji && opts.customEmoji[code]) {
|
if (opts && opts.customEmoji && opts.customEmoji[code]) {
|
||||||
url = opts.customEmoji[code];
|
url = opts.customEmoji[code].url || opts.customEmoji[code];
|
||||||
}
|
}
|
||||||
|
|
||||||
const noToneMatch = code.match(/([^:]+):?/);
|
const noToneMatch = code.match(/([^:]+):?/);
|
||||||
|
@ -979,3 +979,4 @@ a.inline-editable-field {
|
|||||||
@import "common/admin/admin_report_table";
|
@import "common/admin/admin_report_table";
|
||||||
@import "common/admin/admin_report_inline_table";
|
@import "common/admin/admin_report_inline_table";
|
||||||
@import "common/admin/admin_intro";
|
@import "common/admin/admin_intro";
|
||||||
|
@import "common/admin/admin_emojis";
|
||||||
|
50
app/assets/stylesheets/common/admin/admin_emojis.scss
Normal file
50
app/assets/stylesheets/common/admin/admin_emojis.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -556,7 +556,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#custom_emoji {
|
#custom_emoji {
|
||||||
width: 27%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body .inputs .branch {
|
.modal-body .inputs .branch {
|
||||||
|
@ -45,6 +45,8 @@ sup img.emoji {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-right: 1px solid $primary-low;
|
border-right: 1px solid $primary-low;
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-picker .category-icon {
|
.emoji-picker .category-icon {
|
||||||
|
@ -6,3 +6,7 @@
|
|||||||
.emoji-picker .category-icon {
|
.emoji-picker .category-icon {
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-picker .categories-column {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
@ -9,6 +9,7 @@ class Admin::EmojisController < Admin::AdminController
|
|||||||
def create
|
def create
|
||||||
file = params[:file] || params[:files].first
|
file = params[:file] || params[:files].first
|
||||||
name = params[:name] || File.basename(file.original_filename, ".*")
|
name = params[:name] || File.basename(file.original_filename, ".*")
|
||||||
|
group = params[:group] ? params[:group].downcase : nil
|
||||||
|
|
||||||
hijack do
|
hijack do
|
||||||
# fix the name
|
# fix the name
|
||||||
@ -26,11 +27,11 @@ class Admin::EmojisController < Admin::AdminController
|
|||||||
|
|
||||||
data =
|
data =
|
||||||
if upload.persisted?
|
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
|
if custom_emoji.save
|
||||||
Emoji.clear_cache
|
Emoji.clear_cache
|
||||||
{ name: custom_emoji.name, url: custom_emoji.upload.url }
|
{ name: custom_emoji.name, url: custom_emoji.upload.url, group: group }
|
||||||
else
|
else
|
||||||
good = false
|
good = false
|
||||||
failed_json.merge(errors: custom_emoji.errors.full_messages)
|
failed_json.merge(errors: custom_emoji.errors.full_messages)
|
||||||
|
@ -14,6 +14,7 @@ end
|
|||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# name :string not null
|
# name :string not null
|
||||||
# upload_id :integer not null
|
# upload_id :integer not null
|
||||||
|
# group :string
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
#
|
#
|
||||||
|
@ -6,9 +6,11 @@ class Emoji
|
|||||||
|
|
||||||
FITZPATRICK_SCALE ||= [ "1f3fb", "1f3fc", "1f3fd", "1f3fe", "1f3ff" ]
|
FITZPATRICK_SCALE ||= [ "1f3fb", "1f3fc", "1f3fd", "1f3fe", "1f3ff" ]
|
||||||
|
|
||||||
|
DEFAULT_GROUP ||= "default"
|
||||||
|
|
||||||
include ActiveModel::SerializerSupport
|
include ActiveModel::SerializerSupport
|
||||||
|
|
||||||
attr_accessor :name, :url, :tonable
|
attr_accessor :name, :url, :tonable, :group
|
||||||
|
|
||||||
def self.all
|
def self.all
|
||||||
Discourse.cache.fetch(cache_key("all_emojis")) { standard | custom }
|
Discourse.cache.fetch(cache_key("all_emojis")) { standard | custom }
|
||||||
@ -104,15 +106,19 @@ class Emoji
|
|||||||
result << Emoji.new.tap do |e|
|
result << Emoji.new.tap do |e|
|
||||||
e.name = emoji.name
|
e.name = emoji.name
|
||||||
e.url = emoji.upload&.url
|
e.url = emoji.upload&.url
|
||||||
|
e.group = emoji.group || DEFAULT_GROUP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Plugin::CustomEmoji.emojis.each do |name, url|
|
Plugin::CustomEmoji.emojis.each do |group, emojis|
|
||||||
result << Emoji.new.tap do |e|
|
emojis.each do |name, url|
|
||||||
e.name = name
|
result << Emoji.new.tap do |e|
|
||||||
url = (Discourse.base_uri + url) if url[/^\/[^\/]/]
|
e.name = name
|
||||||
e.url = url
|
url = (Discourse.base_uri + url) if url[/^\/[^\/]/]
|
||||||
|
e.url = url
|
||||||
|
e.group = group || DEFAULT_GROUP
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class EmojiSerializer < ApplicationSerializer
|
class EmojiSerializer < ApplicationSerializer
|
||||||
attributes :name, :url
|
attributes :name, :url, :group
|
||||||
end
|
end
|
||||||
|
@ -1651,7 +1651,6 @@ en:
|
|||||||
objects: Objects
|
objects: Objects
|
||||||
symbols: Symbols
|
symbols: Symbols
|
||||||
flags: Flags
|
flags: Flags
|
||||||
custom: Custom emojis
|
|
||||||
recent: Recently used
|
recent: Recently used
|
||||||
default_tone: No skin tone
|
default_tone: No skin tone
|
||||||
light_tone: Light skin tone
|
light_tone: Light skin tone
|
||||||
@ -1659,6 +1658,7 @@ en:
|
|||||||
medium_tone: Medium skin tone
|
medium_tone: Medium skin tone
|
||||||
medium_dark_tone: Medium dark skin tone
|
medium_dark_tone: Medium dark skin tone
|
||||||
dark_tone: Dark skin tone
|
dark_tone: Dark skin tone
|
||||||
|
default: Custom emojis
|
||||||
|
|
||||||
shared_drafts:
|
shared_drafts:
|
||||||
title: "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)"
|
help: "Add new emoji that will be available to everyone. (PROTIP: drag & drop multiple files at once)"
|
||||||
add: "Add New Emoji"
|
add: "Add New Emoji"
|
||||||
name: "Name"
|
name: "Name"
|
||||||
|
group: "Group"
|
||||||
image: "Image"
|
image: "Image"
|
||||||
delete_confirm: "Are you sure you want to delete the :%{name}: emoji?"
|
delete_confirm: "Are you sure you want to delete the :%{name}: emoji?"
|
||||||
|
|
||||||
|
7
db/migrate/20200116092259_add_group_to_custom_emojis.rb
Normal file
7
db/migrate/20200116092259_add_group_to_custom_emojis.rb
Normal 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
|
@ -29,17 +29,17 @@ class DbHelper
|
|||||||
|
|
||||||
text_columns.each do |table, columns|
|
text_columns.each do |table, columns|
|
||||||
set = columns.map do |column|
|
set = columns.map do |column|
|
||||||
replace = "REPLACE(#{column[:name]}, :from, :to)"
|
replace = "REPLACE(\"#{column[:name]}\", :from, :to)"
|
||||||
replace = truncate(replace, table, column)
|
replace = truncate(replace, table, column)
|
||||||
"#{column[:name]} = #{replace}"
|
"\"#{column[:name]}\" = #{replace}"
|
||||||
end.join(", ")
|
end.join(", ")
|
||||||
|
|
||||||
where = columns.map do |column|
|
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 ")
|
end.join(" OR ")
|
||||||
|
|
||||||
rows = DB.exec(<<~SQL, from: from, to: to, like: like)
|
rows = DB.exec(<<~SQL, from: from, to: to, like: like)
|
||||||
UPDATE #{table}
|
UPDATE \"#{table}\"
|
||||||
SET #{set}
|
SET #{set}
|
||||||
WHERE #{where}
|
WHERE #{where}
|
||||||
SQL
|
SQL
|
||||||
@ -55,17 +55,17 @@ class DbHelper
|
|||||||
|
|
||||||
text_columns.each do |table, columns|
|
text_columns.each do |table, columns|
|
||||||
set = columns.map do |column|
|
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)
|
replace = truncate(replace, table, column)
|
||||||
"#{column[:name]} = #{replace}"
|
"\"#{column[:name]}\" = #{replace}"
|
||||||
end.join(", ")
|
end.join(", ")
|
||||||
|
|
||||||
where = columns.map do |column|
|
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 ")
|
end.join(" OR ")
|
||||||
|
|
||||||
rows = DB.exec(<<~SQL, pattern: pattern, replacement: replacement, flags: flags, match: match)
|
rows = DB.exec(<<~SQL, pattern: pattern, replacement: replacement, flags: flags, match: match)
|
||||||
UPDATE #{table}
|
UPDATE \"#{table}\"
|
||||||
SET #{set}
|
SET #{set}
|
||||||
WHERE #{where}
|
WHERE #{where}
|
||||||
SQL
|
SQL
|
||||||
@ -84,9 +84,9 @@ class DbHelper
|
|||||||
next if excluded_tables.include?(r.table_name)
|
next if excluded_tables.include?(r.table_name)
|
||||||
|
|
||||||
rows = DB.query(<<~SQL, like: like)
|
rows = DB.query(<<~SQL, like: like)
|
||||||
SELECT #{r.column_name}
|
SELECT \"#{r.column_name}\"
|
||||||
FROM #{r.table_name}
|
FROM \"#{r.table_name}\"
|
||||||
WHERE #{r.column_name} LIKE :like
|
WHERE \""#{r.column_name}"\" LIKE :like
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
if rows.size > 0
|
if rows.size > 0
|
||||||
|
@ -6,21 +6,29 @@ require_dependency 'plugin/metadata'
|
|||||||
require_dependency 'auth'
|
require_dependency 'auth'
|
||||||
|
|
||||||
class Plugin::CustomEmoji
|
class Plugin::CustomEmoji
|
||||||
|
CACHE_KEY ||= "plugin-emoji"
|
||||||
def self.cache_key
|
def self.cache_key
|
||||||
@@cache_key ||= "plugin-emoji"
|
@@cache_key ||= CACHE_KEY
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.emojis
|
def self.emojis
|
||||||
@@emojis ||= {}
|
@@emojis ||= {}
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.register(name, url)
|
def self.clear_cache
|
||||||
@@cache_key = Digest::SHA1.hexdigest(cache_key + name)[0..10]
|
@@cache_key = CACHE_KEY
|
||||||
emojis[name] = url
|
@@emojis = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.unregister(name)
|
def self.register(name, url, group = Emoji::DEFAULT_GROUP)
|
||||||
emojis.delete(name)
|
@@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
|
end
|
||||||
|
|
||||||
def self.translations
|
def self.translations
|
||||||
@ -471,8 +479,9 @@ class Plugin::Instance
|
|||||||
DiscoursePluginRegistry.register_seed_path_builder(&block)
|
DiscoursePluginRegistry.register_seed_path_builder(&block)
|
||||||
end
|
end
|
||||||
|
|
||||||
def register_emoji(name, url)
|
def register_emoji(name, url, group = Emoji::DEFAULT_GROUP)
|
||||||
Plugin::CustomEmoji.register(name, url)
|
Plugin::CustomEmoji.register(name, url, group)
|
||||||
|
Emoji.clear_cache
|
||||||
end
|
end
|
||||||
|
|
||||||
def translate_emoji(from, to)
|
def translate_emoji(from, to)
|
||||||
|
@ -538,4 +538,30 @@ describe Plugin::Instance do
|
|||||||
expect(Reviewable.types).to match_array(current_list << new_element)
|
expect(Reviewable.types).to match_array(current_list << new_element)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
@ -59,22 +59,38 @@ RSpec.describe Admin::EmojisController do
|
|||||||
it 'should allow an admin to add a custom emoji' do
|
it 'should allow an admin to add a custom emoji' do
|
||||||
Emoji.expects(:clear_cache)
|
Emoji.expects(:clear_cache)
|
||||||
|
|
||||||
post "/admin/customize/emojis.json", params: {
|
post "/admin/customize/emojis.json", params: {
|
||||||
name: 'test',
|
name: 'test',
|
||||||
file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png")
|
file: fixture_file_upload("#{Rails.root}/spec/fixtures/images/logo.png")
|
||||||
}
|
}
|
||||||
|
|
||||||
custom_emoji = CustomEmoji.last
|
custom_emoji = CustomEmoji.last
|
||||||
upload = custom_emoji.upload
|
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)
|
it 'should allow an admin to add a custom emoji with a custom group' do
|
||||||
expect(data["errors"]).to eq(nil)
|
Emoji.expects(:clear_cache)
|
||||||
expect(data["name"]).to eq(custom_emoji.name)
|
|
||||||
expect(data["url"]).to eq(upload.url)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
deleteCachedInlineOnebox
|
deleteCachedInlineOnebox
|
||||||
} from "pretty-text/inline-oneboxer";
|
} from "pretty-text/inline-oneboxer";
|
||||||
import { extractDataAttribute } from "pretty-text/engines/discourse-markdown-it";
|
import { extractDataAttribute } from "pretty-text/engines/discourse-markdown-it";
|
||||||
|
import { registerEmoji } from "pretty-text/emoji";
|
||||||
|
|
||||||
QUnit.module("lib:pretty-text");
|
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 => {
|
QUnit.test("extractDataAttribute", assert => {
|
||||||
assert.deepEqual(extractDataAttribute("foo="), ["data-foo", ""]);
|
assert.deepEqual(extractDataAttribute("foo="), ["data-foo", ""]);
|
||||||
assert.deepEqual(extractDataAttribute("foo=bar"), ["data-foo", "bar"]);
|
assert.deepEqual(extractDataAttribute("foo=bar"), ["data-foo", "bar"]);
|
||||||
|
Reference in New Issue
Block a user