import { escape, minBy } from 'lodash'; import emojiRegexFactory from 'emoji-regex'; import emojiAliases from 'emojis/aliases.json'; import { setAttributes } from '~/lib/utils/dom_utils'; import { getEmojiScoreWithIntent } from '~/emoji/utils'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; let emojiMap = null; let validEmojiNames = null; export const FALLBACK_EMOJI_KEY = 'grey_question'; // Keep the version in sync with `lib/gitlab/emoji.rb` export const EMOJI_VERSION = '2'; const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); async function loadEmoji() { if ( isLocalStorageAvailable && window.localStorage.getItem(CACHE_VERSION_KEY) === EMOJI_VERSION && window.localStorage.getItem(CACHE_KEY) ) { const emojis = JSON.parse(window.localStorage.getItem(CACHE_KEY)); // Workaround because the pride flag is broken in EMOJI_VERSION = '1' if (emojis.gay_pride_flag) { emojis.gay_pride_flag.e = '🏳️‍🌈'; } return emojis; } // We load the JSON file direct from the server // because it can't be loaded from a CDN due to // cross domain problems with JSON const { data } = await axios.get( `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`, ); window.localStorage.setItem(CACHE_VERSION_KEY, EMOJI_VERSION); window.localStorage.setItem(CACHE_KEY, JSON.stringify(data)); return data; } async function loadEmojiWithNames() { const emojiRegex = emojiRegexFactory(); return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => { // Filter out entries which aren't emojis if (value.e.match(emojiRegex)?.[0] === value.e) { acc[key] = { ...value, name: key }; } return acc; }, {}); } async function prepareEmojiMap() { emojiMap = await loadEmojiWithNames(); validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; } export function initEmojiMap() { initEmojiMap.promise = initEmojiMap.promise || prepareEmojiMap(); return initEmojiMap.promise; } export function normalizeEmojiName(name) { return Object.prototype.hasOwnProperty.call(emojiAliases, name) ? emojiAliases[name] : name; } export function getValidEmojiNames() { return validEmojiNames; } export function isEmojiNameValid(name) { 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; } 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 }); } return map; }, new Map()); } function getUnicodeMatch(emoji, query) { if (emoji.e === query) { return { score: 0, field: 'e', fieldValue: emoji.name, emoji }; } return null; } function getDescriptionMatch(emoji, query) { if (emoji.d.includes(query)) { return { score: emoji.d.indexOf(query), field: 'd', fieldValue: emoji.d, emoji }; } return null; } function getAliasMatch(emoji, matchingAliases) { if (matchingAliases.has(emoji.name)) { const { score, alias } = matchingAliases.get(emoji.name); return { score, field: 'alias', fieldValue: alias, emoji }; } return null; } function getNameMatch(emoji, query) { if (emoji.name.includes(query)) { return { score: emoji.name.indexOf(query), field: 'name', fieldValue: emoji.name, emoji, }; } return null; } // Sort emoji by emoji score falling back to a string comparison export function sortEmoji(a, b) { return a.score - b.score || a.fieldValue.localeCompare(b.fieldValue); } export function searchEmoji(query) { const lowercaseQuery = query ? `${query}`.toLowerCase() : ''; const matchingAliases = getAliasesMatchingQuery(lowercaseQuery); return Object.values(emojiMap) .map((emoji) => { const matches = [ getUnicodeMatch(emoji, query), getDescriptionMatch(emoji, lowercaseQuery), getAliasMatch(emoji, matchingAliases), getNameMatch(emoji, lowercaseQuery), ] .filter(Boolean) .map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) })); return minBy(matches, (x) => x.score); }) .filter(Boolean) .sort(sortEmoji); } export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP); let emojiCategoryMap; export function getEmojiCategoryMap() { if (!emojiCategoryMap) { emojiCategoryMap = CATEGORY_NAMES.reduce((acc, category) => { if (category === FREQUENTLY_USED_KEY) { return acc; } return { ...acc, [category]: [] }; }, {}); Object.keys(emojiMap).forEach((name) => { const emoji = emojiMap[name]; if (emojiCategoryMap[emoji.c]) { emojiCategoryMap[emoji.c].push(name); } }); } return emojiCategoryMap; } /** * 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) { const { name } = getEmojiInfo(inputName); return `${gon.asset_host || ''}${ gon.relative_url_root || '' }/-/emojis/${EMOJI_VERSION}/${name}.png`; } export function emojiImageTag(name, src) { const img = document.createElement('img'); img.className = 'emoji'; setAttributes(img, { title: `:${name}:`, alt: `:${name}:`, src, width: '16', height: '16', align: 'absmiddle', }); return img; } export function glEmojiTag(inputName, options) { const opts = { sprite: false, ...options }; const name = normalizeEmojiName(inputName); const fallbackSpriteClass = `emoji-${name}`; const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${escape(fallbackSpriteClass)}" ` : ''; const fallbackUrl = opts.url; const fallbackSrcAttribute = fallbackUrl ? `data-fallback-src="${fallbackUrl}" data-unicode-version="custom"` : ''; return ``; }