Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/emoji/index.js')
-rw-r--r--app/assets/javascripts/emoji/index.js239
1 files changed, 186 insertions, 53 deletions
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index 4567c807c40..4a56843c0b5 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -1,53 +1,57 @@
-import { uniq } from 'lodash';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import emojiAliases from 'emojis/aliases.json';
import axios from '../lib/utils/axios_utils';
-
import AccessorUtilities from '../lib/utils/accessor';
let emojiMap = null;
-let emojiPromise = null;
let validEmojiNames = null;
export const EMOJI_VERSION = '1';
const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
-export function initEmojiMap() {
- emojiPromise =
- emojiPromise ||
- new Promise((resolve, reject) => {
- if (emojiMap) {
- resolve(emojiMap);
- } else if (
- isLocalStorageAvailable &&
- window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
- window.localStorage.getItem('gl-emoji-map')
- ) {
- emojiMap = JSON.parse(window.localStorage.getItem('gl-emoji-map'));
- validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
- resolve(emojiMap);
- } else {
- // 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
- axios
- .get(`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`)
- .then(({ data }) => {
- emojiMap = data;
- validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
- resolve(emojiMap);
- if (isLocalStorageAvailable) {
- window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION);
- window.localStorage.setItem('gl-emoji-map', JSON.stringify(emojiMap));
- }
- })
- .catch(err => {
- reject(err);
- });
- }
- });
+async function loadEmoji() {
+ if (
+ isLocalStorageAvailable &&
+ window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
+ window.localStorage.getItem('gl-emoji-map')
+ ) {
+ return JSON.parse(window.localStorage.getItem('gl-emoji-map'));
+ }
+
+ // 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('gl-emoji-map-version', EMOJI_VERSION);
+ window.localStorage.setItem('gl-emoji-map', JSON.stringify(data));
+ return data;
+}
+
+async function prepareEmojiMap() {
+ emojiMap = await loadEmoji();
+
+ validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+
+ 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);
+ });
+}
- return emojiPromise;
+export function initEmojiMap() {
+ initEmojiMap.promise = initEmojiMap.promise || prepareEmojiMap();
+ return initEmojiMap.promise;
}
export function normalizeEmojiName(name) {
@@ -62,13 +66,148 @@ export function isEmojiNameValid(name) {
return validEmojiNames.indexOf(name) >= 0;
}
-export function filterEmojiNames(filter) {
- const match = filter.toLowerCase();
- return validEmojiNames.filter(name => name.indexOf(match) >= 0);
+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;
+ }
+
+ 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.toLowerCase();
+ const name = normalizeEmojiName(lowercaseQuery);
+
+ if (name in emojiMap) {
+ return emojiMap[name];
+ }
+
+ return fallback ? fallbackEmoji : null;
}
-export function filterEmojiNamesByAlias(filter) {
- return uniq(filterEmojiNames(filter).map(name => normalizeEmojiName(name)));
+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 }];
+ },
+};
+
+/**
+ * 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');
+ }
+
+ 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 [];
+ }
+
+ // 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] : [];
+ }
+
+ const matcher = searchMatchers[match] || searchMatchers.exact;
+ const predicates = fields.map(f => searchPredicates[f](matcher, query));
+
+ const results = Object.values(emojiMap)
+ .flatMap(emoji => predicates.flatMap(predicate => predicate(emoji)))
+ .filter(r => r.success);
+
+ // Fallback to question mark for unknown emojis
+ if (fallback && results.length === 0) {
+ return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji];
+ }
+
+ return raw ? results : results.map(r => r.emoji);
}
let emojiCategoryMap;
@@ -95,16 +234,10 @@ export function getEmojiCategoryMap() {
}
export function getEmojiInfo(query) {
- let name = normalizeEmojiName(query);
- let emojiInfo = emojiMap[name];
-
- // Fallback to question mark for unknown emojis
- if (!emojiInfo) {
- name = 'grey_question';
- emojiInfo = emojiMap[name];
- }
-
- return { ...emojiInfo, name };
+ return searchEmoji(query, {
+ fields: ['name', 'alias'],
+ fallback: true,
+ })[0];
}
export function emojiFallbackImageSrc(inputName) {