diff options
Diffstat (limited to 'app/assets/javascripts/emoji/index.js')
-rw-r--r-- | app/assets/javascripts/emoji/index.js | 256 |
1 files changed, 107 insertions, 149 deletions
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index fa1024a74a4..d022fcbeabe 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,10 +1,11 @@ -import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { escape, minBy } from 'lodash'; import emojiAliases from 'emojis/aliases.json'; -import axios from '../lib/utils/axios_utils'; import AccessorUtilities from '../lib/utils/accessor'; +import axios from '../lib/utils/axios_utils'; let emojiMap = null; let validEmojiNames = null; +export const FALLBACK_EMOJI_KEY = 'grey_question'; export const EMOJI_VERSION = '1'; @@ -30,23 +31,17 @@ async function loadEmoji() { return data; } -async function prepareEmojiMap() { - emojiMap = await loadEmoji(); +async function loadEmojiWithNames() { + return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => { + acc[key] = { ...value, name: key }; - validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + return acc; + }, {}); +} - Object.keys(emojiMap).forEach((name) => { - emojiMap[name].aliases = []; - emojiMap[name].name = name; - }); - Object.entries(emojiAliases).forEach(([alias, name]) => { - // This check, `if (name in emojiMap)` is necessary during testing. In - // production, it shouldn't be necessary, because at no point should there - // be an entry in aliases.json with no corresponding entry in emojis.json. - // However, during testing, the endpoint for emojis.json is mocked with a - // small dataset, whereas aliases.json is always `import`ed directly. - if (name in emojiMap) emojiMap[name].aliases.push(alias); - }); +async function prepareEmojiMap() { + emojiMap = await loadEmojiWithNames(); + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; } export function initEmojiMap() { @@ -63,151 +58,101 @@ export function getValidEmojiNames() { } export function isEmojiNameValid(name) { - return validEmojiNames.indexOf(name) >= 0; + if (!emojiMap) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('The emoji map is uninitialized or initialization has not completed'); + } + + return name in emojiMap || name in emojiAliases; } export function getAllEmoji() { return emojiMap; } -/** - * Retrieves an emoji by name or alias. - * - * Note: `initEmojiMap` must have been called and completed before this method - * can safely be called. - * - * @param {String} query The emoji name - * @param {Boolean} fallback If true, a fallback emoji will be returned if the - * named emoji does not exist. Defaults to false. - * @returns {Object} The matching emoji. - */ -export function getEmoji(query, fallback = false) { - // TODO https://gitlab.com/gitlab-org/gitlab/-/issues/268208 - const fallbackEmoji = emojiMap.grey_question; - if (!query) { - return fallback ? fallbackEmoji : null; - } +function getAliasesMatchingQuery(query) { + return Object.keys(emojiAliases) + .filter((alias) => alias.includes(query)) + .reduce((map, alias) => { + const emojiName = emojiAliases[alias]; + const score = alias.indexOf(query); + + const prev = map.get(emojiName); + // overwrite if we beat the previous score or we're more alphabetical + const shouldSet = + !prev || + prev.score > score || + (prev.score === score && prev.alias.localeCompare(alias) > 0); + + if (shouldSet) { + map.set(emojiName, { score, alias }); + } - if (!emojiMap) { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('The emoji map is uninitialized or initialization has not completed'); + return map; + }, new Map()); +} + +function getUnicodeMatch(emoji, query) { + if (emoji.e === query) { + return { score: 0, field: 'e', fieldValue: emoji.name, emoji }; } - const lowercaseQuery = query.toLowerCase(); - const name = normalizeEmojiName(lowercaseQuery); + return null; +} - if (name in emojiMap) { - return emojiMap[name]; +function getDescriptionMatch(emoji, query) { + if (emoji.d.includes(query)) { + return { score: emoji.d.indexOf(query), field: 'd', fieldValue: emoji.d, emoji }; } - return fallback ? fallbackEmoji : null; + return null; } -const searchMatchers = { - // Fuzzy matching compares using a fuzzy matching library - fuzzy: (value, query) => { - const score = fuzzaldrinPlus.score(value, query) > 0; - return { score, success: score > 0 }; - }, - // Contains matching compares by indexOf - contains: (value, query) => { - const index = value.indexOf(query.toLowerCase()); - return { index, success: index >= 0 }; - }, - // Exact matching compares by equality - exact: (value, query) => { - return { success: value === query.toLowerCase() }; - }, -}; - -const searchPredicates = { - // Search by name - name: (matcher, query) => (emoji) => { - const m = matcher(emoji.name, query); - return [{ ...m, emoji, field: emoji.name }]; - }, - // Search by alias - alias: (matcher, query) => (emoji) => - emoji.aliases.map((alias) => { - const m = matcher(alias, query); - return { ...m, emoji, field: alias }; - }), - // Search by description - description: (matcher, query) => (emoji) => { - const m = matcher(emoji.d, query); - return [{ ...m, emoji, field: emoji.d }]; - }, - // Search by unicode value (always exact) - unicode: (matcher, query) => (emoji) => { - return [{ emoji, field: emoji.e, success: emoji.e === query }]; - }, -}; +function getAliasMatch(emoji, matchingAliases) { + if (matchingAliases.has(emoji.name)) { + const { score, alias } = matchingAliases.get(emoji.name); -/** - * Searches emoji by name, aliases, description, and unicode value and returns - * an array of matches. - * - * Behavior is undefined if `opts.fields` is empty or if `opts.match` is fuzzy - * and the query is empty. - * - * Note: `initEmojiMap` must have been called and completed before this method - * can safely be called. - * - * @param {String} query Search query. - * @param {Object} opts Search options (optional). - * @param {String[]} opts.fields Fields to search. Choices are 'name', 'alias', - * 'description', and 'unicode' (value). Default is all (four) fields. - * @param {String} opts.match Search method to use. Choices are 'exact', - * 'contains', or 'fuzzy'. All methods are case-insensitive. Exact matching (the - * default) compares by equality. Contains matching compares by indexOf. Fuzzy - * matching compares using a fuzzy matching library. - * @param {Boolean} opts.fallback If true, a fallback emoji will be returned if - * the result set is empty. Defaults to false. - * @param {Boolean} opts.raw Returns the raw match data instead of just the - * matching emoji. - * @returns {Object[]} A list of emoji that match the query. - */ -export function searchEmoji(query, opts) { - if (!emojiMap) { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('The emoji map is uninitialized or initialization has not completed'); + return { score, field: 'alias', fieldValue: alias, emoji }; } - const { - fields = ['name', 'alias', 'description', 'unicode'], - match = 'exact', - fallback = false, - raw = false, - } = opts || {}; - - const fallbackEmoji = emojiMap.grey_question; - if (!query) { - if (fallback) { - return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji]; - } + return null; +} - return []; +function getNameMatch(emoji, query) { + if (emoji.name.includes(query)) { + return { + score: emoji.name.indexOf(query), + field: 'name', + fieldValue: emoji.name, + emoji, + }; } - // optimization for an exact match in name and alias - if (match === 'exact' && new Set([...fields, 'name', 'alias']).size === 2) { - const emoji = getEmoji(query, fallback); - return emoji ? [emoji] : []; - } + return null; +} - const matcher = searchMatchers[match] || searchMatchers.exact; - const predicates = fields.map((f) => searchPredicates[f](matcher, query)); +export function searchEmoji(query) { + const lowercaseQuery = query ? `${query}`.toLowerCase() : ''; - const results = Object.values(emojiMap) - .flatMap((emoji) => predicates.flatMap((predicate) => predicate(emoji))) - .filter((r) => r.success); + const matchingAliases = getAliasesMatchingQuery(lowercaseQuery); - // Fallback to question mark for unknown emojis - if (fallback && results.length === 0) { - return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji]; - } + return Object.values(emojiMap) + .map((emoji) => { + const matches = [ + getUnicodeMatch(emoji, query), + getDescriptionMatch(emoji, lowercaseQuery), + getAliasMatch(emoji, matchingAliases), + getNameMatch(emoji, lowercaseQuery), + ].filter(Boolean); - return raw ? results : results.map((r) => r.emoji); + return minBy(matches, (x) => x.score); + }) + .filter(Boolean); +} + +export function sortEmoji(items) { + // Sort results by index of and string comparison + return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue)); } let emojiCategoryMap; @@ -233,11 +178,28 @@ export function getEmojiCategoryMap() { return emojiCategoryMap; } -export function getEmojiInfo(query) { - return searchEmoji(query, { - fields: ['name', 'alias'], - fallback: true, - })[0]; +/** + * Retrieves an emoji by name + * + * @param {String} query The emoji name + * @param {Boolean} fallback If true, a fallback emoji will be returned if the + * named emoji does not exist. + * @returns {Object} The matching emoji. + */ +export function getEmojiInfo(query, fallback = true) { + if (!emojiMap) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('The emoji map is uninitialized or initialization has not completed'); + } + + const lowercaseQuery = query ? `${query}`.toLowerCase() : ''; + const name = normalizeEmojiName(lowercaseQuery); + + if (name in emojiMap) { + return emojiMap[name]; + } + + return fallback ? emojiMap[FALLBACK_EMOJI_KEY] : null; } export function emojiFallbackImageSrc(inputName) { @@ -257,12 +219,8 @@ export function glEmojiTag(inputName, options) { const fallbackSpriteClass = `emoji-${name}`; const fallbackSpriteAttribute = opts.sprite - ? `data-fallback-sprite-class="${fallbackSpriteClass}"` + ? `data-fallback-sprite-class="${escape(fallbackSpriteClass)}" ` : ''; - return ` - <gl-emoji - ${fallbackSpriteAttribute} - data-name="${name}"></gl-emoji> - `; + return `<gl-emoji ${fallbackSpriteAttribute}data-name="${escape(name)}"></gl-emoji>`; } |