260 lines
7.1 KiB
JavaScript
260 lines
7.1 KiB
JavaScript
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 `<gl-emoji ${fallbackSrcAttribute}${fallbackSpriteAttribute}data-name="${escape(
|
|
name,
|
|
)}"></gl-emoji>`;
|
|
}
|