Add initial support for custom emojis

This commit is contained in:
JC Brand 2019-10-16 17:17:29 +02:00
parent 2ed8b4660e
commit e6e23a1a82
16 changed files with 837 additions and 707 deletions

View File

@ -6,6 +6,9 @@
- #1691 Fix `collection.chatbox is undefined` errors
- #1733 New message notifications for a minimized chat stack on top of each other
- Prevent editing of sent file uploads.
- Initial support for sending custom emojis. Currently only between Converse
instances. Still working out a wire protocol for compatibility with other clients.
To add custom emojis, edit the `emojis.json` file.
### Breaking changes

View File

@ -68,7 +68,7 @@ serve_bg: stamp-npm
dist/converse-no-dependencies.js: src webpack.common.js webpack.nodeps.js stamp-npm @converse/headless
npm run nodeps
GETTEXT = $(XGETTEXT) --from-code=UTF-8 --language=JavaScript --keyword=__ -keyword=___ --force-po --output=locale/converse.pot --package-name=Converse.js --copyright-holder="Jan-Carel Brand" --package-version=5.0.4 dist/converse-no-dependencies.js -c
GETTEXT = $(XGETTEXT) --from-code=UTF-8 --language=JavaScript --keyword=__ --keyword=___ --force-po --output=locale/converse.pot --package-name=Converse.js --copyright-holder="Jan-Carel Brand" --package-version=5.0.4 dist/converse-no-dependencies.js -c
.PHONY: pot
pot: dist/converse-no-dependencies.js

BIN
dist/custom_emojis/converse.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
dist/custom_emojis/xmpp.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -664,6 +664,63 @@ domain_placeholder
The placeholder text shown in the domain input on the registration form.
emoji_categories
----------------
* Default:::
{
"smileys": ":grinning:",
"people": ":thumbsup:",
"activity": ":soccer:",
"travel": ":motorcycle:",
"objects": ":bomb:",
"nature": ":rainbow:",
"food": ":hotdog:",
"symbols": ":musical_note:",
"flags": ":flag_ac:",
"custom": ":converse:"
}
This setting lets you define the categories that are available in the emoji
picker, as well as the default image that's shown for each category.
The keys of the map are the categories and the values are the shortnames of the
representative images.
If you want to remove a category, don't just remove the key, instead set its
value to ``undefined``.
Due to restrictions intended to prevent addition of undeclared configuration
settings, it's not possible to add new emoji categories. There is however a
``custom`` category where you can put your own custom emojis (also known as
"stickers").
To add custom emojis, you need to edit ``src/headless/emojis.json`` to add new
entries to the map under the ``custom`` key.
emoji_categories_label
----------------------
* Default:::
{
"smileys": "Smileys and emotions",
"people": "People",
"activity": "Activities",
"travel": "Travel",
"objects": "Objects",
"nature": "Animals and nature",
"food": "Food and drink",
"symbols": "Symbols",
"flags": "Flags",
"custom": "Stickers"
}
This setting lets you pass in the text value that goes into the `title`
attribute for the emoji categories. These strings will be translated, but for
your custom text to be translatable, you'll need to wrap it in `__()``
somewhere in your own code.
emoji_image_path
----------------

File diff suppressed because it is too large Load Diff

View File

@ -7,100 +7,103 @@
vertical-align: -0.1em;
}
.toggle-smiley {
a.toggle-smiley {
padding: 0;
}
.emoji-picker.toolbar-menu {
padding-top: 0;
padding-bottom: 0;
background-color: var(--chat-head-color);
.emoji-picker__container {
display: flex;
flex-direction: column;
overflow-y: hidden;
background: white;
.emoji-picker__lists {
height: 100%;
overflow-y: auto;
.emoji-category__heading {
cursor: auto;
color: var(--subdued-color);
font-size: var(--font-size);
padding: 0.5em 0 0 0.5em;
}
.sendXMPPMessage {
.toggle-smiley {
a.toggle-smiley {
padding: 0;
}
.emoji-picker.toolbar-menu {
min-width: 23rem;
padding-top: 0;
padding-bottom: 0;
background-color: var(--chat-head-color);
.emoji-picker__container {
display: flex;
flex-direction: column;
}
.emoji-skintone-picker {
display: flex;
label {
margin: 0;
padding: 0 0.5em;
white-space: nowrap;
font-size: var(--font-size-small);
color: var(--heading-color);
}
li {
padding: 0 0.25em;
}
padding: 0.5em 0;
background-color: var(--chat-head-color);
width: auto;
font-size: var(--font-size);
}
}
.emoji-picker {
background-color: white;
padding: 0.5em;
li {
margin-left: 0;
cursor: pointer;
list-style: none;
position: relative;
&.insert-emoji {
margin: 0;
height: 32px;
width: 32px;
&.picked {
background-color: var(--highlight-color);
overflow-y: hidden;
background: white;
.emoji-picker__lists {
height: 100%;
overflow-y: auto;
.emoji-category__heading {
cursor: auto;
color: var(--subdued-color);
font-size: var(--font-size);
padding: 0.5em 0 0 0.5em;
}
a {
display: flex;
flex-direction: column;
}
.emoji-skintone-picker {
display: flex;
label {
margin: 0;
padding: 0 0.5em;
white-space: nowrap;
font-size: var(--font-size-small);
color: var(--heading-color);
}
li {
padding: 0 0.25em;
}
padding: 0.5em 0;
background-color: var(--chat-head-color);
width: auto;
font-size: var(--font-size);
}
}
.emoji-picker {
background-color: white;
padding: 0.5em;
li {
margin-left: 0;
cursor: pointer;
list-style: none;
position: relative;
&.insert-emoji {
margin: 0;
height: 32px;
width: 32px;
&.picked {
background-color: var(--highlight-color);
}
a {
&:hover {
background-color: var(--highlight-color);
}
font-size: var(--font-size-huge);
}
}
}
}
.emoji-picker__header {
display: flex;
flex-direction: column;
padding-top: 0.5em;
background-color: var(--chat-head-color);
.emoji-search {
width: auto;
margin: 0.25em;
height: 2em;
font-size: var(--font-size-small);
}
ul {
display: flex;
flex-direction: row;
justify-content: space-between;
.emoji-category {
&.picked {
background-color: white;
border: 1px var(--chat-head-color) solid;
border-bottom: none;
}
padding: 0.25em;
font-size: var(--font-size-huge);
&:hover {
background-color: var(--highlight-color);
}
font-size: var(--font-size-huge);
}
}
}
}
.emoji-picker__header {
display: flex;
flex-direction: column;
padding-top: 0.5em;
background-color: var(--chat-head-color);
.emoji-search {
width: auto;
margin: 0.25em;
height: 2em;
font-size: var(--font-size-small);
}
ul {
display: flex;
flex-direction: row;
justify-content: space-between;
.emoji-category {
&.picked {
background-color: white;
border: 1px var(--chat-head-color) solid;
border-bottom: none;
}
padding: 0.25em;
font-size: var(--font-size-huge);
&:hover {
background-color: var(--highlight-color);
}
}
}
@ -110,20 +113,22 @@
}
.chatroom {
.toggle-smiley {
.emoji-picker.toolbar-menu {
background-color: var(--chatroom-head-color);
.emoji-picker__container {
background: white;
.emoji-skintone-picker {
background-color: var(--chatroom-head-color);
}
.emoji-picker__header {
background-color: var(--chatroom-head-color);
.emoji-category {
&.picked {
border: 1px var(--chatroom-head-color) solid;
border-bottom: none;
.sendXMPPMessage {
.toggle-smiley {
.emoji-picker.toolbar-menu {
background-color: var(--chatroom-head-color);
.emoji-picker__container {
background: white;
.emoji-skintone-picker {
background-color: var(--chatroom-head-color);
}
.emoji-picker__header {
background-color: var(--chatroom-head-color);
.emoji-category {
&.picked {
border: 1px var(--chatroom-head-color) solid;
border-bottom: none;
}
}
}
}

View File

@ -121,7 +121,7 @@
await u.waitUntil(() => u.isVisible(view.el.querySelector('.toggle-smiley .emoji-picker__container')));
const picker = await u.waitUntil(() => view.el.querySelector('.toggle-smiley .emoji-picker__container'));
const input = picker.querySelector('.emoji-search');
expect(sizzle('.insert-emoji:not(.hidden)', picker).length).toBe(1589);
expect(sizzle('.insert-emoji:not(.hidden)', picker).length).toBe(1591);
expect(view.emoji_picker_view.model.get('query')).toBeUndefined();
input.value = 'smiley';

View File

@ -1026,9 +1026,10 @@
// Non-https images aren't rendered
base_url = document.URL.split(window.location.pathname)[0];
message = base_url+"/logo/conversejs-filled.svg";
expect(view.el.querySelectorAll('img').length).toBe(4);
const chat_content = view.el.querySelector('.chat-content');
expect(chat_content.querySelectorAll('img').length).toBe(4);
test_utils.sendMessage(view, message);
expect(view.el.querySelectorAll('img').length).toBe(4);
expect(chat_content.querySelectorAll('img').length).toBe(4);
done();
}));

View File

@ -313,6 +313,28 @@ _converse.__ = function (str) {
return i18n.translate.apply(i18n, arguments);
};
/**
* A no-op method which is used to signal to gettext that the passed in string
* should be included in the pot translation file.
*
* In contrast to the double-underscore method, the triple underscore method
* doesn't actually translate the strings.
*
* One reason for this method might be because we're using strings we cannot
* send to the translation function because they require variable interpolation
* and we don't yet have the variables at scan time.
*
* @method ___
* @private
* @memberOf _converse
* @param { String } str
*/
_converse.___ = function (str) {
return str;
}
const __ = _converse.__;
const PROMISES = [

View File

@ -10,7 +10,7 @@ import * as twemoji from "twemoji";
import _ from "./lodash.noconflict";
import converse from "./converse-core";
const { Backbone, } = converse.env;
const { Backbone } = converse.env;
const u = converse.env.utils;
const ASCII_LIST = {
@ -166,7 +166,7 @@ converse.plugins.add('converse-emoji', {
* loaded by converse.js's plugin machinery.
*/
const { _converse } = this;
const { __ } = _converse;
const { ___ } = _converse;
_converse.api.settings.update({
'emoji_image_path': twemoji.default.base,
@ -179,23 +179,32 @@ converse.plugins.add('converse-emoji', {
"nature": ":rainbow:",
"food": ":hotdog:",
"symbols": ":musical_note:",
"flags": ":flag_ac:"
"flags": ":flag_ac:",
"custom": ":converse:"
},
// We use the triple-underscore method which doesn't actually
// translate but does signify to gettext that these strings should
// go into the POT file. The translation then happens in the
// template. We do this so that users can pass in their own
// strings via converse.initialize, which is before __ is
// available.
'emoji_category_labels': {
"smileys": ___("Smileys and emotions"),
"people": ___("People"),
"activity": ___("Activities"),
"travel": ___("Travel"),
"objects": ___("Objects"),
"nature": ___("Animals and nature"),
"food": ___("Food and drink"),
"symbols": ___("Symbols"),
"flags": ___("Flags"),
"custom": ___("Stickers")
}
});
_converse.api.promises.add(['emojisInitialized']);
twemoji.default.base = _converse.emoji_image_path;
_converse.emoji_category_labels = {
"smileys": __("Smileys and emotions"),
"people": __("People"),
"activity": __("Activities"),
"travel": __("Travel"),
"objects": __("Objects"),
"nature": __("Animals and nature"),
"food": __("Food and drink"),
"symbols": __("Symbols"),
"flags": __("Flags")
}
/**
* Model for storing data related to the Emoji picker widget
@ -253,28 +262,43 @@ converse.plugins.add('converse-emoji', {
*/
getEmojiRenderer () {
const how = {
'attributes': (icon) => {
'attributes': icon => {
const codepoint = twemoji.default.convert.toCodePoint(icon);
return {'title': `${u.getEmojisByAtrribute('cp')[codepoint]['sn']} ${icon}`}
}
};
const toUnicode = u.shortnameToUnicode;
return _converse.use_system_emojis ? toUnicode: text => twemoji.default.parse(toUnicode(text), how);
const transform = u.shortnamesToEmojis;
return _converse.use_system_emojis ? transform : text => twemoji.default.parse(transform(text), how);
},
/**
* Returns unicode represented by the passed in shortname.
* @method u.shortnameToUnicode
* Returns an emoji represented by the passed in shortname.
* Scans the passed in text for shortnames and replaces them with
* emoji unicode glyphs or alternatively if it's a custom emoji
* without unicode representation then markup for an HTML image tag
* is returned.
*
* The shortname needs to be defined in `emojis.json`
* and needs to have either a `cp` attribute for the codepoint, or
* an `url` attribute which points to the source for the image.
*
* @method u.shortnamesToEmojis
* @param {string} str - String containg the shortname(s)
*/
shortnameToUnicode (str) {
shortnamesToEmojis (str, unicode_only=false) {
str = str.replace(_converse.emojis.shortnames_regex, shortname => {
if ((typeof shortname === 'undefined') || (shortname === '') || (!_converse.emoji_shortnames.includes(shortname))) {
// if the shortname doesnt exist just return the entire match
return shortname;
}
const unicode = _converse.emojis_map[shortname].cp.toUpperCase();
return convert(unicode);
const codepoint = _converse.emojis_map[shortname].cp;
if (codepoint) {
return convert(codepoint.toUpperCase());
} else if (unicode_only) {
return shortname;
} else {
return `<img class="emoji" draggable="false" alt="${shortname}" src="${_converse.emojis_map[shortname].url}"/>`;
}
});
// Also replace ASCII smileys
str = str.replace(ASCII_REPLACE_REGEX, (entire, m1, m2, m3) => {
@ -289,6 +313,15 @@ converse.plugins.add('converse-emoji', {
return str;
},
/**
* Returns unicode represented by the passed in shortname.
* @method u.shortnameToUnicode
* @param {string} str - String containg the shortname(s)
*/
shortnameToUnicode (str) {
return this.shortnamesToEmojis(str, true);
},
/**
* Determines whether the passed in string is just a single emoji shortname;
* @method u.isSingleEmoji

View File

@ -100,8 +100,8 @@ converse.plugins.add('converse-muc', {
/* The initialize function gets called as soon as the plugin is
* loaded by converse.js's plugin machinery.
*/
const { _converse } = this,
{ __ } = _converse;
const { _converse } = this;
const { __, ___ } = _converse;
// Configuration values for this plugin
// ====================================
@ -128,15 +128,6 @@ converse.plugins.add('converse-muc', {
}
function ___ (str) {
/* This is part of a hack to get gettext to scan strings to be
* translated. Strings we cannot send to the function above because
* they require variable interpolation and we don't yet have the
* variables at scan time.
*/
return str;
}
/* https://xmpp.org/extensions/xep-0045.html
* ----------------------------------------
* 100 message Entering a groupchat Inform user that any occupant is allowed to see the user's full JID

View File

@ -1,4 +1,8 @@
{
"custom": {
":converse:":{"sn":":converse:","url":"/dist/custom_emojis/converse.png","c":"custom"},
":xmpp:":{"sn":":xmpp:","url":"/dist/custom_emojis/xmpp.png","c":"custom"}
},
"smileys": {
":smiley:":{"sn":":smiley:","cp":"1f603","sns":[],"c":"smileys"},
":smile:":{"sn":":smile:","cp":"1f604","sns":[],"c":"smileys"},
@ -2692,4 +2696,3 @@
":tone5:":{"sn":":tone5:","cp":"1f3ff","sns":[],"c":"modifier"}
}
}

View File

@ -4,9 +4,12 @@
{[ if (!o.query) { ]}
<ul>
{[ Object.keys(o.emoji_categories).forEach(function (category) { ]}
<li data-category="{{{category}}}" class="emoji-category {{{o.current_category}}} {{{ category}}} {[ if (o.current_category === category) { ]} picked {[ } ]}" title="{{{o._converse.emoji_category_labels[category]}}}">
{[ if (o.emoji_categories[category]) { ]}
<li data-category="{{{category}}}" class="emoji-category {{{o.current_category}}} {{{ category}}} {[ if (o.current_category === category) { ]} picked {[ } ]}"
title="{{{ o.__(o._converse.emoji_category_labels[category]) }}}">
<a class="pick-category" href="#emoji-picker-{{{category}}}" data-category="{{{category}}}"> {{ o.transformCategory(o.emoji_categories[category]) }} </a>
</li>
{[ } ]}
{[ }); ]}
</ul>
{[ } ]}
@ -24,15 +27,17 @@
</ul>
{[ } else { ]}
{[ Object.keys(o.emoji_categories).forEach(function (category) { ]}
<a id="emoji-picker-{{{category}}}" class="emoji-category__heading" data-category="{{{category}}}">{{{o._converse.emoji_category_labels[category]}}}</a>
<ul class="emoji-picker" data-category="{{{category}}}">
{[ Object.values(o.emojis_by_category[category]).forEach(function (emoji) { ]}
<li class="emoji insert-emoji {[ if (o.shouldBeHidden(emoji.sn)) { ]} hidden {[ }; ]}"
data-emoji="{{{emoji.sn}}}" title="{{{emoji.sn}}}">
<a href="#" data-emoji="{{{emoji.sn}}}"> {{ o.transform(emoji.sn) }} </a>
</li>
{[ }); ]}
</ul>
{[ if (o.emoji_categories[category]) { ]}
<a id="emoji-picker-{{{category}}}" class="emoji-category__heading" data-category="{{{category}}}">{{{ o.__(o._converse.emoji_category_labels[category]) }}} </a>
<ul class="emoji-picker" data-category="{{{category}}}">
{[ Object.values(o.emojis_by_category[category]).forEach(function (emoji) { ]}
<li class="emoji insert-emoji {[ if (o.shouldBeHidden(emoji.sn)) { ]} hidden {[ }; ]}"
data-emoji="{{{emoji.sn}}}" title="{{{emoji.sn}}}">
<a href="#" data-emoji="{{{emoji.sn}}}"> {{ o.transform(emoji.sn) }} </a>
</li>
{[ }); ]}
</ul>
{[ } ]}
{[ }); ]}
{[ } ]}
</div>

View File

@ -1,6 +1,5 @@
/* global __dirname, module, process */
/* global __dirname, module */
const path = require('path');
const webpack = require('webpack');
module.exports = {
output: {

View File

@ -7,11 +7,11 @@ module.exports = merge(common, {
mode: "development",
devtool: "inline-source-map",
devServer: {
contentBase: "./dist"
contentBase: "./"
},
plugins: [
new HTMLWebpackPlugin({
title: 'Production',
title: 'Converse.js Dev',
template: 'webpack.html'
})
],