2
0
Эх сурвалжийг харах

emojis: fix rendering of custom emojis

JC Brand 5 жил өмнө
parent
commit
be20b8e1a0

+ 3 - 1
karma.conf.js

@@ -12,6 +12,7 @@ module.exports = function(config) {
       { pattern: "dist/emojis.js", served: true },
       "dist/converse.js",
       "dist/converse.css",
+      { pattern: "dist/images/**/*.*", included: false },
       { pattern: "dist/webfonts/**/*.*", included: false },
       { pattern: "dist/\@fortawesome/fontawesome-free/sprites/solid.svg",
         watched: false,
@@ -60,7 +61,8 @@ module.exports = function(config) {
     ],
 
     proxies: {
-      "/dist/\@fortawesome/fontawesome-free/sprites/solid.svg": "/base/dist/\@fortawesome/fontawesome-free/sprites/solid.svg"
+      "/dist/\@fortawesome/fontawesome-free/sprites/solid.svg": "/base/dist/\@fortawesome/fontawesome-free/sprites/solid.svg",
+      "/dist/images/custom_emojis/": "/base/dist/images/custom_emojis/"
     },
 
     client: {

+ 46 - 1
spec/emojis.js

@@ -100,7 +100,6 @@ describe("Emojis", function () {
             done();
         }));
 
-
         it("allows you to search for particular emojis",
             mock.initConverse(
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
@@ -239,5 +238,51 @@ describe("Emojis", function () {
             expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
             done()
         }));
+
+
+        it("can show custom emojis",
+            mock.initConverse(
+                ['rosterGroupsFetched', 'chatBoxesFetched'],
+                { emoji_categories: {
+                    "smileys": ":grinning:",
+                    "people": ":thumbsup:",
+                    "activity": ":soccer:",
+                    "travel": ":motorcycle:",
+                    "objects": ":bomb:",
+                    "nature": ":rainbow:",
+                    "food": ":hotdog:",
+                    "symbols": ":musical_note:",
+                    "flags": ":flag_ac:",
+                    "custom": ':xmpp:'
+                } },
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.api.chatviews.get(contact_jid);
+
+            const toolbar = await u.waitUntil(() => view.el.querySelector('ul.chat-toolbar'));
+            expect(toolbar.querySelectorAll('li.toggle-smiley__container').length).toBe(1);
+            toolbar.querySelector('a.toggle-smiley').click();
+            await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')), 1000);
+            const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'), 1000);
+            const custom_category = picker.querySelector('.pick-category[data-category="custom"]');
+            expect(custom_category.innerHTML.replace(/<!---->/g, '').trim()).toBe(
+                '<img class="emoji" draggable="false" title=":xmpp:" alt=":xmpp:" src="/dist/images/custom_emojis/xmpp.png">');
+
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            textarea.value = 'Running tests for :converse:';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            const body = view.el.querySelector('converse-chat-message-body');
+            expect(body.innerHTML.replace(/<!---->/g, '').trim()).toBe(
+                'Running tests for <img class="emoji" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
+            done();
+        }));
     });
 });

+ 11 - 9
src/headless/converse-chat.js

@@ -951,21 +951,23 @@ converse.plugins.add('converse-chat', {
             getOutgoingMessageAttributes (text, spoiler_hint) {
                 const is_spoiler = this.get('composing_spoiler');
                 const origin_id = u.getUniqueId();
+                const body = text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined;
                 return {
+                    'from': _converse.bare_jid,
+                    'fullname': _converse.xmppstatus.get('fullname'),
                     'id': origin_id,
+                    'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
                     'jid': this.get('jid'),
-                    'nickname': this.get('nickname'),
+                    'message': body,
                     'msgid': origin_id,
-                    'origin_id': origin_id,
-                    'fullname': _converse.xmppstatus.get('fullname'),
-                    'from': _converse.bare_jid,
-                    'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
+                    'nickname': this.get('nickname'),
                     'sender': 'me',
-                    'time': (new Date()).toISOString(),
-                    'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined,
-                    'is_spoiler': is_spoiler,
                     'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
-                    'type': this.get('message_type')
+                    'time': (new Date()).toISOString(),
+                    'type': this.get('message_type'),
+                    body,
+                    is_spoiler,
+                    origin_id
                 }
             },
 

+ 100 - 61
src/headless/converse-emoji.js

@@ -3,10 +3,11 @@
  * @copyright 2020, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import { Model } from '@converse/skeletor/src/model.js';
-import { find, uniq } from "lodash-es";
 import * as twemoji from "twemoji";
+import { Model } from '@converse/skeletor/src/model.js';
 import { _converse, api, converse } from "./converse-core";
+import { find, isString, uniq } from "lodash-es";
+import { html } from 'lit-html';
 
 const u = converse.env.utils;
 
@@ -27,6 +28,7 @@ const ASCII_LIST = {
 };
 
 
+let shortnames_regex;
 const ASCII_REGEX = '(\\*\\\\0\\/\\*|\\*\\\\O\\/\\*|\\-___\\-|\\:\'\\-\\)|\'\\:\\-\\)|\'\\:\\-D|\\>\\:\\-\\)|>\\:\\-\\)|\'\\:\\-\\(|\\>\\:\\-\\(|>\\:\\-\\(|\\:\'\\-\\(|O\\:\\-\\)|0\\:\\-3|0\\:\\-\\)|0;\\^\\)|O;\\-\\)|0;\\-\\)|O\\:\\-3|\\-__\\-|\\:\\-Þ|\\:\\-Þ|\\<\\/3|<\\/3|\\:\'\\)|\\:\\-D|\'\\:\\)|\'\\=\\)|\'\\:D|\'\\=D|\\>\\:\\)|>\\:\\)|\\>;\\)|>;\\)|\\>\\=\\)|>\\=\\)|;\\-\\)|\\*\\-\\)|;\\-\\]|;\\^\\)|\'\\:\\(|\'\\=\\(|\\:\\-\\*|\\:\\^\\*|\\>\\:P|>\\:P|X\\-P|\\>\\:\\[|>\\:\\[|\\:\\-\\(|\\:\\-\\[|\\>\\:\\(|>\\:\\(|\\:\'\\(|;\\-\\(|\\>\\.\\<|>\\.<|#\\-\\)|%\\-\\)|X\\-\\)|\\\\0\\/|\\\\O\\/|0\\:3|0\\:\\)|O\\:\\)|O\\=\\)|O\\:3|B\\-\\)|8\\-\\)|B\\-D|8\\-D|\\-_\\-|\\>\\:\\\\|>\\:\\\\|\\>\\:\\/|>\\:\\/|\\:\\-\\/|\\:\\-\\.|\\:\\-P|\\:Þ|\\:Þ|\\:\\-b|\\:\\-O|O_O|\\>\\:O|>\\:O|\\:\\-X|\\:\\-#|\\:\\-\\)|\\(y\\)|\\<3|<3|\\:D|\\=D|;\\)|\\*\\)|;\\]|;D|\\:\\*|\\=\\*|\\:\\(|\\:\\[|\\=\\(|\\:@|;\\(|D\\:|\\:\\$|\\=\\$|#\\)|%\\)|X\\)|B\\)|8\\)|\\:\\/|\\:\\\\|\\=\\/|\\=\\\\|\\:L|\\=L|\\:P|\\=P|\\:b|\\:O|\\:X|\\:#|\\=X|\\=#|\\:\\)|\\=\\]|\\=\\)|\\:\\])';
 const ASCII_REPLACE_REGEX = new RegExp("<object[^>]*>.*?<\/object>|<span[^>]*>.*?<\/span>|<(?:object|embed|svg|img|div|span|p|a)[^>]*>|((\\s|^)"+ASCII_REGEX+"(?=\\s|$|[!,.?]))", "gi");
 
@@ -54,6 +56,74 @@ function convert (unicode) {
 }
 
 
+function getTonedEmojis () {
+    if (!_converse.toned_emojis) {
+        _converse.toned_emojis = uniq(
+            Object.values(_converse.emojis.json.people)
+                .filter(person => person.sn.includes('_tone'))
+                .map(person => person.sn.replace(/_tone[1-5]/, ''))
+        );
+    }
+    return _converse.toned_emojis;
+}
+
+
+function convertASCII2Emoji (str) {
+    // Replace ASCII smileys
+    return str.replace(ASCII_REPLACE_REGEX, (entire, m1, m2, m3) => {
+        if( (typeof m3 === 'undefined') || (m3 === '') || (!(u.unescapeHTML(m3) in ASCII_LIST)) ) {
+            // if the ascii doesnt exist just return the entire match
+            return entire;
+        }
+        m3 = u.unescapeHTML(m3);
+        const unicode = ASCII_LIST[m3].toUpperCase();
+        return m2+convert(unicode);
+    });
+}
+
+
+function getEmojiMarkup (shortname, unicode_only) {
+    if ((typeof shortname === 'undefined') || (shortname === '') || (!_converse.emoji_shortnames.includes(shortname))) {
+        // if the shortname doesnt exist just return the entire match
+        return shortname;
+    }
+    const codepoint = _converse.emojis_map[shortname].cp;
+    if (codepoint) {
+        return convert(codepoint.toUpperCase());
+    } else if (unicode_only) {
+        return shortname;
+    } else {
+        return html`<img class="emoji" draggable="false" title="${shortname}" alt="${shortname}" src="${_converse.emojis_map[shortname].url}">`;
+    }
+}
+
+
+function addEmojisMarkup (text, unicode_only=false) {
+    const original_text = text;
+    let list = [text];
+    const references = [...text.matchAll(shortnames_regex)];
+    if (references.length) {
+        references.map(ref => {
+            ref.begin = ref.index;
+            ref.end = ref.index+ref[0].length;
+            return ref;
+        })
+        .sort((a, b) => b.begin - a.begin)
+        .forEach(ref => {
+            const text = list.shift();
+            const shortname = original_text.slice(ref.begin, ref.end);
+            const emoji = getEmojiMarkup(shortname, unicode_only);
+            if (isString(emoji)) {
+                list = [text.slice(0, ref.begin) + emoji + text.slice(ref.end), ...list];
+            } else {
+                list = [text.slice(0, ref.begin), emoji, text.slice(ref.end), ...list];
+            }
+        });
+    }
+    return list;
+}
+
+
 converse.plugins.add('converse-emoji', {
 
     initialize () {
@@ -115,42 +185,15 @@ converse.plugins.add('converse-emoji', {
             }
         });
 
-        function getTonedEmojis () {
-            if (!_converse.toned_emojis) {
-                _converse.toned_emojis = uniq(
-                    Object.values(_converse.emojis.json.people)
-                        .filter(person => person.sn.includes('_tone'))
-                        .map(person => person.sn.replace(/_tone[1-5]/, ''))
-                );
-            }
-            return _converse.toned_emojis;
-        }
-
-
         /************************ BEGIN Utils ************************/
         // Closured cache
         const emojis_by_attribute = {};
 
         Object.assign(u, {
-
-            /**
-             * Replaces emoji shortnames in the passed-in string with unicode or image-based emojis
-             * (based on the value of `use_system_emojis`).
-             * @method u.addEmoji
-             * @param {string} text = The text
-             * @returns {string} The text with shortnames replaced with emoji
-             *  unicodes or images.
-             */
-            addEmoji (text) {
-                return u.getEmojiRenderer()(text);
-            },
-
             /**
              * Based on the value of `use_system_emojis` will return either
              * a function that converts emoji shortnames into unicode glyphs
-             * (see {@link u.shortnameToUnicode} or one that converts them into images.
-             * unicode emojis
-             * @method u.getEmojiRenderer
+             * (see {@link u.shortnamesToEmojis} or one that converts them into images.
              * @returns {function}
              */
             getEmojiRenderer () {
@@ -164,55 +207,51 @@ converse.plugins.add('converse-emoji', {
                 return api.settings.get('use_system_emojis') ? transform : text => twemoji.default.parse(transform(text), how);
             },
 
+            /**
+             * Replaces emoji shortnames in the passed-in string with unicode or image-based emojis
+             * (based on the value of `use_system_emojis`).
+             * @method u.addEmoji
+             * @param {string} text = The text
+             * @returns {string} The text with shortnames replaced with emoji
+             *  unicodes or images.
+             */
+            addEmoji (text) {
+                return u.getEmojiRenderer()(text);
+            },
+
             /**
              * 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.
+             * without unicode representation then a lit-html TemplateResult
+             * which represents image tag markup 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)
+             * @param {String} str - String containg the shortname(s)
+             * @param {Boolean} unicode_only - Whether emojis are rendered as
+             * unicode codepoints. If so, the returned result will be an array
+             * with containing one string, because the emojis themselves will
+             * also be strings. If set to false, emojis will be represented by
+             * lit-html TemplateResult objects.
+             * @returns {Array} An array of at least one string, or otherwise
+             * strings and lit-html TemplateResult objects.
              */
             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 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) => {
-                    if( (typeof m3 === 'undefined') || (m3 === '') || (!(u.unescapeHTML(m3) in ASCII_LIST)) ) {
-                        // if the ascii doesnt exist just return the entire match
-                        return entire;
-                    }
-                    m3 = u.unescapeHTML(m3);
-                    const unicode = ASCII_LIST[m3].toUpperCase();
-                    return m2+convert(unicode);
-                });
-                return str;
+                str = convertASCII2Emoji(str);
+                return addEmojisMarkup(str, unicode_only);
             },
 
             /**
              * Returns unicode represented by the passed in shortname.
              * @method u.shortnameToUnicode
-             * @param {string} str - String containg the shortname(s)
+             * @param {string} str - String containing the shortname(s)
              */
             shortnameToUnicode (str) {
-                return this.shortnamesToEmojis(str, true);
+                return u.shortnamesToEmojis(str, true)[0];
             },
 
             /**
@@ -290,7 +329,7 @@ converse.plugins.add('converse-emoji', {
                     _converse.emoji_shortnames = _converse.emojis_list.map(m => m.sn);
 
                     const getShortNames = () => _converse.emoji_shortnames.map(s => s.replace(/[+]/g, "\\$&")).join('|');
-                    _converse.emojis.shortnames_regex = new RegExp("<object[^>]*>.*?<\/object>|<span[^>]*>.*?<\/span>|<(?:object|embed|svg|img|div|span|p|a)[^>]*>|("+getShortNames()+")", "gi");
+                    shortnames_regex = new RegExp(getShortNames(), "gi");
 
                     _converse.emojis.toned = getTonedEmojis();
                     _converse.emojis.initialized.resolve();

+ 1 - 3
src/headless/converse-muc.js

@@ -964,8 +964,6 @@ converse.plugins.add('converse-muc', {
                 if (!raw_mentions) return [original_message, []];
 
                 const known_nicknames = this.getAllKnownNicknames();
-                const known_nicknames_with_at_regex = this.getAllKnownNicknamesRegex();
-                const getMatchesForNickRegex = nick_regex => [...findRegexInMessage(nick_regex)];
                 const getMatchingNickname = p.findFirstMatchInArray(known_nicknames);
 
                 const uriFromNickname = nickname => {
@@ -985,7 +983,7 @@ converse.plugins.add('converse-muc', {
                     return { begin, end, value, type, uri }
                 }
 
-                const mentions = getMatchesForNickRegex(known_nicknames_with_at_regex);
+                const mentions = [...findRegexInMessage(this.getAllKnownNicknamesRegex())];
                 const references = mentions.map(matchToReference);
 
                 const [updated_message, updated_references] = p.reduceTextFromReferences(

+ 2 - 2
src/headless/emojis.json

@@ -1,7 +1,7 @@
 {
 "custom": {
-":converse:":{"sn":":converse:","url":"/dist/custom_emojis/converse.png","c":"custom"},
-":xmpp:":{"sn":":xmpp:","url":"/dist/custom_emojis/xmpp.png","c":"custom"}
+":converse:":{"sn":":converse:","url":"/dist/images/custom_emojis/converse.png","c":"custom"},
+":xmpp:":{"sn":":xmpp:","url":"/dist/images/custom_emojis/xmpp.png","c":"custom"}
 },
 "smileys": {
 ":smiley:":{"sn":":smiley:","cp":"1f603","sns":[],"c":"smileys"},

+ 7 - 8
src/templates/directives/body.js

@@ -30,12 +30,12 @@ class MessageBodyRenderer extends String {
         text = text.replace(/\n\n+/g, '\n\n');
         text = u.geoUriToHttp(text, _converse.geouri_replacement);
 
-        const process = (text) => {
-            text = u.addEmoji(text);
-            return addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox);
-        }
-        const list = await Promise.all(u.addHyperlinks(text));
-        this.list = list.reduce((acc, i) => isString(i) ? [...acc, ...process(i)] : [...acc, i], []);
+        let list = await Promise.all(u.addHyperlinks(text));
+
+        list = list.reduce((acc, i) => isString(i) ? [...acc, ...u.addEmoji(i)] : [...acc, i], []);
+
+        const addMentions = text => addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox)
+        list = list.reduce((acc, i) => isString(i) ? [...acc, ...addMentions(i)] : [...acc, i], []);
         /**
          * Synchronous event which provides a hook for transforming a chat message's body text
          * after the default transformations have been applied.
@@ -45,8 +45,7 @@ class MessageBodyRenderer extends String {
          * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
          */
         await api.trigger('afterMessageBodyTransformed', this.model, text, {'Synchronous': true});
-
-        return this.list;
+        return list;
     }
 
     async render () {

+ 3 - 10
src/templates/emoji_picker.js

@@ -1,7 +1,5 @@
 import { html } from "lit-html";
 import { __ } from '@converse/headless/i18n';
-import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
-import xss from "xss/dist/xss";
 
 
 const i18n_search = __('Search');
@@ -10,7 +8,6 @@ const skintones = ['tone1', 'tone2', 'tone3', 'tone4', 'tone5'];
 
 
 const emoji_category = (o) => {
-    const category_emoji = unsafeHTML(xss.filterXSS(o.transformCategory(o.emoji_categories[o.category]), {'whiteList': {'img': ['class', 'draggable' ,'alt', 'src', 'title']}}));
     return html`
         <li data-category="${o.category}"
             class="emoji-category ${o.category} ${(o.current_category === o.category) ? 'picked' : ''}"
@@ -19,7 +16,7 @@ const emoji_category = (o) => {
             <a class="pick-category"
                @click=${o.onCategoryPicked}
                href="#emoji-picker-${o.category}"
-               data-category="${o.category}">${category_emoji} </a>
+               data-category="${o.category}">${o.transformCategory(o.emoji_categories[o.category])} </a>
         </li>
     `;
 }
@@ -30,12 +27,10 @@ const emoji_picker_header = (o) => html`
     </ul>
 `;
 
-
 const emoji_item = (o) => {
-    const emoji = unsafeHTML(xss.filterXSS(o.transform(o.emoji.sn), {'whiteList': {'img': ['class', 'draggable' ,'alt', 'src', 'title']}}));
     return html`
         <li class="emoji insert-emoji ${o.shouldBeHidden(o.emoji.sn) ? 'hidden' : ''}" data-emoji="${o.emoji.sn}" title="${o.emoji.sn}">
-            <a href="#" @click=${o.onEmojiPicked} data-emoji="${o.emoji.sn}">${emoji}</a>
+            <a href="#" @click=${o.onEmojiPicked} data-emoji="${o.emoji.sn}">${o.transform(o.emoji.sn)}</a>
         </li>
     `;
 }
@@ -58,11 +53,9 @@ const emojis_for_category = (o) => html`
 
 
 const skintone_emoji = (o) => {
-    const shortname = ':'+o.skintone+':';
-    const emoji = unsafeHTML(xss.filterXSS(o.transform(shortname), {'whiteList': {'img': ['class', 'draggable' ,'alt', 'src', 'title']}}));
     return html`
         <li data-skintone="${o.skintone}" class="emoji-skintone ${(o.current_skintone === o.skintone) ? 'picked' : ''}">
-            <a class="pick-skintone" href="#" data-skintone="${o.skintone}" @click=${o.onSkintonePicked}>${emoji}</a>
+            <a class="pick-skintone" href="#" data-skintone="${o.skintone}" @click=${o.onSkintonePicked}>${o.transform(':'+o.skintone+':')}</a>
         </li>
     `;
 }