/** * @module converse-emoji * @copyright 2020, the Converse.js contributors * @license Mozilla Public License (MPLv2) */ import { Model } from 'skeletor.js/src/model.js'; import { find, uniq } from "lodash"; import * as twemoji from "twemoji"; import converse from "./converse-core"; const u = converse.env.utils; 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', ':)':'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' }; 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"); 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 twemoji.default.convert.fromCodePoint(unicode); } converse.plugins.add('converse-emoji', { initialize () { /* The initialize function gets called as soon as the plugin is * loaded by converse.js's plugin machinery. */ const { _converse } = this; const { api } = _converse; const { ___ } = _converse; api.settings.update({ 'emoji_image_path': twemoji.default.base, 'emoji_categories': { "smileys": ":grinning:", "people": ":thumbsup:", "activity": ":soccer:", "travel": ":motorcycle:", "objects": ":bomb:", "nature": ":rainbow:", "food": ":hotdog:", "symbols": ":musical_note:", "flags": ":flag_ac:", "custom": null }, // 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.emojis = {}; api.promises.add('emojisInitialized', false); twemoji.default.base = api.settings.get('emoji_image_path'); /** * Model for storing data related to the Emoji picker widget * @class * @namespace _converse.EmojiPicker * @memberOf _converse */ _converse.EmojiPicker = Model.extend({ defaults: { 'current_category': 'smileys', 'current_skintone': '', 'scroll_position': 0 } }); 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 * @returns {function} */ getEmojiRenderer () { const how = { 'attributes': icon => { const codepoint = twemoji.default.convert.toCodePoint(icon); return {'title': `${u.getEmojisByAtrribute('cp')[codepoint]['sn']} ${icon}`} } }; const transform = u.shortnamesToEmojis; return api.settings.get('use_system_emojis') ? transform : text => twemoji.default.parse(transform(text), how); }, /** * 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) */ 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; }, /** * 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.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 rejects = words.filter(text => { const result = twemoji.default.parse(u.shortnameToUnicode(text)); const match = result.match(/.*?/); return !match || match.length !== 1; }); return rejects.length === 0; }, /** * @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] = find(_converse.emojis_list, 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, { /** * The "rooms" namespace groups methods relevant to chatrooms * (aka groupchats). * * @namespace api.rooms * @memberOf api */ emojis: { /** * Initializes Emoji support by downloading the emojis JSON (and any applicable images). * @method api.emojis.initialize * @returns {Promise} */ async initialize () { if (_converse.emojis.initialized) { return _converse.emojis.initialized; } _converse.emojis.initialized = u.getResolveablePromise(); const { default: json } = await import(/*webpackChunkName: "emojis" */ './emojis.json'); _converse.emojis.json = json; _converse.emojis.categories = Object.keys(_converse.emojis.json); _converse.emojis_map = _converse.emojis.categories.reduce((result, cat) => Object.assign(result, _converse.emojis.json[cat]), {}); _converse.emojis_list = Object.values(_converse.emojis_map); _converse.emojis_list.sort((a, b) => a.sn < b.sn ? -1 : (a.sn > b.sn ? 1 : 0)); _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"); _converse.emojis.toned = getTonedEmojis(); _converse.emojis.initialized.resolve(); /** * Triggered once the JSON file representing emoji data has been * fetched and its save to start calling emoji utility methods. * @event _converse#emojisInitialized */ api.trigger('emojisInitialized'); } } }); } });