BigW Consortium Gitlab

awards_handler.js 18.9 KB
Newer Older
1
/* eslint-disable class-methods-use-this */
2
import _ from 'underscore';
3
import Cookies from 'js-cookie';
4
import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils';
5
import Flash from './flash';
6 7

const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
8
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
9 10 11 12
const requestAnimationFrame = window.requestAnimationFrame ||
  window.webkitRequestAnimationFrame ||
  window.mozRequestAnimationFrame ||
  window.setTimeout;
13 14 15 16 17 18 19 20 21 22 23 24 25 26

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',
};

27 28 29
const IS_VISIBLE = 'is-visible';
const IS_RENDERED = 'is-rendered';

30 31 32
class AwardsHandler {
  constructor(emoji) {
    this.emoji = emoji;
33 34 35 36 37
    this.eventListeners = [];
    // If the user shows intent let's pre-build the menu
    this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => {
      const $menu = $('.emoji-menu');
      if ($menu.length === 0) {
38 39 40
        requestAnimationFrame(() => {
          this.createEmojiMenu();
        });
41 42 43 44 45 46 47
      }
    });
    this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => {
      e.stopPropagation();
      e.preventDefault();
      this.showEmojiMenu($(e.currentTarget));
    });
48

49 50 51 52
    this.registerEventListener('on', $('html'), 'click', (e) => {
      const $target = $(e.target);
      if (!$target.closest('.emoji-menu-content').length) {
        $('.js-awards-block.current').removeClass('current');
53
      }
54 55 56
      if (!$target.closest('.emoji-menu').length) {
        if ($('.emoji-menu').is(':visible')) {
          $('.js-add-award.is-active').removeClass('is-active');
57
          this.hideMenuElement($('.emoji-menu'));
58 59 60 61 62 63 64 65
        }
      }
    });
    this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', (e) => {
      e.preventDefault();
      const $target = $(e.currentTarget);
      const $glEmojiElement = $target.find('gl-emoji');
      const $spriteIconElement = $target.find('.icon');
66
      const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
67 68

      $target.closest('.js-awards-block').addClass('current');
69
      this.addAward(this.getVotesBlock(), this.getAwardUrl(), emojiName);
70 71
    });
  }
72

73 74 75 76 77 78 79
  registerEventListener(method = 'on', element, ...args) {
    element[method].call(element, ...args);
    this.eventListeners.push({
      element,
      args,
    });
  }
80

81 82 83
  showEmojiMenu($addBtn) {
    if ($addBtn.hasClass('js-note-emoji')) {
      $addBtn.closest('.note').find('.js-awards-block').addClass('current');
84
    } else {
85
      $addBtn.closest('.js-awards-block').addClass('current');
86
    }
87 88 89 90 91 92 93

    const $menu = $('.emoji-menu');
    const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent();
    const $userAuthored = this.isUserAuthored($addBtn);
    if ($menu.length) {
      if ($menu.is('.is-visible')) {
        $addBtn.removeClass('is-active');
94
        this.hideMenuElement($menu);
95 96 97 98
        $('.js-emoji-menu-search').blur();
      } else {
        $addBtn.addClass('is-active');
        this.positionMenu($menu, $addBtn);
99
        this.showMenuElement($menu);
100
        $('.js-emoji-menu-search').focus();
101 102 103 104 105 106 107 108
      }
    } else {
      $addBtn.addClass('is-loading is-active');
      this.createEmojiMenu(() => {
        const $createdMenu = $('.emoji-menu');
        $addBtn.removeClass('is-loading');
        this.positionMenu($createdMenu, $addBtn);
        return setTimeout(() => {
109
          this.showMenuElement($createdMenu);
110 111 112 113 114 115
          $('.js-emoji-menu-search').focus();
        }, 200);
      });
    }

    $thumbsBtn.toggleClass('disabled', $userAuthored);
116
    $thumbsBtn.prop('disabled', $userAuthored);
117
  }
118

119 120 121 122 123 124 125
  // 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;
126

127
    // Render the first category
128
    const categoryMap = this.emoji.getEmojiCategoryMap();
129 130
    const categoryNameKey = Object.keys(categoryMap)[0];
    const emojisInCategory = categoryMap[categoryNameKey];
131
    const firstCategory = this.renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
132 133 134 135 136

    // Render the frequently used
    const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
    let frequentlyUsedCatgegory = '';
    if (frequentlyUsedEmojis.length > 0) {
137
      frequentlyUsedCatgegory = this.renderCategory('Frequently used', frequentlyUsedEmojis, {
138 139 140
        menuListClass: 'frequent-emojis',
      });
    }
141

142 143 144
    const emojiMenuMarkup = `
      <div class="emoji-menu">
        <input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" />
145

146 147 148 149
        <div class="emoji-menu-content">
          ${frequentlyUsedCatgegory}
          ${firstCategory}
        </div>
150
      </div>
151
    `;
152

153
    document.body.insertAdjacentHTML('beforeend', emojiMenuMarkup);
154

155 156 157 158 159
    this.addRemainingEmojiMenuCategories();
    this.setupSearch();
    if (callback) {
      callback();
    }
160 161
  }

162
  addRemainingEmojiMenuCategories() {
163 164 165 166 167
    if (this.isAddingRemainingEmojiMenuCategories) {
      return;
    }
    this.isAddingRemainingEmojiMenuCategories = true;

168
    const categoryMap = this.emoji.getEmojiCategoryMap();
169 170 171

    // Avoid the jank and render the remaining categories separately
    // This will take more time, but makes UI more responsive
172 173
    const menu = document.querySelector('.emoji-menu');
    const emojiContentElement = menu.querySelector('.emoji-menu-content');
174 175 176 177 178 179
    const remainingCategories = Object.keys(categoryMap).slice(1);
    const allCategoriesAddedPromise = remainingCategories.reduce(
      (promiseChain, categoryNameKey) =>
        promiseChain.then(() =>
          new Promise((resolve) => {
            const emojisInCategory = categoryMap[categoryNameKey];
180
            const categoryMarkup = this.renderCategory(
181 182 183
              categoryLabelMap[categoryNameKey],
              emojisInCategory,
            );
184
            requestAnimationFrame(() => {
185 186 187 188 189 190 191 192 193 194 195 196 197
              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'));
Fatih Acet committed
198
      }
199 200 201
    }).catch((err) => {
      emojiContentElement.insertAdjacentHTML('beforeend', '<p>We encountered an error while adding the remaining categories</p>');
      throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`);
202
    });
203
  }
204

205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
  renderCategory(name, emojiList, opts = {}) {
    return `
      <h5 class="emoji-menu-title">
        ${name}
      </h5>
      <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}">
        ${emojiList.map(emojiName => `
          <li class="emoji-menu-list-item">
            <button class="emoji-menu-btn text-center js-emoji-btn" type="button">
              ${this.emoji.glEmojiTag(emojiName, {
                sprite: true,
              })}
            </button>
          </li>
        `).join('\n')}
      </ul>
    `;
  }

224 225 226 227 228 229 230 231 232 233
  positionMenu($menu, $addBtn) {
    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`,
    };
    if (position === 'right') {
      css.left = `${($addBtn.offset().left - $menu.outerWidth()) + 20}px`;
      $menu.addClass('is-aligned-right');
234
    } else {
235 236
      css.left = `${$addBtn.offset().left}px`;
      $menu.removeClass('is-aligned-right');
237
    }
238
    return $menu.css(css);
239 240
  }

241
  addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
242 243
    const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length;

244
    if (isInIssuePage() && !isMainAwardsBlock) {
245
      const id = votesBlock.attr('id').replace('note_', '');
246

247 248
      this.hideMenuElement($('.emoji-menu'));

249
      $('.js-add-award.is-active').removeClass('is-active');
250 251 252 253 254 255
      const toggleAwardEvent = new CustomEvent('toggleAward', {
        detail: {
          awardName: emoji,
          noteId: id,
        },
      });
256

257
      document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent);
258 259
    }

260
    const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
261
    const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
262

263 264 265 266
    this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
      this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
      return typeof callback === 'function' ? callback() : undefined;
    });
267

268 269
    this.hideMenuElement($('.emoji-menu'));

270
    return $('.js-add-award.is-active').removeClass('is-active');
271 272
  }

273
  addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
274 275 276 277
    if (checkForMutuality || checkForMutuality === null) {
      this.checkMutuality(votesBlock, emoji);
    }
    this.addEmojiToFrequentlyUsedList(emoji);
278
    const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294
    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);
    }
  }
295

296
  getVotesBlock() {
297
    if (isInIssuePage()) {
298 299 300 301 302
      const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');

      if ($el.length) {
        return $el;
      }
303 304
    }

305 306 307 308 309 310 311 312
    const currentBlock = $('.js-awards-block.current');
    let resultantVotesBlock = currentBlock;
    if (currentBlock.length === 0) {
      resultantVotesBlock = $('.js-awards-block').eq(0);
    }

    return resultantVotesBlock;
  }
313

314 315 316 317 318 319 320 321 322 323 324 325 326
  getAwardUrl() {
    return this.getVotesBlock().data('award-url');
  }

  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);
      }
327 328 329
    }
  }

330 331 332
  isActive($emojiButton) {
    return $emojiButton.hasClass('active');
  }
333

334 335 336
  isUserAuthored($button) {
    return $button.hasClass('js-user-authored');
  }
337

338 339 340 341 342 343 344 345 346 347 348 349 350 351
  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') {
      $emojiButton.tooltip('destroy');
      counter.text('0');
      this.removeYouFromUserList($emojiButton);
      if ($emojiButton.parents('.note').length) {
        this.removeEmoji($emojiButton);
      }
    } else {
352 353
      this.removeEmoji($emojiButton);
    }
354
    return $emojiButton.removeClass('active');
355 356
  }

357 358 359 360 361 362 363
  removeEmoji($emojiButton) {
    $emojiButton.tooltip('destroy');
    $emojiButton.remove();
    const $votesBlock = this.getVotesBlock();
    if ($votesBlock.find('.js-emoji-btn').length === 0) {
      $votesBlock.addClass('hidden');
    }
364 365
  }

366 367
  getAwardTooltip($awardBlock) {
    return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
368 369
  }

370 371 372 373 374 375 376
  toSentence(list) {
    let sentence;
    if (list.length <= 2) {
      sentence = list.join(' and ');
    } else {
      sentence = `${list.slice(0, -1).join(', ')}, and ${list[list.length - 1]}`;
    }
377

378 379
    return sentence;
  }
380

381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406
  removeYouFromUserList($emojiButton) {
    const awardBlock = $emojiButton;
    const originalTitle = this.getAwardTooltip(awardBlock);
    const authors = originalTitle.split(FROM_SENTENCE_REGEX);
    authors.splice(authors.indexOf('You'), 1);
    return awardBlock
      .closest('.js-emoji-btn')
      .removeData('title')
      .removeAttr('data-title')
      .removeAttr('data-original-title')
      .attr('title', this.toSentence(authors))
      .tooltip('fixTitle');
  }

  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');
    return awardBlock
      .attr('title', this.toSentence(users))
      .tooltip('fixTitle');
  }
407

408
  createAwardButtonForVotesBlock(votesBlock, emojiName) {
409 410
    const buttonHtml = `
      <button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom">
411
        ${this.emoji.glEmojiTag(emojiName)}
412 413 414 415 416 417 418 419
        <span class="award-control-text js-counter">1</span>
      </button>
    `;
    const $emojiButton = $(buttonHtml);
    $emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('name', emojiName);
    this.animateEmoji($emojiButton);
    $('.award-control').tooltip();
    votesBlock.removeClass('current');
420
  }
421

422 423 424
  animateEmoji($emoji) {
    const className = 'pulse animated once short';
    $emoji.addClass(className);
425

426 427 428 429
    this.registerEventListener('on', $emoji, animationEndEventString, (e) => {
      $(e.currentTarget).removeClass(className);
    });
  }
430

431 432 433 434 435 436 437
  createEmoji(votesBlock, emoji) {
    if ($('.emoji-menu').length) {
      this.createAwardButtonForVotesBlock(votesBlock, emoji);
    }
    this.createEmojiMenu(() => {
      this.createAwardButtonForVotesBlock(votesBlock, emoji);
    });
438 439
  }

440 441 442 443 444 445 446 447 448 449 450 451
  postEmoji($emojiButton, awardUrl, emoji, callback) {
    if (this.isUserAuthored($emojiButton)) {
      this.userAuthored($emojiButton);
    } else {
      $.post(awardUrl, {
        name: emoji,
      }, (data) => {
        if (data.ok) {
          callback();
        }
      }).fail(() => new Flash('Something went wrong on our end.'));
    }
452
  }
453

454 455 456
  findEmojiIcon(votesBlock, emoji) {
    return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`);
  }
457

458 459 460
  userAuthored($emojiButton) {
    const oldTitle = this.getAwardTooltip($emojiButton);
    const newTitle = 'You cannot vote on your own issue, MR and note';
461
    updateTooltipTitle($emojiButton, newTitle).tooltip('show');
462 463 464
    // Restore tooltip back to award list
    return setTimeout(() => {
      $emojiButton.tooltip('hide');
465
      updateTooltipTitle($emojiButton, oldTitle);
466 467
    }, 2800);
  }
468

469 470 471 472 473 474
  scrollToAwards() {
    const options = {
      scrollTop: $('.awards').offset().top - 110,
    };
    return $('body, html').animate(options, 200);
  }
475

476
  addEmojiToFrequentlyUsedList(emoji) {
477
    if (this.emoji.isEmojiNameValid(emoji)) {
478 479 480
      this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji));
      Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
    }
481
  }
482

483 484 485 486
  getFrequentlyUsedEmojis() {
    return this.frequentlyUsedEmojis || (() => {
      const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(','));
      this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(
487
        inputName => this.emoji.isEmojiNameValid(inputName),
488
      );
489

490 491 492
      return this.frequentlyUsedEmojis;
    })();
  }
493

494 495
  setupSearch() {
    const $search = $('.js-emoji-menu-search');
496

497 498 499 500
    this.registerEventListener('on', $search, 'input', (e) => {
      const term = $(e.target).val().trim();
      this.searchEmojis(term);
    });
501

502 503 504 505 506 507 508
    const $menu = $('.emoji-menu');
    this.registerEventListener('on', $menu, transitionEndEventString, (e) => {
      if (e.target === e.currentTarget) {
        // Clear the search
        this.searchEmojis('');
      }
    });
509
  }
Fatih Acet committed
510

511 512 513 514 515 516 517 518 519 520 521 522 523 524 525
  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 = $('<h5 class="emoji-search-title"/>').text('Search results');
      const foundEmojis = this.findMatchingEmojiElements(term).show();
      const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis);
      $('.emoji-menu-content ul, .emoji-menu-content h5').hide();
      $('.emoji-menu-content').append(h5).append(ul);
    } else {
      $('.emoji-menu-content').children().show();
526
    }
527
  }
528

529
  findMatchingEmojiElements(query) {
530
    const emojiMatches = this.emoji.filterEmojiNamesByAlias(query);
531 532 533
    const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
    const $matchingElements = $emojiElements
      .filter((i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0);
534 535
    return $matchingElements.closest('li').clone();
  }
536

537 538 539 540 541 542 543 544
  /* showMenuElement and hideMenuElement are performance optimizations. We use
   * opacity to show/hide the emoji menu, because we can animate it. But opacity
   * leaves hidden elements in the render tree, which is unacceptable given the number
   * of emoji elements in the emoji menu (5k+). To get the best of both worlds, we separately
   * apply IS_RENDERED to add/remove the menu from the render tree and IS_VISIBLE to animate
   * the menu being opened and closed. */

  showMenuElement($emojiMenu) {
545 546
    $emojiMenu.addClass(IS_RENDERED);

547
    // enqueues animation as a microtask, so it begins ASAP once IS_RENDERED added
548 549
    return Promise.resolve()
      .then(() => $emojiMenu.addClass(IS_VISIBLE));
550 551 552 553 554 555 556 557 558 559 560 561 562 563
  }

  hideMenuElement($emojiMenu) {
    $emojiMenu.on(transitionEndEventString, (e) => {
      if (e.currentTarget === e.target) {
        $emojiMenu
          .removeClass(IS_RENDERED)
          .off(transitionEndEventString);
      }
    });

    $emojiMenu.removeClass(IS_VISIBLE);
  }

564 565 566 567 568 569 570
  destroy() {
    this.eventListeners.forEach((entry) => {
      entry.element.off.call(entry.element, ...entry.args);
    });
    $('.emoji-menu').remove();
  }
}
571 572 573 574 575 576 577 578 579

let awardsHandlerPromise = null;
export default function loadAwardsHandler(reload = false) {
  if (!awardsHandlerPromise || reload) {
    awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji')
      .then(Emoji => new AwardsHandler(Emoji));
  }
  return awardsHandlerPromise;
}