diff --git a/karma.conf.js b/karma.conf.js index 68836138c..c7c4242d0 100644 --- a/karma.conf.js +++ b/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: { diff --git a/spec/emojis.js b/spec/emojis.js index d48cea6e0..3e2c1fafb 100644 --- a/spec/emojis.js +++ b/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( + ':xmpp:'); + + 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 :converse:'); + done(); + })); }); }); diff --git a/src/headless/converse-chat.js b/src/headless/converse-chat.js index ca20992ba..6e6756413 100644 --- a/src/headless/converse-chat.js +++ b/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 { - 'id': origin_id, - 'jid': this.get('jid'), - 'nickname': this.get('nickname'), - 'msgid': origin_id, - 'origin_id': origin_id, - 'fullname': _converse.xmppstatus.get('fullname'), 'from': _converse.bare_jid, + 'fullname': _converse.xmppstatus.get('fullname'), + 'id': origin_id, 'is_only_emojis': text ? u.isOnlyEmojis(text) : false, + 'jid': this.get('jid'), + 'message': body, + 'msgid': origin_id, + '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 } }, diff --git a/src/headless/converse-emoji.js b/src/headless/converse-emoji.js index 703ddc5c6..398fb4bee 100644 --- a/src/headless/converse-emoji.js +++ b/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>|]*>.*?<\/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`${shortname}`; + } +} + + +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 `${shortname}`; - } - }); - // 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>|]*>.*?<\/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(); diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index 12b25e34b..c555a4e8b 100644 --- a/src/headless/converse-muc.js +++ b/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( diff --git a/src/headless/emojis.json b/src/headless/emojis.json index 738b7049c..308777013 100644 --- a/src/headless/emojis.json +++ b/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"}, diff --git a/src/templates/directives/body.js b/src/templates/directives/body.js index 69f652611..bc59ba9be 100644 --- a/src/templates/directives/body.js +++ b/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 () { diff --git a/src/templates/emoji_picker.js b/src/templates/emoji_picker.js index 842459580..fcdf44aba 100644 --- a/src/templates/emoji_picker.js +++ b/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`
  • { ${category_emoji} + data-category="${o.category}">${o.transformCategory(o.emoji_categories[o.category])}
  • `; } @@ -30,12 +27,10 @@ const emoji_picker_header = (o) => html` `; - const emoji_item = (o) => { - const emoji = unsafeHTML(xss.filterXSS(o.transform(o.emoji.sn), {'whiteList': {'img': ['class', 'draggable' ,'alt', 'src', 'title']}})); return html`
  • - ${emoji} + ${o.transform(o.emoji.sn)}
  • `; } @@ -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`
  • - ${emoji} + ${o.transform(':'+o.skintone+':')}
  • `; }