logo

mastofe

My custom branche(s) on git.pleroma.social/pleroma/mastofe
commit: fd7f0732fe26554c51218c4f67955e8050590d2c
parent: eb5ac234342db46c881d8e69644d3292b5eabb54
Author: Nolan Lawson <nolan@nolanlawson.com>
Date:   Thu,  5 Oct 2017 18:42:34 -0700

Compress and combine emoji data (#5229)


Diffstat:

Mapp/javascript/mastodon/actions/compose.js2+-
Mapp/javascript/mastodon/components/autosuggest_emoji.js4++--
Dapp/javascript/mastodon/emoji.js71-----------------------------------------------------------------------
Dapp/javascript/mastodon/emoji_data_compressed.js22----------------------
Dapp/javascript/mastodon/emoji_data_light.js16----------------
Dapp/javascript/mastodon/emoji_index_light.js154-------------------------------------------------------------------------------
Dapp/javascript/mastodon/emoji_utils.js137-------------------------------------------------------------------------------
Dapp/javascript/mastodon/emojione_light.js38--------------------------------------
Mapp/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js2+-
Aapp/javascript/mastodon/features/emoji/emoji.js72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/javascript/mastodon/features/emoji/emoji_compressed.js90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rapp/javascript/mastodon/emoji_map.json -> app/javascript/mastodon/features/emoji/emoji_map.json0
Aapp/javascript/mastodon/features/emoji/emoji_mart_data_light.js41+++++++++++++++++++++++++++++++++++++++++
Aapp/javascript/mastodon/features/emoji/emoji_mart_search_light.js154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js35+++++++++++++++++++++++++++++++++++
Aapp/javascript/mastodon/features/emoji/emoji_utils.js137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/javascript/mastodon/features/emoji/unicode_to_filename.js26++++++++++++++++++++++++++
Aapp/javascript/mastodon/features/emoji/unicode_to_unified_name.js17+++++++++++++++++
Mapp/javascript/mastodon/reducers/accounts.js2+-
Mapp/javascript/mastodon/reducers/custom_emojis.js4++--
Mapp/javascript/mastodon/reducers/statuses.js2+-
Mapp/javascript/packs/public.js2+-
Mlib/tasks/emojis.rake2+-
Mspec/javascript/components/emoji_index.test.js20+++++++++++++++++++-
Mspec/javascript/components/emojify.test.js11++++++++++-
25 files changed, 611 insertions(+), 450 deletions(-)

diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js @@ -1,6 +1,6 @@ import api from '../api'; import { throttle } from 'lodash'; -import { search as emojiSearch } from '../emoji_index_light'; +import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; import { updateTimeline, diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { unicodeMapping } from '../emojione_light'; +import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; const assetHost = process.env.CDN_HOST || ''; @@ -23,7 +23,7 @@ export default class AutosuggestEmoji extends React.PureComponent { return null; } - url = `${assetHost}/emoji/${mapping[0]}.svg`; + url = `${assetHost}/emoji/${mapping.filename}.svg`; } return ( diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js @@ -1,71 +0,0 @@ -import { unicodeMapping } from './emojione_light'; -import Trie from 'substring-trie'; - -const trie = new Trie(Object.keys(unicodeMapping)); - -const assetHost = process.env.CDN_HOST || ''; - -const emojify = (str, customEmojis = {}) => { - let rtn = ''; - for (;;) { - let match, i = 0, tag; - while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(match = trie.search(str.slice(i)))) { - i += str.codePointAt(i) < 65536 ? 1 : 2; - } - let rend, replacement = ''; - if (i === str.length) { - break; - } else if (str[i] === ':') { - if (!(() => { - rend = str.indexOf(':', i + 1) + 1; - if (!rend) return false; // no pair of ':' - const lt = str.indexOf('<', i + 1); - if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':' - const shortname = str.slice(i, rend); - // now got a replacee as ':shortname:' - // if you want additional emoji handler, add statements below which set replacement and return true. - if (shortname in customEmojis) { - replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`; - return true; - } - return false; - })()) rend = ++i; - } else if (tag >= 0) { // <, & - rend = str.indexOf('>;'[tag], i + 1) + 1; - if (!rend) break; - i = rend; - } else { // matched to unicode emoji - const [filename, shortCode] = unicodeMapping[match]; - replacement = `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="${assetHost}/emoji/${filename}.svg" />`; - rend = i + match.length; - } - rtn += str.slice(0, i) + replacement; - str = str.slice(rend); - } - return rtn + str; -}; - -export default emojify; - -export const buildCustomEmojis = customEmojis => { - const emojis = []; - - customEmojis.forEach(emoji => { - const shortcode = emoji.get('shortcode'); - const url = emoji.get('static_url'); - const name = shortcode.replace(':', ''); - - emojis.push({ - id: name, - name, - short_names: [name], - text: '', - emoticons: [], - keywords: [name], - imageUrl: url, - custom: true, - }); - }); - - return emojis; -}; diff --git a/app/javascript/mastodon/emoji_data_compressed.js b/app/javascript/mastodon/emoji_data_compressed.js @@ -1,22 +0,0 @@ -// @preval -const data = require('emoji-mart/dist/data').default; -const pick = require('lodash/pick'); -const values = require('lodash/values'); - -const condensedEmojis = Object.keys(data.emojis).map(key => { - if (!data.emojis[key].short_names[0] === key) { - throw new Error('The condenser expects the first short_code to be the ' + - 'key. It may need to be rewritten if the emoji change such that this ' + - 'is no longer the case.'); - } - return values(pick(data.emojis[key], ['short_names', 'unified', 'search'])); -}); - -// JSON.parse/stringify is to emulate what @preval is doing and avoid any -// inconsistent behavior in dev mode -module.exports = JSON.parse(JSON.stringify({ - emojis: condensedEmojis, - skins: data.skins, - categories: data.categories, - short_names: data.short_names, -})); diff --git a/app/javascript/mastodon/emoji_data_light.js b/app/javascript/mastodon/emoji_data_light.js @@ -1,16 +0,0 @@ -const data = require('./emoji_data_compressed'); - -// decompress -const emojis = {}; -data.emojis.forEach(compressedEmoji => { - const [ short_names, unified, search ] = compressedEmoji; - emojis[short_names[0]] = { - short_names, - unified, - search, - }; -}); - -data.emojis = emojis; - -module.exports = data; diff --git a/app/javascript/mastodon/emoji_index_light.js b/app/javascript/mastodon/emoji_index_light.js @@ -1,154 +0,0 @@ -// This code is largely borrowed from: -// https://github.com/missive/emoji-mart/blob/bbd4fbe/src/utils/emoji-index.js - -import data from './emoji_data_light'; -import { getData, getSanitizedData, intersect } from './emoji_utils'; - -let index = {}; -let emojisList = {}; -let emoticonsList = {}; -let previousInclude = []; -let previousExclude = []; - -for (let emoji in data.emojis) { - let emojiData = data.emojis[emoji], - { short_names, emoticons } = emojiData, - id = short_names[0]; - - for (let emoticon of (emoticons || [])) { - if (!emoticonsList[emoticon]) { - emoticonsList[emoticon] = id; - } - } - - emojisList[id] = getSanitizedData(id); -} - -function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) { - maxResults = maxResults || 75; - include = include || []; - exclude = exclude || []; - - if (custom.length) { - for (const emoji of custom) { - data.emojis[emoji.id] = getData(emoji); - emojisList[emoji.id] = getSanitizedData(emoji); - } - - data.categories.push({ - name: 'Custom', - emojis: custom.map(emoji => emoji.id), - }); - } - - let results = null; - let pool = data.emojis; - - if (value.length) { - if (value === '-' || value === '-1') { - return [emojisList['-1']]; - } - - let values = value.toLowerCase().split(/[\s|,|\-|_]+/); - - if (values.length > 2) { - values = [values[0], values[1]]; - } - - if (include.length || exclude.length) { - pool = {}; - - if (previousInclude !== include.sort().join(',') || previousExclude !== exclude.sort().join(',')) { - previousInclude = include.sort().join(','); - previousExclude = exclude.sort().join(','); - index = {}; - } - - for (let category of data.categories) { - let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true; - let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false; - if (!isIncluded || isExcluded) { - continue; - } - - for (let emojiId of category.emojis) { - pool[emojiId] = data.emojis[emojiId]; - } - } - } else if (previousInclude.length || previousExclude.length) { - index = {}; - } - - let allResults = values.map((value) => { - let aPool = pool; - let aIndex = index; - let length = 0; - - for (let char of value.split('')) { - length++; - - aIndex[char] = aIndex[char] || {}; - aIndex = aIndex[char]; - - if (!aIndex.results) { - let scores = {}; - - aIndex.results = []; - aIndex.pool = {}; - - for (let id in aPool) { - let emoji = aPool[id], - { search } = emoji, - sub = value.substr(0, length), - subIndex = search.indexOf(sub); - - if (subIndex !== -1) { - let score = subIndex + 1; - if (sub === id) { - score = 0; - } - - aIndex.results.push(emojisList[id]); - aIndex.pool[id] = emoji; - - scores[id] = score; - } - } - - aIndex.results.sort((a, b) => { - let aScore = scores[a.id], - bScore = scores[b.id]; - - return aScore - bScore; - }); - } - - aPool = aIndex.pool; - } - - return aIndex.results; - }).filter(a => a); - - if (allResults.length > 1) { - results = intersect(...allResults); - } else if (allResults.length) { - results = allResults[0]; - } else { - results = []; - } - } - - if (results) { - if (emojisToShowFilter) { - results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified)); - } - - if (results && results.length > maxResults) { - results = results.slice(0, maxResults); - } - } - - return results; -} - -export { search }; diff --git a/app/javascript/mastodon/emoji_utils.js b/app/javascript/mastodon/emoji_utils.js @@ -1,137 +0,0 @@ -// This code is largely borrowed from: -// https://github.com/missive/emoji-mart/blob/bbd4fbe/src/utils/index.js - -import data from './emoji_data_light'; - -const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/; - -function buildSearch(thisData) { - const search = []; - - let addToSearch = (strings, split) => { - if (!strings) { - return; - } - - (Array.isArray(strings) ? strings : [strings]).forEach((string) => { - (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => { - s = s.toLowerCase(); - - if (search.indexOf(s) === -1) { - search.push(s); - } - }); - }); - }; - - addToSearch(thisData.short_names, true); - addToSearch(thisData.name, true); - addToSearch(thisData.keywords, false); - addToSearch(thisData.emoticons, false); - - return search; -} - -function unifiedToNative(unified) { - let unicodes = unified.split('-'), - codePoints = unicodes.map((u) => `0x${u}`); - - return String.fromCodePoint(...codePoints); -} - -function sanitize(emoji) { - let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji, - id = emoji.id || short_names[0], - colons = `:${id}:`; - - if (custom) { - return { - id, - name, - colons, - emoticons, - custom, - imageUrl, - }; - } - - if (skin_tone) { - colons += `:skin-tone-${skin_tone}:`; - } - - return { - id, - name, - colons, - emoticons, - unified: unified.toLowerCase(), - skin: skin_tone || (skin_variations ? 1 : null), - native: unifiedToNative(unified), - }; -} - -function getSanitizedData(emoji) { - return sanitize(getData(emoji)); -} - -function getData(emoji) { - let emojiData = {}; - - if (typeof emoji === 'string') { - let matches = emoji.match(COLONS_REGEX); - - if (matches) { - emoji = matches[1]; - - } - - if (data.short_names.hasOwnProperty(emoji)) { - emoji = data.short_names[emoji]; - } - - if (data.emojis.hasOwnProperty(emoji)) { - emojiData = data.emojis[emoji]; - } - } else if (emoji.custom) { - emojiData = emoji; - - emojiData.search = buildSearch({ - short_names: emoji.short_names, - name: emoji.name, - keywords: emoji.keywords, - emoticons: emoji.emoticons, - }); - - emojiData.search = emojiData.search.join(','); - } else if (emoji.id) { - if (data.short_names.hasOwnProperty(emoji.id)) { - emoji.id = data.short_names[emoji.id]; - } - - if (data.emojis.hasOwnProperty(emoji.id)) { - emojiData = data.emojis[emoji.id]; - } - } - - emojiData.emoticons = emojiData.emoticons || []; - emojiData.variations = emojiData.variations || []; - - if (emojiData.variations && emojiData.variations.length) { - emojiData = JSON.parse(JSON.stringify(emojiData)); - emojiData.unified = emojiData.variations.shift(); - } - - return emojiData; -} - -function intersect(a, b) { - let aSet = new Set(a); - let bSet = new Set(b); - let intersection = new Set( - [...aSet].filter(x => bSet.has(x)) - ); - - return Array.from(intersection); -} - -export { getData, getSanitizedData, intersect }; diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js @@ -1,38 +0,0 @@ -// @preval -// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt - -const emojis = require('./emoji_map.json'); -const { emojiIndex } = require('emoji-mart'); -const excluded = ['®', '©', '™']; -const skins = ['🏻', '🏼', '🏽', '🏾', '🏿']; -const shortcodeMap = {}; - -Object.keys(emojiIndex.emojis).forEach(key => { - shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id; -}); - -const stripModifiers = unicode => { - skins.forEach(tone => { - unicode = unicode.replace(tone, ''); - }); - - return unicode; -}; - -Object.keys(emojis).forEach(key => { - if (excluded.includes(key)) { - delete emojis[key]; - return; - } - - const normalizedKey = stripModifiers(key); - let shortcode = shortcodeMap[normalizedKey]; - - if (!shortcode) { - shortcode = shortcodeMap[normalizedKey + '\uFE0F']; - } - - emojis[key] = [emojis[key], shortcode]; -}); - -module.exports.unicodeMapping = emojis; diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -6,7 +6,7 @@ import Overlay from 'react-overlays/lib/Overlay'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; -import { buildCustomEmojis } from '../../../emoji'; +import { buildCustomEmojis } from '../../emoji/emoji'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js @@ -0,0 +1,72 @@ +import unicodeMapping from './emoji_unicode_mapping_light'; +import Trie from 'substring-trie'; + +const trie = new Trie(Object.keys(unicodeMapping)); + +const assetHost = process.env.CDN_HOST || ''; + +const emojify = (str, customEmojis = {}) => { + let rtn = ''; + for (;;) { + let match, i = 0, tag; + while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(match = trie.search(str.slice(i)))) { + i += str.codePointAt(i) < 65536 ? 1 : 2; + } + let rend, replacement = ''; + if (i === str.length) { + break; + } else if (str[i] === ':') { + if (!(() => { + rend = str.indexOf(':', i + 1) + 1; + if (!rend) return false; // no pair of ':' + const lt = str.indexOf('<', i + 1); + if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':' + const shortname = str.slice(i, rend); + // now got a replacee as ':shortname:' + // if you want additional emoji handler, add statements below which set replacement and return true. + if (shortname in customEmojis) { + replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`; + return true; + } + return false; + })()) rend = ++i; + } else if (tag >= 0) { // <, & + rend = str.indexOf('>;'[tag], i + 1) + 1; + if (!rend) break; + i = rend; + } else { // matched to unicode emoji + const { filename, shortCode } = unicodeMapping[match]; + const title = shortCode ? `:${shortCode}:` : ''; + replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`; + rend = i + match.length; + } + rtn += str.slice(0, i) + replacement; + str = str.slice(rend); + } + return rtn + str; +}; + +export default emojify; + +export const buildCustomEmojis = customEmojis => { + const emojis = []; + + customEmojis.forEach(emoji => { + const shortcode = emoji.get('shortcode'); + const url = emoji.get('static_url'); + const name = shortcode.replace(':', ''); + + emojis.push({ + id: name, + name, + short_names: [name], + text: '', + emoticons: [], + keywords: [name], + imageUrl: url, + custom: true, + }); + }); + + return emojis; +}; diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js @@ -0,0 +1,90 @@ +// @preval +// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt +// This file contains the compressed version of the emoji data from +// both emoji_map.json and from emoji-mart's emojiIndex and data objects. +// It's designed to be emitted in an array format to take up less space +// over the wire. + +const { unicodeToFilename } = require('./unicode_to_filename'); +const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); +const emojiMap = require('./emoji_map.json'); +const { emojiIndex } = require('emoji-mart'); +const emojiMartData = require('emoji-mart/dist/data').default; +const excluded = ['®', '©', '™']; +const skins = ['🏻', '🏼', '🏽', '🏾', '🏿']; +const shortcodeMap = {}; + +const shortCodesToEmojiData = {}; +const emojisWithoutShortCodes = []; + +Object.keys(emojiIndex.emojis).forEach(key => { + shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id; +}); + +const stripModifiers = unicode => { + skins.forEach(tone => { + unicode = unicode.replace(tone, ''); + }); + + return unicode; +}; + +Object.keys(emojiMap).forEach(key => { + if (excluded.includes(key)) { + delete emojiMap[key]; + return; + } + + const normalizedKey = stripModifiers(key); + let shortcode = shortcodeMap[normalizedKey]; + + if (!shortcode) { + shortcode = shortcodeMap[normalizedKey + '\uFE0F']; + } + + const filename = emojiMap[key]; + + const filenameData = [key]; + + if (unicodeToFilename(key) !== filename) { + // filename can't be derived using unicodeToFilename + filenameData.push(filename); + } + + if (typeof shortcode === 'undefined') { + emojisWithoutShortCodes.push(filenameData); + } else { + shortCodesToEmojiData[shortcode] = shortCodesToEmojiData[shortcode] || [[]]; + shortCodesToEmojiData[shortcode][0].push(filenameData); + } +}); + +Object.keys(emojiIndex.emojis).forEach(key => { + const { native } = emojiIndex.emojis[key]; + const { short_names, search, unified } = emojiMartData.emojis[key]; + if (short_names[0] !== key) { + throw new Error('The compresser expects the first short_code to be the ' + + 'key. It may need to be rewritten if the emoji change such that this ' + + 'is no longer the case.'); + } + + short_names.splice(0, 1); // first short name can be inferred from the key + + const searchData = [native, short_names, search]; + if (unicodeToUnifiedName(native) !== unified) { + // unified name can't be derived from unicodeToUnifiedName + searchData.push(unified); + } + + shortCodesToEmojiData[key].push(searchData); +}); + +// JSON.parse/stringify is to emulate what @preval is doing and avoid any +// inconsistent behavior in dev mode +module.exports = JSON.parse(JSON.stringify([ + shortCodesToEmojiData, + emojiMartData.skins, + emojiMartData.categories, + emojiMartData.short_names, + emojisWithoutShortCodes, +])); diff --git a/app/javascript/mastodon/emoji_map.json b/app/javascript/mastodon/features/emoji/emoji_map.json diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js @@ -0,0 +1,41 @@ +// The output of this module is designed to mimic emoji-mart's +// "data" object, such that we can use it for a light version of emoji-mart's +// emojiIndex.search functionality. +const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); +const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed'); + +const emojis = {}; + +// decompress +Object.keys(shortCodesToEmojiData).forEach((shortCode) => { + let [ + filenameData, // eslint-disable-line no-unused-vars + searchData, + ] = shortCodesToEmojiData[shortCode]; + let [ + native, + short_names, + search, + unified, + ] = searchData; + + if (!unified) { + // unified name can be derived from unicodeToUnifiedName + unified = unicodeToUnifiedName(native); + } + + short_names = [shortCode].concat(short_names); + emojis[shortCode] = { + native, + search, + short_names, + unified, + }; +}); + +module.exports = { + emojis, + skins, + categories, + short_names, +}; diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js @@ -0,0 +1,154 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/bbd4fbe/src/utils/emoji-index.js + +import data from './emoji_mart_data_light'; +import { getData, getSanitizedData, intersect } from './emoji_utils'; + +let index = {}; +let emojisList = {}; +let emoticonsList = {}; +let previousInclude = []; +let previousExclude = []; + +for (let emoji in data.emojis) { + let emojiData = data.emojis[emoji], + { short_names, emoticons } = emojiData, + id = short_names[0]; + + for (let emoticon of (emoticons || [])) { + if (!emoticonsList[emoticon]) { + emoticonsList[emoticon] = id; + } + } + + emojisList[id] = getSanitizedData(id); +} + +function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) { + maxResults = maxResults || 75; + include = include || []; + exclude = exclude || []; + + if (custom.length) { + for (const emoji of custom) { + data.emojis[emoji.id] = getData(emoji); + emojisList[emoji.id] = getSanitizedData(emoji); + } + + data.categories.push({ + name: 'Custom', + emojis: custom.map(emoji => emoji.id), + }); + } + + let results = null; + let pool = data.emojis; + + if (value.length) { + if (value === '-' || value === '-1') { + return [emojisList['-1']]; + } + + let values = value.toLowerCase().split(/[\s|,|\-|_]+/); + + if (values.length > 2) { + values = [values[0], values[1]]; + } + + if (include.length || exclude.length) { + pool = {}; + + if (previousInclude !== include.sort().join(',') || previousExclude !== exclude.sort().join(',')) { + previousInclude = include.sort().join(','); + previousExclude = exclude.sort().join(','); + index = {}; + } + + for (let category of data.categories) { + let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true; + let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false; + if (!isIncluded || isExcluded) { + continue; + } + + for (let emojiId of category.emojis) { + pool[emojiId] = data.emojis[emojiId]; + } + } + } else if (previousInclude.length || previousExclude.length) { + index = {}; + } + + let allResults = values.map((value) => { + let aPool = pool; + let aIndex = index; + let length = 0; + + for (let char of value.split('')) { + length++; + + aIndex[char] = aIndex[char] || {}; + aIndex = aIndex[char]; + + if (!aIndex.results) { + let scores = {}; + + aIndex.results = []; + aIndex.pool = {}; + + for (let id in aPool) { + let emoji = aPool[id], + { search } = emoji, + sub = value.substr(0, length), + subIndex = search.indexOf(sub); + + if (subIndex !== -1) { + let score = subIndex + 1; + if (sub === id) { + score = 0; + } + + aIndex.results.push(emojisList[id]); + aIndex.pool[id] = emoji; + + scores[id] = score; + } + } + + aIndex.results.sort((a, b) => { + let aScore = scores[a.id], + bScore = scores[b.id]; + + return aScore - bScore; + }); + } + + aPool = aIndex.pool; + } + + return aIndex.results; + }).filter(a => a); + + if (allResults.length > 1) { + results = intersect(...allResults); + } else if (allResults.length) { + results = allResults[0]; + } else { + results = []; + } + } + + if (results) { + if (emojisToShowFilter) { + results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified)); + } + + if (results && results.length > maxResults) { + results = results.slice(0, maxResults); + } + } + + return results; +} + +export { search }; diff --git a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js @@ -0,0 +1,35 @@ +// A mapping of unicode strings to an object containing the filename +// (i.e. the svg filename) and a shortCode intended to be shown +// as a "title" attribute in an HTML element (aka tooltip). + +const [ + shortCodesToEmojiData, + skins, // eslint-disable-line no-unused-vars + categories, // eslint-disable-line no-unused-vars + short_names, // eslint-disable-line no-unused-vars + emojisWithoutShortCodes, +] = require('./emoji_compressed'); +const { unicodeToFilename } = require('./unicode_to_filename'); + +// decompress +const unicodeMapping = {}; + +function processEmojiMapData(emojiMapData, shortCode) { + let [ native, filename ] = emojiMapData; + if (!filename) { + // filename name can be derived from unicodeToFilename + filename = unicodeToFilename(native); + } + unicodeMapping[native] = { + shortCode: shortCode, + filename: filename, + }; +} + +Object.keys(shortCodesToEmojiData).forEach((shortCode) => { + let [ filenameData ] = shortCodesToEmojiData[shortCode]; + filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode)); +}); +emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData)); + +module.exports = unicodeMapping; diff --git a/app/javascript/mastodon/features/emoji/emoji_utils.js b/app/javascript/mastodon/features/emoji/emoji_utils.js @@ -0,0 +1,137 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/bbd4fbe/src/utils/index.js + +import data from './emoji_mart_data_light'; + +const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/; + +function buildSearch(thisData) { + const search = []; + + let addToSearch = (strings, split) => { + if (!strings) { + return; + } + + (Array.isArray(strings) ? strings : [strings]).forEach((string) => { + (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => { + s = s.toLowerCase(); + + if (search.indexOf(s) === -1) { + search.push(s); + } + }); + }); + }; + + addToSearch(thisData.short_names, true); + addToSearch(thisData.name, true); + addToSearch(thisData.keywords, false); + addToSearch(thisData.emoticons, false); + + return search; +} + +function unifiedToNative(unified) { + let unicodes = unified.split('-'), + codePoints = unicodes.map((u) => `0x${u}`); + + return String.fromCodePoint(...codePoints); +} + +function sanitize(emoji) { + let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji, + id = emoji.id || short_names[0], + colons = `:${id}:`; + + if (custom) { + return { + id, + name, + colons, + emoticons, + custom, + imageUrl, + }; + } + + if (skin_tone) { + colons += `:skin-tone-${skin_tone}:`; + } + + return { + id, + name, + colons, + emoticons, + unified: unified.toLowerCase(), + skin: skin_tone || (skin_variations ? 1 : null), + native: unifiedToNative(unified), + }; +} + +function getSanitizedData(emoji) { + return sanitize(getData(emoji)); +} + +function getData(emoji) { + let emojiData = {}; + + if (typeof emoji === 'string') { + let matches = emoji.match(COLONS_REGEX); + + if (matches) { + emoji = matches[1]; + + } + + if (data.short_names.hasOwnProperty(emoji)) { + emoji = data.short_names[emoji]; + } + + if (data.emojis.hasOwnProperty(emoji)) { + emojiData = data.emojis[emoji]; + } + } else if (emoji.custom) { + emojiData = emoji; + + emojiData.search = buildSearch({ + short_names: emoji.short_names, + name: emoji.name, + keywords: emoji.keywords, + emoticons: emoji.emoticons, + }); + + emojiData.search = emojiData.search.join(','); + } else if (emoji.id) { + if (data.short_names.hasOwnProperty(emoji.id)) { + emoji.id = data.short_names[emoji.id]; + } + + if (data.emojis.hasOwnProperty(emoji.id)) { + emojiData = data.emojis[emoji.id]; + } + } + + emojiData.emoticons = emojiData.emoticons || []; + emojiData.variations = emojiData.variations || []; + + if (emojiData.variations && emojiData.variations.length) { + emojiData = JSON.parse(JSON.stringify(emojiData)); + emojiData.unified = emojiData.variations.shift(); + } + + return emojiData; +} + +function intersect(a, b) { + let aSet = new Set(a); + let bSet = new Set(b); + let intersection = new Set( + [...aSet].filter(x => bSet.has(x)) + ); + + return Array.from(intersection); +} + +export { getData, getSanitizedData, intersect }; diff --git a/app/javascript/mastodon/features/emoji/unicode_to_filename.js b/app/javascript/mastodon/features/emoji/unicode_to_filename.js @@ -0,0 +1,26 @@ +// taken from: +// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866 +exports.unicodeToFilename = (str) => { + let result = ''; + let charCode = 0; + let p = 0; + let i = 0; + while (i < str.length) { + charCode = str.charCodeAt(i++); + if (p) { + if (result.length > 0) { + result += '-'; + } + result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16); + p = 0; + } else if (0xD800 <= charCode && charCode <= 0xDBFF) { + p = charCode; + } else { + if (result.length > 0) { + result += '-'; + } + result += charCode.toString(16); + } + } + return result; +}; diff --git a/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js @@ -0,0 +1,17 @@ +function padLeft(str, num) { + while (str.length < num) { + str = '0' + str; + } + return str; +} + +exports.unicodeToUnifiedName = (str) => { + let output = ''; + for (let i = 0; i < str.length; i += 2) { + if (i > 0) { + output += '-'; + } + output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4); + } + return output; +}; diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js @@ -44,7 +44,7 @@ import { FAVOURITED_STATUSES_EXPAND_SUCCESS, } from '../actions/favourites'; import { STORE_HYDRATE } from '../actions/store'; -import emojify from '../emoji'; +import emojify from '../features/emoji/emoji'; import { Map as ImmutableMap, fromJS } from 'immutable'; import escapeTextContentForBrowser from 'escape-html'; diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js @@ -1,7 +1,7 @@ import { List as ImmutableList } from 'immutable'; import { STORE_HYDRATE } from '../actions/store'; -import { search as emojiSearch } from '../emoji_index_light'; -import { buildCustomEmojis } from '../emoji'; +import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; +import { buildCustomEmojis } from '../features/emoji/emoji'; const initialState = ImmutableList(); diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js @@ -39,7 +39,7 @@ import { PINNED_STATUSES_FETCH_SUCCESS, } from '../actions/pin_statuses'; import { SEARCH_FETCH_SUCCESS } from '../actions/search'; -import emojify from '../emoji'; +import emojify from '../features/emoji/emoji'; import { Map as ImmutableMap, fromJS } from 'immutable'; import escapeTextContentForBrowser from 'escape-html'; diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js @@ -21,7 +21,7 @@ function main() { const { length } = require('stringz'); const IntlRelativeFormat = require('intl-relativeformat').default; const { delegate } = require('rails-ujs'); - const emojify = require('../mastodon/emoji').default; + const emojify = require('../mastodon/features/emoji/emoji').default; const { getLocale } = require('../mastodon/locales'); const { localeData } = getLocale(); const VideoContainer = require('../mastodon/containers/video_container').default; diff --git a/lib/tasks/emojis.rake b/lib/tasks/emojis.rake @@ -17,7 +17,7 @@ namespace :emojis do task :generate do source = 'http://www.unicode.org/Public/emoji/5.0/emoji-test.txt' codes = [] - dest = Rails.root.join('app', 'javascript', 'mastodon', 'emoji_map.json') + dest = Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json') puts "Downloading emojos from source... (#{source})" diff --git a/spec/javascript/components/emoji_index.test.js b/spec/javascript/components/emoji_index.test.js @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { search } from '../../../app/javascript/mastodon/emoji_index_light'; +import { search } from '../../../app/javascript/mastodon/features/emoji/emoji_mart_search_light'; import { emojiIndex } from 'emoji-mart'; import { pick } from 'lodash'; @@ -78,4 +78,22 @@ describe('emoji_index', () => { expect(emojiIndex.search('flag', { include: ['people'] })) .to.deep.equal([]); }); + + it('does an emoji whose unified name is irregular', () => { + let expected = [{ + 'id': 'water_polo', + 'unified': '1f93d', + 'native': '🤽', + }, { + 'id': 'man-playing-water-polo', + 'unified': '1f93d-200d-2642-fe0f', + 'native': '🤽‍♂️', + }, { + 'id': 'woman-playing-water-polo', + 'unified': '1f93d-200d-2640-fe0f', + 'native': '🤽‍♀️', + }]; + expect(search('polo').map(trimEmojis)).to.deep.equal(expected); + expect(emojiIndex.search('polo').map(trimEmojis)).to.deep.equal(expected); + }); }); diff --git a/spec/javascript/components/emojify.test.js b/spec/javascript/components/emojify.test.js @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import emojify from '../../../app/javascript/mastodon/emoji'; +import emojify from '../../../app/javascript/mastodon/features/emoji/emoji'; describe('emojify', () => { it('ignores unknown shortcodes', () => { @@ -49,4 +49,13 @@ describe('emojify', () => { expect(emojify('👌🌈💕')).to.equal('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />'); expect(emojify('👌 🌈 💕')).to.equal('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />'); }); + + it('does an emoji that has no shortcode', () => { + expect(emojify('🕉️')).to.equal('<img draggable="false" class="emojione" alt="🕉️" title="" src="/emoji/1f549.svg" />'); + }); + + it('does an emoji whose filename is irregular', () => { + expect(emojify('↙️')).to.equal('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg" />'); + }); + });