Bläddra i källkod

Move emoji functions to utils.js

Also, move various emoji utility methods out of @converse/headless, and
thereby remove the dependency on lit-html
JC Brand 3 år sedan
förälder
incheckning
9d4382c754

+ 0 - 293
src/headless/plugins/emoji/index.js

@@ -3,13 +3,10 @@
  * @copyright 2022, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import { ASCII_REPLACE_REGEX, CODEPOINTS_REGEX } from './regexes.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { _converse, api, converse } from "../../core.js";
 import { getOpenPromise } from '@converse/openpromise';
-import { html } from 'lit';
 
-const u = converse.env.utils;
 
 converse.emojis = {
     'initialized': false,
@@ -17,202 +14,6 @@ converse.emojis = {
 };
 
 
-const ASCII_LIST = {
-    '*\\0/*':'1f646', '*\\O/*':'1f646', '-___-':'1f611', ':\'-)':'1f602', '\':-)':'1f605', '\':-D':'1f605', '>:-)':'1f606', '\':-(':'1f613',
-    '>:-(':'1f620', ':\'-(':'1f622', 'O:-)':'1f607', '0:-3':'1f607', '0:-)':'1f607', '0;^)':'1f607', 'O;-)':'1f607', '0;-)':'1f607', 'O:-3':'1f607',
-    '-__-':'1f611', ':-Þ':'1f61b', '</3':'1f494', ':\')':'1f602', ':-D':'1f603', '\':)':'1f605', '\'=)':'1f605', '\':D':'1f605', '\'=D':'1f605',
-    '>:)':'1f606', '>;)':'1f606', '>=)':'1f606', ';-)':'1f609', '*-)':'1f609', ';-]':'1f609', ';^)':'1f609', '\':(':'1f613', '\'=(':'1f613',
-    ':-*':'1f618', ':^*':'1f618', '>:P':'1f61c', 'X-P':'1f61c', '>:[':'1f61e', ':-(':'1f61e', ':-[':'1f61e', '>:(':'1f620', ':\'(':'1f622',
-    ';-(':'1f622', '>.<':'1f623', '#-)':'1f635', '%-)':'1f635', 'X-)':'1f635', '\\0/':'1f646', '\\O/':'1f646', '0:3':'1f607', '0:)':'1f607',
-    'O:)':'1f607', 'O=)':'1f607', 'O:3':'1f607', 'B-)':'1f60e', '8-)':'1f60e', 'B-D':'1f60e', '8-D':'1f60e', '-_-':'1f611', '>:\\':'1f615',
-    '>:/':'1f615', ':-/':'1f615', ':-.':'1f615', ':-P':'1f61b', ':Þ':'1f61b', ':-b':'1f61b', ':-O':'1f62e', 'O_O':'1f62e', '>:O':'1f62e',
-    ':-X':'1f636', ':-#':'1f636', ':-)':'1f642', '(y)':'1f44d', '<3':'2764', ':D':'1f603', '=D':'1f603', ';)':'1f609', '*)':'1f609',
-    ';]':'1f609', ';D':'1f609', ':*':'1f618', '=*':'1f618', ':(':'1f61e', ':[':'1f61e', '=(':'1f61e', ':@':'1f620', ';(':'1f622', 'D:':'1f628',
-    ':$':'1f633', '=$':'1f633', '#)':'1f635', '%)':'1f635', 'X)':'1f635', 'B)':'1f60e', '8)':'1f60e', ':/':'1f615', ':\\':'1f615', '=/':'1f615',
-    '=\\':'1f615', ':L':'1f615', '=L':'1f615', ':P':'1f61b', '=P':'1f61b', ':b':'1f61b', ':O':'1f62e', ':X':'1f636', ':#':'1f636', '=X':'1f636',
-    '=#':'1f636', ':)':'1f642', '=]':'1f642', '=)':'1f642', ':]':'1f642'
-};
-
-function toCodePoint(unicode_surrogates) {
-    const r = [];
-    let  p = 0;
-    let  i = 0;
-    while (i < unicode_surrogates.length) {
-        const c = unicode_surrogates.charCodeAt(i++);
-        if (p) {
-            r.push((0x10000 + ((p - 0xD800) << 10) + (c - 0xDC00)).toString(16));
-            p = 0;
-        } else if (0xD800 <= c && c <= 0xDBFF) {
-            p = c;
-        } else {
-            r.push(c.toString(16));
-        }
-    }
-    return r.join('-');
-}
-
-
-function fromCodePoint (codepoint) {
-    let code = typeof codepoint === 'string' ? parseInt(codepoint, 16) : codepoint;
-    if (code < 0x10000) {
-        return String.fromCharCode(code);
-    }
-    code -= 0x10000;
-    return String.fromCharCode(
-        0xD800 + (code >> 10),
-        0xDC00 + (code & 0x3FF)
-    );
-}
-
-
-function convert (unicode) {
-    // Converts unicode code points and code pairs to their respective characters
-    if (unicode.indexOf("-") > -1) {
-        const parts = [],
-              s = unicode.split('-');
-        for (let i = 0; i < s.length; i++) {
-            let part = parseInt(s[i], 16);
-            if (part >= 0x10000 && part <= 0x10FFFF) {
-                const hi = Math.floor((part - 0x10000) / 0x400) + 0xD800;
-                const lo = ((part - 0x10000) % 0x400) + 0xDC00;
-                part = (String.fromCharCode(hi) + String.fromCharCode(lo));
-            } else {
-                part = String.fromCharCode(part);
-            }
-            parts.push(part);
-        }
-        return parts.join('');
-    }
-    return fromCodePoint(unicode);
-}
-
-function unique (arr) {
-    return [...new Set(arr)];
-}
-
-function getTonedEmojis () {
-    if (!converse.emojis.toned) {
-        converse.emojis.toned = unique(
-            Object.values(converse.emojis.json.people)
-                .filter(person => person.sn.includes('_tone'))
-                .map(person => person.sn.replace(/_tone[1-5]/, ''))
-        );
-    }
-    return converse.emojis.toned;
-}
-
-
-export 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);
-    });
-}
-
-
-export function getEmojiMarkup (data, options={unicode_only: false, add_title_wrapper: false}) {
-    const emoji = data.emoji;
-    const shortname = data.shortname;
-    if (emoji) {
-        if (options.unicode_only) {
-            return emoji;
-        } else if (api.settings.get('use_system_emojis')) {
-            if (options.add_title_wrapper) {
-                return shortname ? html`<span title="${shortname}">${emoji}</span>` : emoji;
-            } else {
-                return emoji;
-            }
-        } else {
-            const path = api.settings.get('emoji_image_path');
-            return html`<img class="emoji"
-                loading="lazy"
-                draggable="false"
-                title="${shortname}"
-                alt="${emoji}"
-                src="${path}/72x72/${data.cp}.png"/>`;
-        }
-    } else if (options.unicode_only) {
-        return shortname;
-    } else {
-        return html`<img class="emoji"
-            loading="lazy"
-            draggable="false"
-            title="${shortname}"
-            alt="${shortname}"
-            src="${converse.emojis.by_sn[shortname].url}">`;
-    }
-}
-
-
-export function getShortnameReferences (text) {
-    if (!converse.emojis.initialized) {
-        throw new Error(
-            'getShortnameReferences called before emojis are initialized. '+
-            'To avoid this problem, first await the converse.emojis.initilaized_promise.'
-        );
-    }
-    const references = [...text.matchAll(converse.emojis.shortnames_regex)].filter(ref => ref[0].length > 0);
-    return references.map(ref => {
-        const cp = converse.emojis.by_sn[ref[0]].cp;
-        return {
-            cp,
-            'begin': ref.index,
-            'end': ref.index+ref[0].length,
-            'shortname': ref[0],
-            'emoji': cp ? convert(cp) : null
-        }
-    });
-}
-
-
-function parseStringForEmojis(str, callback) {
-    const UFE0Fg = /\uFE0F/g;
-    const U200D = String.fromCharCode(0x200D);
-    return String(str).replace(CODEPOINTS_REGEX, (emoji, _, offset) => {
-        const icon_id = toCodePoint(emoji.indexOf(U200D) < 0 ? emoji.replace(UFE0Fg, '') : emoji);
-        if (icon_id) callback(icon_id, emoji, offset);
-    });
-}
-
-
-export function getCodePointReferences (text) {
-    const references = [];
-    parseStringForEmojis(text, (icon_id, emoji, offset) => {
-        references.push({
-            'begin': offset,
-            'cp': icon_id,
-            'emoji': emoji,
-            'end': offset + emoji.length,
-            'shortname': u.getEmojisByAtrribute('cp')[icon_id]?.sn || ''
-        });
-    });
-    return references;
-}
-
-
-function addEmojisMarkup (text, options) {
-    let list = [text];
-    [...getShortnameReferences(text), ...getCodePointReferences(text)]
-        .sort((a, b) => b.begin - a.begin)
-        .forEach(ref => {
-            const text = list.shift();
-            const emoji = getEmojiMarkup(ref, options);
-            if (typeof emoji === 'string') {
-                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 () {
@@ -255,7 +56,6 @@ converse.plugins.add('converse-emoji', {
             }
         });
 
-
         /**
          * Model for storing data related to the Emoji picker widget
          * @class
@@ -270,98 +70,6 @@ converse.plugins.add('converse-emoji', {
             }
         });
 
-        /************************ BEGIN Utils ************************/
-        // Closured cache
-        const emojis_by_attribute = {};
-
-        Object.assign(u, {
-            /**
-             * 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 a lit 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 { Object } options
-             * @param { Boolean } options.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 TemplateResult objects.
-             * @param { Boolean } options.add_title_wrapper - Whether unicode
-             *  codepoints should be wrapped with a `<span>` element with a
-             *  title, so that the shortname is shown upon hovering with the
-             *  mouse.
-             * @returns {Array} An array of at least one string, or otherwise
-             * strings and lit TemplateResult objects.
-             */
-            shortnamesToEmojis (str, options={unicode_only: false, add_title_wrapper: false}) {
-                str = convertASCII2Emoji(str);
-                return addEmojisMarkup(str, options);
-            },
-
-            /**
-             * Replaces all shortnames in the passed in string with their
-             * unicode (emoji) representation.
-             * @method u.shortnamesToUnicode
-             * @param { String } str - String containing the shortname(s)
-             * @returns { String }
-             */
-            shortnamesToUnicode (str) {
-                return u.shortnamesToEmojis(str, {'unicode_only': true})[0];
-            },
-
-            /**
-             * Determines whether the passed in string is just a single emoji shortname;
-             * @method u.isOnlyEmojis
-             * @param { String } shortname - A string which migh be just an emoji shortname
-             * @returns { Boolean }
-             */
-            isOnlyEmojis (text) {
-                const words = text.trim().split(/\s+/);
-                if (words.length === 0 || words.length > 3) {
-                    return false;
-                }
-                const emojis = words.filter(text => {
-                    const refs = getCodePointReferences(u.shortnamesToUnicode(text));
-                    return refs.length === 1 && (text === refs[0]['shortname'] || text === refs[0]['emoji']);
-                });
-                return emojis.length === words.length;
-            },
-
-            /**
-             * @method u.getEmojisByAtrribute
-             * @param { String } attr - The attribute according to which the
-             *  returned map should be keyed.
-             * @returns { Object } - Map of emojis with the passed in attribute values
-             *  as keys and a list of emojis for a particular category as values.
-             */
-            getEmojisByAtrribute (attr) {
-                if (emojis_by_attribute[attr]) {
-                    return emojis_by_attribute[attr];
-                }
-                if (attr === 'category') {
-                    return converse.emojis.json;
-                }
-                const all_variants = converse.emojis.list
-                    .map(e => e[attr])
-                    .filter((c, i, arr) => arr.indexOf(c) == i);
-
-                emojis_by_attribute[attr] = {};
-                all_variants.forEach(v => (emojis_by_attribute[attr][v] = converse.emojis.list.find(i => i[attr] === v)));
-                return emojis_by_attribute[attr];
-            }
-        });
-        /************************ END Utils ************************/
-
-
-        /************************ BEGIN API ************************/
         // We extend the default converse.js API to add methods specific to MUC groupchats.
         Object.assign(api, {
             /**
@@ -385,7 +93,6 @@ converse.plugins.add('converse-emoji', {
                         converse.emojis.shortnames = converse.emojis.list.map(m => m.sn);
                         const getShortNames = () => converse.emojis.shortnames.map(s => s.replace(/[+]/g, "\\$&")).join('|');
                         converse.emojis.shortnames_regex = new RegExp(getShortNames(), "gi");
-                        converse.emojis.toned = getTonedEmojis();
                         converse.emojis.initialized_promise.resolve();
                     }
                     return converse.emojis.initialized_promise;

+ 210 - 0
src/headless/plugins/emoji/utils.js

@@ -0,0 +1,210 @@
+import { ASCII_REPLACE_REGEX, CODEPOINTS_REGEX } from './regexes.js';
+import { converse } from "../../core.js";
+
+const { u } = converse.env;
+
+// Closured cache
+const emojis_by_attribute = {};
+
+
+const ASCII_LIST = {
+    '*\\0/*':'1f646', '*\\O/*':'1f646', '-___-':'1f611', ':\'-)':'1f602', '\':-)':'1f605', '\':-D':'1f605', '>:-)':'1f606', '\':-(':'1f613',
+    '>:-(':'1f620', ':\'-(':'1f622', 'O:-)':'1f607', '0:-3':'1f607', '0:-)':'1f607', '0;^)':'1f607', 'O;-)':'1f607', '0;-)':'1f607', 'O:-3':'1f607',
+    '-__-':'1f611', ':-Þ':'1f61b', '</3':'1f494', ':\')':'1f602', ':-D':'1f603', '\':)':'1f605', '\'=)':'1f605', '\':D':'1f605', '\'=D':'1f605',
+    '>:)':'1f606', '>;)':'1f606', '>=)':'1f606', ';-)':'1f609', '*-)':'1f609', ';-]':'1f609', ';^)':'1f609', '\':(':'1f613', '\'=(':'1f613',
+    ':-*':'1f618', ':^*':'1f618', '>:P':'1f61c', 'X-P':'1f61c', '>:[':'1f61e', ':-(':'1f61e', ':-[':'1f61e', '>:(':'1f620', ':\'(':'1f622',
+    ';-(':'1f622', '>.<':'1f623', '#-)':'1f635', '%-)':'1f635', 'X-)':'1f635', '\\0/':'1f646', '\\O/':'1f646', '0:3':'1f607', '0:)':'1f607',
+    'O:)':'1f607', 'O=)':'1f607', 'O:3':'1f607', 'B-)':'1f60e', '8-)':'1f60e', 'B-D':'1f60e', '8-D':'1f60e', '-_-':'1f611', '>:\\':'1f615',
+    '>:/':'1f615', ':-/':'1f615', ':-.':'1f615', ':-P':'1f61b', ':Þ':'1f61b', ':-b':'1f61b', ':-O':'1f62e', 'O_O':'1f62e', '>:O':'1f62e',
+    ':-X':'1f636', ':-#':'1f636', ':-)':'1f642', '(y)':'1f44d', '<3':'2764', ':D':'1f603', '=D':'1f603', ';)':'1f609', '*)':'1f609',
+    ';]':'1f609', ';D':'1f609', ':*':'1f618', '=*':'1f618', ':(':'1f61e', ':[':'1f61e', '=(':'1f61e', ':@':'1f620', ';(':'1f622', 'D:':'1f628',
+    ':$':'1f633', '=$':'1f633', '#)':'1f635', '%)':'1f635', 'X)':'1f635', 'B)':'1f60e', '8)':'1f60e', ':/':'1f615', ':\\':'1f615', '=/':'1f615',
+    '=\\':'1f615', ':L':'1f615', '=L':'1f615', ':P':'1f61b', '=P':'1f61b', ':b':'1f61b', ':O':'1f62e', ':X':'1f636', ':#':'1f636', '=X':'1f636',
+    '=#':'1f636', ':)':'1f642', '=]':'1f642', '=)':'1f642', ':]':'1f642'
+};
+
+
+function toCodePoint(unicode_surrogates) {
+    const r = [];
+    let  p = 0;
+    let  i = 0;
+    while (i < unicode_surrogates.length) {
+        const c = unicode_surrogates.charCodeAt(i++);
+        if (p) {
+            r.push((0x10000 + ((p - 0xD800) << 10) + (c - 0xDC00)).toString(16));
+            p = 0;
+        } else if (0xD800 <= c && c <= 0xDBFF) {
+            p = c;
+        } else {
+            r.push(c.toString(16));
+        }
+    }
+    return r.join('-');
+}
+
+
+function fromCodePoint (codepoint) {
+    let code = typeof codepoint === 'string' ? parseInt(codepoint, 16) : codepoint;
+    if (code < 0x10000) {
+        return String.fromCharCode(code);
+    }
+    code -= 0x10000;
+    return String.fromCharCode(
+        0xD800 + (code >> 10),
+        0xDC00 + (code & 0x3FF)
+    );
+}
+
+
+function convert (unicode) {
+    // Converts unicode code points and code pairs to their respective characters
+    if (unicode.indexOf("-") > -1) {
+        const parts = [],
+              s = unicode.split('-');
+        for (let i = 0; i < s.length; i++) {
+            let part = parseInt(s[i], 16);
+            if (part >= 0x10000 && part <= 0x10FFFF) {
+                const hi = Math.floor((part - 0x10000) / 0x400) + 0xD800;
+                const lo = ((part - 0x10000) % 0x400) + 0xDC00;
+                part = (String.fromCharCode(hi) + String.fromCharCode(lo));
+            } else {
+                part = String.fromCharCode(part);
+            }
+            parts.push(part);
+        }
+        return parts.join('');
+    }
+    return fromCodePoint(unicode);
+}
+
+export function convertASCII2Emoji (str) {
+    // Replace ASCII smileys
+    return str.replace(ASCII_REPLACE_REGEX, (entire, _, 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);
+    });
+}
+
+export function getShortnameReferences (text) {
+    if (!converse.emojis.initialized) {
+        throw new Error(
+            'getShortnameReferences called before emojis are initialized. '+
+            'To avoid this problem, first await the converse.emojis.initilaized_promise.'
+        );
+    }
+    const references = [...text.matchAll(converse.emojis.shortnames_regex)].filter(ref => ref[0].length > 0);
+    return references.map(ref => {
+        const cp = converse.emojis.by_sn[ref[0]].cp;
+        return {
+            cp,
+            'begin': ref.index,
+            'end': ref.index+ref[0].length,
+            'shortname': ref[0],
+            'emoji': cp ? convert(cp) : null
+        }
+    });
+}
+
+
+function parseStringForEmojis(str, callback) {
+    const UFE0Fg = /\uFE0F/g;
+    const U200D = String.fromCharCode(0x200D);
+    return String(str).replace(CODEPOINTS_REGEX, (emoji, _, offset) => {
+        const icon_id = toCodePoint(emoji.indexOf(U200D) < 0 ? emoji.replace(UFE0Fg, '') : emoji);
+        if (icon_id) callback(icon_id, emoji, offset);
+    });
+}
+
+
+export function getCodePointReferences (text) {
+    const references = [];
+    parseStringForEmojis(text, (icon_id, emoji, offset) => {
+        references.push({
+            'begin': offset,
+            'cp': icon_id,
+            'emoji': emoji,
+            'end': offset + emoji.length,
+            'shortname': getEmojisByAtrribute('cp')[icon_id]?.sn || ''
+        });
+    });
+    return references;
+}
+
+function addEmojisMarkup (text) {
+    let list = [text];
+    [...getShortnameReferences(text), ...getCodePointReferences(text)]
+        .sort((a, b) => b.begin - a.begin)
+        .forEach(ref => {
+            const text = list.shift();
+            const emoji = ref.emoji || ref.shortname;
+            list = [text.slice(0, ref.begin) + emoji + text.slice(ref.end), ...list];
+        });
+    return list;
+}
+
+/**
+ * Replaces all shortnames in the passed in string with their
+ * unicode (emoji) representation.
+ * @namespace u
+ * @method u.shortnamesToUnicode
+ * @param { String } str - String containing the shortname(s)
+ * @returns { String }
+ */
+function shortnamesToUnicode (str) {
+    return addEmojisMarkup(convertASCII2Emoji(str)).pop();
+}
+
+/**
+ * Determines whether the passed in string is just a single emoji shortname;
+ * @namespace u
+ * @method u.isOnlyEmojis
+ * @param { String } shortname - A string which migh be just an emoji shortname
+ * @returns { Boolean }
+ */
+function isOnlyEmojis (text) {
+    const words = text.trim().split(/\s+/);
+    if (words.length === 0 || words.length > 3) {
+        return false;
+    }
+    const emojis = words.filter(text => {
+        const refs = getCodePointReferences(u.shortnamesToUnicode(text));
+        return refs.length === 1 && (text === refs[0]['shortname'] || text === refs[0]['emoji']);
+    });
+    return emojis.length === words.length;
+}
+
+/**
+ * @namespace u
+ * @method u.getEmojisByAtrribute
+ * @param { 'category'|'cp'|'sn' } attr
+ *  The attribute according to which the returned map should be keyed.
+ * @returns { Object }
+ *  Map of emojis with the passed in `attr` used as key and a list of emojis as values.
+ */
+function getEmojisByAtrribute (attr) {
+    if (emojis_by_attribute[attr]) {
+        return emojis_by_attribute[attr];
+    }
+    if (attr === 'category') {
+        return converse.emojis.json;
+    }
+    const all_variants = converse.emojis.list
+        .map(e => e[attr])
+        .filter((c, i, arr) => arr.indexOf(c) == i);
+
+    emojis_by_attribute[attr] = {};
+    all_variants.forEach(v => (emojis_by_attribute[attr][v] = converse.emojis.list.find(i => i[attr] === v)));
+    return emojis_by_attribute[attr];
+}
+
+Object.assign(u, {
+    getEmojisByAtrribute,
+    isOnlyEmojis,
+    shortnamesToUnicode,
+});
+

+ 3 - 3
src/plugins/chatview/tests/emojis.js

@@ -133,7 +133,7 @@ describe("Emojis", function () {
             const view = _converse.chatboxviews.get(contact_jid);
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
             await u.waitUntil(() => view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '') ===
-                '<img class="emoji" draggable="false" title=":innocent:" alt="😇" src="https://twemoji.maxcdn.com/v/12.1.6//72x72/1f607.png">');
+                '<img class="emoji" loading="lazy" draggable="false" title=":innocent:" alt="😇" src="https://twemoji.maxcdn.com/v/12.1.6//72x72/1f607.png">');
 
             const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
             let message = view.querySelector(last_msg_sel);
@@ -191,7 +191,7 @@ describe("Emojis", function () {
             const picker = await u.waitUntil(() => view.querySelector('converse-emoji-picker'), 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">');
+                '<img class="emoji" loading="lazy" draggable="false" title=":xmpp:" alt=":xmpp:" src="/dist/images/custom_emojis/xmpp.png">');
 
             const textarea = view.querySelector('textarea.chat-textarea');
             textarea.value = 'Running tests for :converse:';
@@ -204,7 +204,7 @@ describe("Emojis", function () {
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
             const body = view.querySelector('converse-chat-message-body');
             await u.waitUntil(() => body.innerHTML.replace(/<!-.*?->/g, '').trim() ===
-                'Running tests for <img class="emoji" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
+                'Running tests for <img class="emoji" loading="lazy" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
         }));
     });
 });

+ 1 - 1
src/plugins/chatview/tests/http-file-upload.js

@@ -273,7 +273,7 @@ describe("XEP-0363: HTTP File Upload", function () {
                     // Check that the image renders
                     expect(img_link_el.outerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
                         `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                        `<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg"></a>`);
+                        `<img class="chat-image img-thumbnail" loading="lazy" src="${base_url}/logo/conversejs-filled.svg"></a>`);
                     XMLHttpRequest.prototype.send = send_backup;
                 }));
 

+ 2 - 2
src/plugins/chatview/tests/message-images.js

@@ -18,7 +18,7 @@ describe("A Chat Message", function () {
         let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
         expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
             `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                `<img class="chat-image img-thumbnail" src="https://conversejs.org/logo/conversejs-filled.svg">`+
+                `<img class="chat-image img-thumbnail" loading="lazy" src="https://conversejs.org/logo/conversejs-filled.svg">`+
             `</a>`);
 
         message += "?param1=val1&param2=val2";
@@ -28,7 +28,7 @@ describe("A Chat Message", function () {
         msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
         expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
             `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg?param1=val1&amp;param2=val2">`+
-                `<img class="chat-image img-thumbnail" src="${message.replace(/&/g, '&amp;')}">`+
+                `<img class="chat-image img-thumbnail" loading="lazy" src="${message.replace(/&/g, '&amp;')}">`+
             `</a>`);
 
         // Test now with two images in one message

+ 1 - 1
src/plugins/muc-views/tests/http-file-upload.js

@@ -139,7 +139,7 @@ describe("XEP-0363: HTTP File Upload", function () {
                     // Check that the image renders
                     expect(img_link_el.outerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
                         `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                        `<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg"></a>`);
+                        `<img class="chat-image img-thumbnail" loading="lazy" src="${base_url}/logo/conversejs-filled.svg"></a>`);
 
                     expect(view.querySelector('.chat-msg .chat-msg__media')).toBe(null);
                     XMLHttpRequest.prototype.send = send_backup;

+ 2 - 1
src/shared/chat/emoji-picker-content.js

@@ -2,6 +2,7 @@ import { CustomElement } from 'shared/components/element.js';
 import { _converse, converse, api } from "@converse/headless/core";
 import { html } from "lit";
 import { tpl_all_emojis, tpl_search_results } from "./templates/emoji-picker.js";
+import { getTonedEmojis } from './utils.js';
 
 const { sizzle } = converse.env;
 
@@ -90,7 +91,7 @@ export default class EmojiPickerContent extends CustomElement {
               return true;
           }
       } else {
-          if (this.current_skintone && converse.emojis.toned.includes(shortname)) {
+          if (this.current_skintone && getTonedEmojis().includes(shortname)) {
               return true;
           }
       }

+ 2 - 1
src/shared/chat/emoji-picker.js

@@ -5,6 +5,7 @@ import debounce from 'lodash-es/debounce';
 import { CustomElement } from 'shared/components/element.js';
 import { KEYCODES } from '@converse/headless/shared/constants.js';
 import { _converse, api, converse } from "@converse/headless/core";
+import { getTonedEmojis } from './utils.js';
 import { tpl_emoji_picker } from "./templates/emoji-picker.js";
 
 import './styles/emoji.scss';
@@ -221,7 +222,7 @@ export default class EmojiPicker extends CustomElement {
     }
 
     getTonedShortname (shortname) {
-        if (converse.emojis.toned.includes(shortname) && this.current_skintone) {
+        if (getTonedEmojis().includes(shortname) && this.current_skintone) {
             return `${shortname.slice(0, shortname.length-1)}_${this.current_skintone}:`
         }
         return shortname;

+ 106 - 1
src/shared/chat/utils.js

@@ -1,8 +1,14 @@
 import debounce from 'lodash/debounce';
 import tpl_new_day from "./templates/new-day.js";
 import { _converse, api, converse } from '@converse/headless/core';
+import { html } from 'lit';
+import {
+    convertASCII2Emoji,
+    getShortnameReferences,
+    getCodePointReferences
+} from '@converse/headless/plugins/emoji/utils.js';
 
-const { dayjs } = converse.env;
+const { dayjs, u } = converse.env;
 
 export function onScrolledDown (model) {
     if (!model.isHidden()) {
@@ -94,3 +100,102 @@ export function getHats (message) {
     }
     return [];
 }
+
+function unique (arr) {
+    return [...new Set(arr)];
+}
+
+export function getTonedEmojis () {
+    if (!converse.emojis.toned) {
+        converse.emojis.toned = unique(
+            Object.values(converse.emojis.json.people)
+                .filter(person => person.sn.includes('_tone'))
+                .map(person => person.sn.replace(/_tone[1-5]/, ''))
+        );
+    }
+    return converse.emojis.toned;
+}
+
+export function getEmojiMarkup (data, options={unicode_only: false, add_title_wrapper: false}) {
+    const emoji = data.emoji;
+    const shortname = data.shortname;
+    if (emoji) {
+        if (options.unicode_only) {
+            return emoji;
+        } else if (api.settings.get('use_system_emojis')) {
+            if (options.add_title_wrapper) {
+                return shortname ? html`<span title="${shortname}">${emoji}</span>` : emoji;
+            } else {
+                return emoji;
+            }
+        } else {
+            const path = api.settings.get('emoji_image_path');
+            return html`<img class="emoji"
+                loading="lazy"
+                draggable="false"
+                title="${shortname}"
+                alt="${emoji}"
+                src="${path}/72x72/${data.cp}.png"/>`;
+        }
+    } else if (options.unicode_only) {
+        return shortname;
+    } else {
+        return html`<img class="emoji"
+            loading="lazy"
+            draggable="false"
+            title="${shortname}"
+            alt="${shortname}"
+            src="${converse.emojis.by_sn[shortname].url}">`;
+    }
+}
+
+export function addEmojisMarkup (text, options) {
+    let list = [text];
+    [...getShortnameReferences(text), ...getCodePointReferences(text)]
+        .sort((a, b) => b.begin - a.begin)
+        .forEach(ref => {
+            const text = list.shift();
+            const emoji = getEmojiMarkup(ref, options);
+            if (typeof emoji === 'string') {
+                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;
+}
+
+/**
+ * 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 a lit 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.
+ *
+ * @namespace u
+ * @method u.shortnamesToEmojis
+ * @param { String } str - String containg the shortname(s)
+ * @param { Object } options
+ * @param { Boolean } options.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 TemplateResult objects.
+ * @param { Boolean } options.add_title_wrapper - Whether unicode
+ *  codepoints should be wrapped with a `<span>` element with a
+ *  title, so that the shortname is shown upon hovering with the
+ *  mouse.
+ * @returns {Array} An array of at least one string, or otherwise
+ * strings and lit TemplateResult objects.
+ */
+export function shortnamesToEmojis (str, options={unicode_only: false, add_title_wrapper: false}) {
+    str = convertASCII2Emoji(str);
+    return addEmojisMarkup(str, options);
+}
+
+
+Object.assign(u, { shortnamesToEmojis });

+ 3 - 2
src/shared/rich-text.js

@@ -4,15 +4,15 @@ import tpl_image from 'templates/image.js';
 import tpl_video from 'templates/video.js';
 import { api } from '@converse/headless/core';
 import { containsDirectives, getDirectiveAndLength, getDirectiveTemplate, isQuoteDirective } from './styling.js';
+import { getEmojiMarkup } from './chat/utils.js';
 import { getHyperlinkTemplate } from 'utils/html.js';
 import { getMediaURLs } from '@converse/headless/shared/chat/utils.js';
 import { getMediaURLsMetadata } from '@converse/headless/shared/parsers.js';
 import {
     convertASCII2Emoji,
     getCodePointReferences,
-    getEmojiMarkup,
     getShortnameReferences
-} from '@converse/headless/plugins/emoji/index.js';
+} from '@converse/headless/plugins/emoji/utils.js';
 import {
     filterQueryParamsFromURL,
     isAudioURL,
@@ -22,6 +22,7 @@ import {
     shouldRenderMediaFromURL,
 } from '@converse/headless/utils/url.js';
 
+
 import { html } from 'lit';
 
 const isString = s => typeof s === 'string';