/* eslint-disable class-methods-use-this, @gitlab/require-i18n-strings */ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import { uniq, escape } from 'lodash'; import { getEmojiScoreWithIntent } from '~/emoji/utils'; import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils'; import * as Emoji from '~/emoji'; import { dispose, fixTitle } from '~/tooltips'; import { createAlert } from '~/alert'; import axios from './lib/utils/axios_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { __ } from './locale'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; // For separating lists produced by ruby's Array#toSentence const categoryLabelMap = { activity: 'Activity', people: 'People', nature: 'Nature', food: 'Food', travel: 'Travel', objects: 'Objects', symbols: 'Symbols', flags: 'Flags', }; const IS_VISIBLE = 'is-visible'; const IS_RENDERED = 'is-rendered'; export class AwardsHandler { constructor(emoji) { this.emoji = emoji; this.eventListeners = []; this.toggleButtonSelector = '.js-add-award'; this.menuClass = 'js-award-emoji-menu'; } bindEvents() { const $parentEl = this.targetContainerEl ? $(this.targetContainerEl) : $(document); // If the user shows intent let's pre-build the menu this.registerEventListener( 'one', $parentEl, 'mouseenter focus', this.toggleButtonSelector, 'mouseenter focus', () => { const $menu = $(`.${this.menuClass}`); if ($menu.length === 0) { requestAnimationFrame(() => { this.createEmojiMenu(); }); } }, ); this.registerEventListener('on', $parentEl, 'click', this.toggleButtonSelector, (e) => { e.stopPropagation(); e.preventDefault(); this.showEmojiMenu($(e.currentTarget)); }); this.registerEventListener('on', $('html'), 'click', (e) => { const $target = $(e.target); if (!$target.closest(`.${this.menuClass}`).length) { $('.js-awards-block.current').removeClass('current'); if ($(`.${this.menuClass}`).is(':visible')) { $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active'); this.hideMenuElement($(`.${this.menuClass}`)); } } }); const emojiButtonSelector = `.js-awards-block .js-emoji-btn, .${this.menuClass} .js-emoji-btn`; this.registerEventListener('on', $parentEl, 'click', emojiButtonSelector, (e) => { e.preventDefault(); const $target = $(e.currentTarget); const $glEmojiElement = $target.find('gl-emoji'); const $spriteIconElement = $target.find('.icon'); const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data( 'name', ); $target.closest('.js-awards-block').addClass('current'); this.addAward(this.getVotesBlock(), this.getAwardUrl(), emojiName); }); } registerEventListener(method = 'on', element, ...args) { element[method].call(element, ...args); this.eventListeners.push({ element, args, }); } showEmojiMenu($addBtn) { if ($addBtn.hasClass('js-note-emoji')) { $addBtn.closest('.note').find('.js-awards-block').addClass('current'); } else { $addBtn.closest('.js-awards-block').addClass('current'); } const $menu = $(`.${this.menuClass}`); if ($menu.length) { if ($menu.is('.is-visible')) { $addBtn.removeClass('is-active'); this.hideMenuElement($menu); $('.js-emoji-menu-search').blur(); } else { $addBtn.addClass('is-active'); this.positionMenu($menu, $addBtn); this.showMenuElement($menu); $('.js-emoji-menu-search').focus(); } } else { $addBtn.addClass('is-loading is-active'); this.createEmojiMenu(() => { const $createdMenu = $(`.${this.menuClass}`); $addBtn.removeClass('is-loading'); this.positionMenu($createdMenu, $addBtn); return setTimeout(() => { this.showMenuElement($createdMenu); $('.js-emoji-menu-search').focus(); }, 200); }); } } // Create the emoji menu with the first category of emojis. // Then render the remaining categories of emojis one by one to avoid jank. createEmojiMenu(callback) { if (this.isCreatingEmojiMenu) { return; } this.isCreatingEmojiMenu = true; // Render the first category const categoryMap = this.emoji.getEmojiCategoryMap(); const categoryNameKey = Object.keys(categoryMap)[0]; const emojisInCategory = categoryMap[categoryNameKey]; const firstCategory = this.renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory); // Render the frequently used const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis(); let frequentlyUsedCatgegory = ''; if (frequentlyUsedEmojis.length > 0) { frequentlyUsedCatgegory = this.renderCategory('Frequently used', frequentlyUsedEmojis, { frequentEmojis: true, }); } const emojiMenuMarkup = `
${frequentlyUsedCatgegory} ${firstCategory}
`; const targetEl = this.targetContainerEl ? this.targetContainerEl : document.body; // eslint-disable-next-line no-unsanitized/method targetEl.insertAdjacentHTML('beforeend', emojiMenuMarkup); this.addRemainingEmojiMenuCategories(); this.setupSearch(); if (callback) { callback(); } } addRemainingEmojiMenuCategories() { if (this.isAddingRemainingEmojiMenuCategories) { return; } this.isAddingRemainingEmojiMenuCategories = true; const categoryMap = this.emoji.getEmojiCategoryMap(); // Avoid the jank and render the remaining categories separately // This will take more time, but makes UI more responsive const menu = document.querySelector(`.${this.menuClass}`); const emojiContentElement = menu.querySelector('.emoji-menu-content'); const remainingCategories = Object.keys(categoryMap).slice(1); const allCategoriesAddedPromise = remainingCategories.reduce( (promiseChain, categoryNameKey) => promiseChain.then( () => new Promise((resolve) => { const emojisInCategory = categoryMap[categoryNameKey]; const categoryMarkup = this.renderCategory( categoryLabelMap[categoryNameKey], emojisInCategory, ); requestAnimationFrame(() => { // eslint-disable-next-line no-unsanitized/method emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup); resolve(); }); }), ), Promise.resolve(), ); allCategoriesAddedPromise .then(() => { // Used for tests // We check for the menu in case it was destroyed in the meantime if (menu) { menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish')); } }) .catch((err) => { emojiContentElement.insertAdjacentHTML( 'beforeend', '

We encountered an error while adding the remaining categories

', ); throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`); }); } renderCategory(name, emojiList, opts = {}) { return `
${escape(name)}
`; } positionMenu($menu, $addBtn) { if (this.targetContainerEl) { return $menu.css({ top: `${$addBtn.outerHeight()}px`, }); } const position = $addBtn.data('position'); // The menu could potentially be off-screen or in a hidden overflow element // So we position the element absolute in the body const css = { top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`, }; // for xs screen we position the element on center if (bp.getBreakpointSize() === 'xs' || bp.getBreakpointSize() === 'sm') { css.left = '5%'; } else if (position === 'right') { css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`; $menu.addClass('is-aligned-right'); } else { css.left = `${$addBtn.offset().left}px`; $menu.removeClass('is-aligned-right'); } return $menu.css(css); } addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length; if (isInVueNoteablePage() && !isMainAwardsBlock) { const id = votesBlock.attr('id').replace('note_', ''); this.hideMenuElement($(`.${this.menuClass}`)); $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active'); const toggleAwardEvent = new CustomEvent('toggleAward', { detail: { awardName: emoji, noteId: id, }, }); document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent); } const normalizedEmoji = this.emoji.normalizeEmojiName(emoji); const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent(); this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => { this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); return typeof callback === 'function' ? callback() : undefined; }); this.hideMenuElement($(`.${this.menuClass}`)); return $(`${this.toggleButtonSelector}.is-active`).removeClass('is-active'); } addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) { if (checkForMutuality || checkForMutuality === null) { this.checkMutuality(votesBlock, emoji); } this.addEmojiToFrequentlyUsedList(emoji); const normalizedEmoji = this.emoji.normalizeEmojiName(emoji); const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent(); if ($emojiButton.length > 0) { if (this.isActive($emojiButton)) { this.decrementCounter($emojiButton, normalizedEmoji); } else { const counter = $emojiButton.find('.js-counter'); counter.text(parseInt(counter.text(), 10) + 1); $emojiButton.addClass('active'); this.addYouToUserList(votesBlock, normalizedEmoji); this.animateEmoji($emojiButton); } } else { votesBlock.removeClass('hidden'); this.createEmoji(votesBlock, normalizedEmoji); } } getVotesBlock() { if (isInVueNoteablePage()) { const $el = $(`${this.toggleButtonSelector}.is-active`).closest('.note.timeline-entry'); if ($el.length) { return $el; } } const currentBlock = $('.js-awards-block.current'); let resultantVotesBlock = currentBlock; if (currentBlock.length === 0) { resultantVotesBlock = $('.js-awards-block').eq(0); } return resultantVotesBlock; } getAwardUrl() { return this.getVotesBlock().data('awardUrl'); } checkMutuality(votesBlock, emoji) { const awardUrl = this.getAwardUrl(); if (emoji === 'thumbsup' || emoji === 'thumbsdown') { const mutualVote = emoji === 'thumbsup' ? 'thumbsdown' : 'thumbsup'; const $emojiButton = votesBlock.find(`[data-name="${mutualVote}"]`).parent(); const isAlreadyVoted = $emojiButton.hasClass('active'); if (isAlreadyVoted) { this.addAward(votesBlock, awardUrl, mutualVote, false); } } } isActive($emojiButton) { return $emojiButton.hasClass('active'); } decrementCounter($emojiButton, emoji) { const counter = $('.js-counter', $emojiButton); const counterNumber = parseInt(counter.text(), 10); if (counterNumber > 1) { counter.text(counterNumber - 1); this.removeYouFromUserList($emojiButton); } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') { dispose($emojiButton); counter.text('0'); this.removeYouFromUserList($emojiButton); if ($emojiButton.parents('.note').length) { this.removeEmoji($emojiButton); } } else { this.removeEmoji($emojiButton); } return $emojiButton.removeClass('active'); } removeEmoji($emojiButton) { dispose($emojiButton); $emojiButton.remove(); const $votesBlock = this.getVotesBlock(); if ($votesBlock.find('.js-emoji-btn').length === 0) { $votesBlock.addClass('hidden'); } } getAwardTooltip($awardBlock) { return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || ''; } toSentence(list) { let sentence; if (list.length <= 2) { sentence = list.join(' and '); } else { sentence = `${list.slice(0, -1).join(', ')}, and ${list[list.length - 1]}`; } return sentence; } removeYouFromUserList($emojiButton) { const awardBlock = $emojiButton; const originalTitle = this.getAwardTooltip(awardBlock); const authors = originalTitle.split(FROM_SENTENCE_REGEX); authors.splice(authors.indexOf('You'), 1); awardBlock .closest('.js-emoji-btn') .removeData('title') .removeAttr('data-title') .removeAttr('data-original-title') .attr('title', this.toSentence(authors)); fixTitle(awardBlock); return awardBlock; } addYouToUserList(votesBlock, emoji) { const awardBlock = this.findEmojiIcon(votesBlock, emoji).parent(); const origTitle = this.getAwardTooltip(awardBlock); let users = []; if (origTitle) { users = origTitle.trim().split(FROM_SENTENCE_REGEX); } users.unshift('You'); awardBlock.attr('title', this.toSentence(users)); fixTitle(awardBlock); return awardBlock; } createAwardButtonForVotesBlock(votesBlock, emojiName) { const buttonHtml = ` `; const $emojiButton = $(buttonHtml); $emojiButton .insertBefore(votesBlock.find('.js-award-holder')) .find('.emoji-icon') .data('name', emojiName); this.animateEmoji($emojiButton); votesBlock.removeClass('current'); } animateEmoji($emoji) { const className = 'pulse animated once short'; $emoji.addClass(className); this.registerEventListener('on', $emoji, animationEndEventString, (e) => { $(e.currentTarget).removeClass(className); }); } createEmoji(votesBlock, emoji) { if ($(`.${this.menuClass}`).length) { this.createAwardButtonForVotesBlock(votesBlock, emoji); } this.createEmojiMenu(() => { this.createAwardButtonForVotesBlock(votesBlock, emoji); }); } postEmoji($emojiButton, awardUrl, emoji, callback) { axios .post(awardUrl, { name: emoji, }) .then(({ data }) => { if (data.ok) { callback(); } }) .catch(() => createAlert({ message: __('Something went wrong on our end.'), }), ); } findEmojiIcon(votesBlock, emoji) { return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`); } scrollToAwards() { scrollToElement('.awards', { offset: -110 }); } addEmojiToFrequentlyUsedList(emoji) { if (this.emoji.isEmojiNameValid(emoji)) { this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji)); setCookie('frequently_used_emojis', this.frequentlyUsedEmojis.join(',')); } } getFrequentlyUsedEmojis() { return ( this.frequentlyUsedEmojis || (() => { const frequentlyUsedEmojis = uniq((getCookie('frequently_used_emojis') || '').split(',')); this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter((inputName) => this.emoji.isEmojiNameValid(inputName), ); return this.frequentlyUsedEmojis; })() ); } setupSearch() { const $search = $('.js-emoji-menu-search'); this.registerEventListener('on', $search, 'input', (e) => { const term = $(e.target).val().trim(); this.searchEmojis(term); }); const $menu = $(`.${this.menuClass}`); this.registerEventListener('on', $menu, transitionEndEventString, (e) => { if (e.target === e.currentTarget) { // Clear the search this.searchEmojis(''); } }); } searchEmojis(term) { const $search = $('.js-emoji-menu-search'); $search.val(term); // Clean previous search results $('ul.emoji-menu-search, h5.emoji-search-title').remove(); if (term.length > 0) { // Generate a search result block const h5 = $('
').text('Search results'); const foundEmojis = this.findMatchingEmojiElements(term).show(); const ul = $('