Use native unicode emojis
- gl_emoji for falling back to image/css-sprite when the browser doesn't support an emoji - Markdown rendering (Banzai filter) - Autocomplete - Award emoji menu - Perceived perf - Immediate response because we now build client-side - Update `digests.json` generation in gemojione rake task to be more useful and include `unicodeVersion` MR: !9437 See issues - #26371 - #27250 - #22474
This commit is contained in:
parent
f911b948e9
commit
e6fc0207cb
Binary file not shown.
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.8 MiB |
|
@ -1,380 +1,519 @@
|
|||
/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, no-var, prefer-arrow-callback, consistent-return, one-var, one-var-declaration-per-line, no-unused-vars, no-else-return, prefer-template, quotes, comma-dangle, no-param-reassign, no-void, brace-style, no-underscore-dangle, no-return-assign, camelcase */
|
||||
/* global Cookies */
|
||||
|
||||
var emojiAliases = require('emoji-aliases');
|
||||
const emojiMap = require('emoji-map');
|
||||
const emojiAliases = require('emoji-aliases');
|
||||
const glEmoji = require('./behaviors/gl_emoji');
|
||||
|
||||
(function() {
|
||||
this.AwardsHandler = (function() {
|
||||
var FROM_SENTENCE_REGEX = /(?:, and | and |, )/; // For separating lists produced by ruby's Array#toSentence
|
||||
function AwardsHandler() {
|
||||
this.aliases = emojiAliases;
|
||||
$(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) {
|
||||
return function(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return _this.showEmojiMenu($(e.currentTarget));
|
||||
};
|
||||
})(this));
|
||||
$('html').on('click', function(e) {
|
||||
var $target;
|
||||
$target = $(e.target);
|
||||
if (!$target.closest('.emoji-menu-content').length) {
|
||||
$('.js-awards-block.current').removeClass('current');
|
||||
}
|
||||
if (!$target.closest('.emoji-menu').length) {
|
||||
if ($('.emoji-menu').is(':visible')) {
|
||||
$('.js-add-award.is-active').removeClass('is-active');
|
||||
return $('.emoji-menu').removeClass('is-visible');
|
||||
}
|
||||
}
|
||||
});
|
||||
$(document).off('click', '.js-emoji-btn').on('click', '.js-emoji-btn', (function(_this) {
|
||||
return function(e) {
|
||||
var $target, emoji;
|
||||
e.preventDefault();
|
||||
$target = $(e.currentTarget);
|
||||
emoji = $target.find('.icon').data('emoji');
|
||||
$target.closest('.js-awards-block').addClass('current');
|
||||
return _this.addAward(_this.getVotesBlock(), _this.getAwardUrl(), emoji);
|
||||
};
|
||||
})(this));
|
||||
const glEmojiTag = glEmoji.glEmojiTag;
|
||||
|
||||
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
|
||||
const requestAnimationFrame = window.requestAnimationFrame ||
|
||||
window.webkitRequestAnimationFrame ||
|
||||
window.mozRequestAnimationFrame ||
|
||||
window.setTimeout;
|
||||
|
||||
const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; // For separating lists produced by ruby's Array#toSentence
|
||||
|
||||
let categoryMap = null;
|
||||
|
||||
const categoryLabelMap = {
|
||||
activity: 'Activity',
|
||||
people: 'People',
|
||||
nature: 'Nature',
|
||||
food: 'Food',
|
||||
travel: 'Travel',
|
||||
objects: 'Objects',
|
||||
symbols: 'Symbols',
|
||||
flags: 'Flags',
|
||||
};
|
||||
|
||||
function buildCategoryMap() {
|
||||
return Object.keys(emojiMap).reduce((currentCategoryMap, emojiNameKey) => {
|
||||
const emojiInfo = emojiMap[emojiNameKey];
|
||||
if (currentCategoryMap[emojiInfo.category]) {
|
||||
currentCategoryMap[emojiInfo.category].push(emojiNameKey);
|
||||
}
|
||||
|
||||
AwardsHandler.prototype.showEmojiMenu = function($addBtn) {
|
||||
var $holder, $menu, url;
|
||||
$menu = $('.emoji-menu');
|
||||
if ($addBtn.hasClass('js-note-emoji')) {
|
||||
$addBtn.closest('.note').find('.js-awards-block').addClass('current');
|
||||
} else {
|
||||
$addBtn.closest('.js-awards-block').addClass('current');
|
||||
}
|
||||
if ($menu.length) {
|
||||
$holder = $addBtn.closest('.js-award-holder');
|
||||
if ($menu.is('.is-visible')) {
|
||||
$addBtn.removeClass('is-active');
|
||||
$menu.removeClass('is-visible');
|
||||
return $('#emoji_search').blur();
|
||||
} else {
|
||||
$addBtn.addClass('is-active');
|
||||
this.positionMenu($menu, $addBtn);
|
||||
$menu.addClass('is-visible');
|
||||
return $('#emoji_search').focus();
|
||||
}
|
||||
} else {
|
||||
$addBtn.addClass('is-loading is-active');
|
||||
url = this.getAwardMenuUrl();
|
||||
return this.createEmojiMenu(url, (function(_this) {
|
||||
return function() {
|
||||
$addBtn.removeClass('is-loading');
|
||||
$menu = $('.emoji-menu');
|
||||
_this.positionMenu($menu, $addBtn);
|
||||
if (!_this.frequentEmojiBlockRendered) {
|
||||
_this.renderFrequentlyUsedBlock();
|
||||
}
|
||||
return setTimeout(function() {
|
||||
$menu.addClass('is-visible');
|
||||
$('#emoji_search').focus();
|
||||
return _this.setupSearch();
|
||||
}, 200);
|
||||
};
|
||||
})(this));
|
||||
}
|
||||
};
|
||||
return currentCategoryMap;
|
||||
}, {
|
||||
activity: [],
|
||||
people: [],
|
||||
nature: [],
|
||||
food: [],
|
||||
travel: [],
|
||||
objects: [],
|
||||
symbols: [],
|
||||
flags: [],
|
||||
});
|
||||
}
|
||||
|
||||
AwardsHandler.prototype.createEmojiMenu = function(awardMenuUrl, callback) {
|
||||
return $.get(awardMenuUrl, function(response) {
|
||||
$('body').append(response);
|
||||
return callback();
|
||||
function renderCategory(name, emojiList) {
|
||||
return `
|
||||
<h5 class="emoji-menu-title">
|
||||
${name}
|
||||
</h5>
|
||||
<ul class="clearfix emoji-menu-list">
|
||||
${emojiList.map(emojiName => `
|
||||
<li class="emoji-menu-list-item">
|
||||
<button class="emoji-menu-btn text-center js-emoji-btn" type="button">
|
||||
${glEmojiTag(emojiName, {
|
||||
sprite: true,
|
||||
})}
|
||||
</button>
|
||||
</li>
|
||||
`).join('\n')}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function AwardsHandler() {
|
||||
this.eventListeners = [];
|
||||
this.aliases = emojiAliases;
|
||||
// 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) {
|
||||
requestAnimationFrame(() => {
|
||||
this.createEmojiMenu();
|
||||
});
|
||||
};
|
||||
}
|
||||
// Prebuild the categoryMap
|
||||
categoryMap = categoryMap || buildCategoryMap();
|
||||
});
|
||||
this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.showEmojiMenu($(e.currentTarget));
|
||||
});
|
||||
|
||||
AwardsHandler.prototype.positionMenu = function($menu, $addBtn) {
|
||||
var css, position;
|
||||
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
|
||||
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');
|
||||
} else {
|
||||
css.left = ($addBtn.offset().left) + "px";
|
||||
$menu.removeClass('is-aligned-right');
|
||||
this.registerEventListener('on', $('html'), 'click', (e) => {
|
||||
const $target = $(e.target);
|
||||
if (!$target.closest('.emoji-menu-content').length) {
|
||||
$('.js-awards-block.current').removeClass('current');
|
||||
}
|
||||
if (!$target.closest('.emoji-menu').length) {
|
||||
if ($('.emoji-menu').is(':visible')) {
|
||||
$('.js-add-award.is-active').removeClass('is-active');
|
||||
$('.emoji-menu').removeClass('is-visible');
|
||||
}
|
||||
return $menu.css(css);
|
||||
};
|
||||
}
|
||||
});
|
||||
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');
|
||||
const emoji = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
|
||||
$target.closest('.js-awards-block').addClass('current');
|
||||
return this.addAward(this.getVotesBlock(), this.getAwardUrl(), emoji);
|
||||
});
|
||||
}
|
||||
|
||||
AwardsHandler.prototype.addAward = function(votesBlock, awardUrl, emoji, checkMutuality, callback) {
|
||||
if (checkMutuality == null) {
|
||||
checkMutuality = true;
|
||||
AwardsHandler.prototype.registerEventListener = function registerEventListener(method = 'on', element, ...args) {
|
||||
element[method].call(element, ...args);
|
||||
this.eventListeners.push({
|
||||
element,
|
||||
args,
|
||||
});
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.showEmojiMenu = function 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 = $('.emoji-menu');
|
||||
if ($menu.length) {
|
||||
if ($menu.is('.is-visible')) {
|
||||
$addBtn.removeClass('is-active');
|
||||
$menu.removeClass('is-visible');
|
||||
$('#emoji_search').blur();
|
||||
} else {
|
||||
$addBtn.addClass('is-active');
|
||||
this.positionMenu($menu, $addBtn);
|
||||
$menu.addClass('is-visible');
|
||||
$('#emoji_search').focus();
|
||||
}
|
||||
} else {
|
||||
$addBtn.addClass('is-loading is-active');
|
||||
this.createEmojiMenu(() => {
|
||||
const $createdMenu = $('.emoji-menu');
|
||||
$addBtn.removeClass('is-loading');
|
||||
this.positionMenu($createdMenu, $addBtn);
|
||||
if (!this.frequentEmojiBlockRendered) {
|
||||
this.renderFrequentlyUsedBlock();
|
||||
}
|
||||
emoji = this.normilizeEmojiName(emoji);
|
||||
this.postEmoji(awardUrl, emoji, (function(_this) {
|
||||
return function() {
|
||||
_this.addAwardToEmojiBar(votesBlock, emoji, checkMutuality);
|
||||
return typeof callback === "function" ? callback() : void 0;
|
||||
};
|
||||
})(this));
|
||||
return $('.emoji-menu').removeClass('is-visible');
|
||||
};
|
||||
return setTimeout(() => {
|
||||
$createdMenu.addClass('is-visible');
|
||||
$('#emoji_search').focus();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.addAwardToEmojiBar = function(votesBlock, emoji, checkForMutuality) {
|
||||
var $emojiButton, counter;
|
||||
if (checkForMutuality == null) {
|
||||
checkForMutuality = true;
|
||||
// Create the emoji menu with the first category of emojis.
|
||||
// Then after the emoji menu has been expanded(and CSS transition has ended),
|
||||
// render the remaining categories of emojis one by one to avoid jank.
|
||||
AwardsHandler.prototype.createEmojiMenu = function createEmojiMenu(callback) {
|
||||
if (this.isCreatingEmojiMenu) {
|
||||
return;
|
||||
}
|
||||
this.isCreatingEmojiMenu = true;
|
||||
|
||||
// Render the first category
|
||||
categoryMap = categoryMap || buildCategoryMap();
|
||||
const categoryNameKey = Object.keys(categoryMap)[0];
|
||||
const emojisInCategory = categoryMap[categoryNameKey];
|
||||
const firstCategory = renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
|
||||
|
||||
const emojiMenuMarkup = `
|
||||
<div class="emoji-menu">
|
||||
<input type="text" name="emoji_search" id="emoji_search" value="" class="emoji-search search-input form-control" placeholder="Search emoji" />
|
||||
|
||||
<div class="emoji-menu-content">
|
||||
${firstCategory}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', emojiMenuMarkup);
|
||||
|
||||
this.addRemainingEmojiMenuCategories();
|
||||
this.setupSearch();
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler
|
||||
.prototype
|
||||
.addRemainingEmojiMenuCategories = function addRemainingEmojiMenuCategories() {
|
||||
if (this.isAddingRemainingEmojiMenuCategories) {
|
||||
return;
|
||||
}
|
||||
this.isAddingRemainingEmojiMenuCategories = true;
|
||||
|
||||
categoryMap = categoryMap || buildCategoryMap();
|
||||
|
||||
// Avoid the jank and render the remaining categories separately
|
||||
// This will take more time, but makes UI more responsive
|
||||
const emojiContentElement = document.querySelector('.emoji-menu .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 = renderCategory(
|
||||
categoryLabelMap[categoryNameKey],
|
||||
emojisInCategory,
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
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
|
||||
const menu = document.querySelector('.emoji-menu');
|
||||
if (menu) {
|
||||
menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish'));
|
||||
}
|
||||
if (checkForMutuality) {
|
||||
this.checkMutuality(votesBlock, emoji);
|
||||
}
|
||||
this.addEmojiToFrequentlyUsedList(emoji);
|
||||
emoji = this.normilizeEmojiName(emoji);
|
||||
$emojiButton = this.findEmojiIcon(votesBlock, emoji).parent();
|
||||
if ($emojiButton.length > 0) {
|
||||
if (this.isActive($emojiButton)) {
|
||||
return this.decrementCounter($emojiButton, emoji);
|
||||
} else {
|
||||
counter = $emojiButton.find('.js-counter');
|
||||
counter.text(parseInt(counter.text(), 10) + 1);
|
||||
$emojiButton.addClass('active');
|
||||
this.addYouToUserList(votesBlock, emoji);
|
||||
return this.animateEmoji($emojiButton);
|
||||
}
|
||||
} else {
|
||||
votesBlock.removeClass('hidden');
|
||||
return this.createEmoji(votesBlock, emoji);
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.getVotesBlock = function() {
|
||||
var currentBlock;
|
||||
currentBlock = $('.js-awards-block.current');
|
||||
if (currentBlock.length) {
|
||||
return currentBlock;
|
||||
} else {
|
||||
return $('.js-awards-block').eq(0);
|
||||
}
|
||||
};
|
||||
AwardsHandler.prototype.positionMenu = function 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');
|
||||
} else {
|
||||
css.left = `${$addBtn.offset().left}px`;
|
||||
$menu.removeClass('is-aligned-right');
|
||||
}
|
||||
return $menu.css(css);
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.getAwardUrl = function() {
|
||||
return this.getVotesBlock().data('award-url');
|
||||
};
|
||||
AwardsHandler.prototype.addAward = function addAward(
|
||||
votesBlock,
|
||||
awardUrl,
|
||||
emoji,
|
||||
checkMutuality,
|
||||
callback,
|
||||
) {
|
||||
const normalizedEmoji = this.normalizeEmojiName(emoji);
|
||||
this.postEmoji(awardUrl, normalizedEmoji, () => {
|
||||
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
|
||||
return typeof callback === 'function' ? callback() : undefined;
|
||||
});
|
||||
return $('.emoji-menu').removeClass('is-visible');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.checkMutuality = function(votesBlock, emoji) {
|
||||
var $emojiButton, awardUrl, isAlreadyVoted, mutualVote;
|
||||
awardUrl = this.getAwardUrl();
|
||||
if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
|
||||
mutualVote = emoji === 'thumbsup' ? 'thumbsdown' : 'thumbsup';
|
||||
$emojiButton = votesBlock.find("[data-emoji=" + mutualVote + "]").parent();
|
||||
isAlreadyVoted = $emojiButton.hasClass('active');
|
||||
if (isAlreadyVoted) {
|
||||
this.addAward(votesBlock, awardUrl, mutualVote, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.isActive = function($emojiButton) {
|
||||
return $emojiButton.hasClass('active');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.decrementCounter = function($emojiButton, emoji) {
|
||||
var counter, counterNumber;
|
||||
counter = $('.js-counter', $emojiButton);
|
||||
counterNumber = parseInt(counter.text(), 10);
|
||||
if (counterNumber > 1) {
|
||||
counter.text(counterNumber - 1);
|
||||
this.removeYouFromUserList($emojiButton, emoji);
|
||||
} else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
|
||||
$emojiButton.tooltip('destroy');
|
||||
counter.text('0');
|
||||
this.removeYouFromUserList($emojiButton, emoji);
|
||||
if ($emojiButton.parents('.note').length) {
|
||||
this.removeEmoji($emojiButton);
|
||||
}
|
||||
} else {
|
||||
this.removeEmoji($emojiButton);
|
||||
}
|
||||
return $emojiButton.removeClass('active');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.removeEmoji = function($emojiButton) {
|
||||
var $votesBlock;
|
||||
$emojiButton.tooltip('destroy');
|
||||
$emojiButton.remove();
|
||||
$votesBlock = this.getVotesBlock();
|
||||
if ($votesBlock.find('.js-emoji-btn').length === 0) {
|
||||
return $votesBlock.addClass('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.getAwardTooltip = function($awardBlock) {
|
||||
return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.toSentence = function(list) {
|
||||
if (list.length <= 2) {
|
||||
return list.join(' and ');
|
||||
}
|
||||
else {
|
||||
return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1];
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.removeYouFromUserList = function($emojiButton, emoji) {
|
||||
var authors, awardBlock, newAuthors, originalTitle;
|
||||
awardBlock = $emojiButton;
|
||||
originalTitle = this.getAwardTooltip(awardBlock);
|
||||
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');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.addYouToUserList = function(votesBlock, emoji) {
|
||||
var awardBlock, origTitle, users;
|
||||
awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
|
||||
origTitle = this.getAwardTooltip(awardBlock);
|
||||
users = [];
|
||||
if (origTitle) {
|
||||
users = origTitle.trim().split(FROM_SENTENCE_REGEX);
|
||||
}
|
||||
users.unshift('You');
|
||||
return awardBlock
|
||||
.attr('title', this.toSentence(users))
|
||||
.tooltip('fixTitle');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) {
|
||||
var $emojiButton, buttonHtml, emojiCssClass;
|
||||
emojiCssClass = this.resolveNameToCssClass(emoji);
|
||||
buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='You' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>";
|
||||
$emojiButton = $(buttonHtml);
|
||||
$emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji);
|
||||
AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar(
|
||||
votesBlock,
|
||||
emoji,
|
||||
checkForMutuality,
|
||||
) {
|
||||
if (checkForMutuality || checkForMutuality === null) {
|
||||
this.checkMutuality(votesBlock, emoji);
|
||||
}
|
||||
this.addEmojiToFrequentlyUsedList(emoji);
|
||||
const normalizedEmoji = this.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);
|
||||
$('.award-control').tooltip();
|
||||
return votesBlock.removeClass('current');
|
||||
};
|
||||
}
|
||||
} else {
|
||||
votesBlock.removeClass('hidden');
|
||||
this.createEmoji(votesBlock, normalizedEmoji);
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.animateEmoji = function($emoji) {
|
||||
var className = 'pulse animated once short';
|
||||
$emoji.addClass(className);
|
||||
AwardsHandler.prototype.getVotesBlock = function getVotesBlock() {
|
||||
const currentBlock = $('.js-awards-block.current');
|
||||
let resultantVotesBlock = currentBlock;
|
||||
if (currentBlock.length === 0) {
|
||||
resultantVotesBlock = $('.js-awards-block').eq(0);
|
||||
}
|
||||
|
||||
$emoji.on('webkitAnimationEnd animationEnd', function() {
|
||||
$(this).removeClass(className);
|
||||
});
|
||||
};
|
||||
return resultantVotesBlock;
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.createEmoji = function(votesBlock, emoji) {
|
||||
if ($('.emoji-menu').length) {
|
||||
return this.createEmoji_(votesBlock, emoji);
|
||||
}
|
||||
return this.createEmojiMenu(this.getAwardMenuUrl(), (function(_this) {
|
||||
return function() {
|
||||
return _this.createEmoji_(votesBlock, emoji);
|
||||
};
|
||||
})(this));
|
||||
};
|
||||
AwardsHandler.prototype.getAwardUrl = function getAwardUrl() {
|
||||
return this.getVotesBlock().data('award-url');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.getAwardMenuUrl = function() {
|
||||
return gon.award_menu_url;
|
||||
};
|
||||
AwardsHandler.prototype.checkMutuality = function 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.resolveNameToCssClass = function(emoji) {
|
||||
var emojiIcon, unicodeName;
|
||||
emojiIcon = $(".emoji-menu-content [data-emoji='" + emoji + "']");
|
||||
if (emojiIcon.length > 0) {
|
||||
unicodeName = emojiIcon.data('unicode-name');
|
||||
} else {
|
||||
// Find by alias
|
||||
unicodeName = $(".emoji-menu-content [data-aliases*=':" + emoji + ":']").data('unicode-name');
|
||||
}
|
||||
return "emoji-" + unicodeName;
|
||||
};
|
||||
AwardsHandler.prototype.isActive = function isActive($emojiButton) {
|
||||
return $emojiButton.hasClass('active');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.postEmoji = function(awardUrl, emoji, callback) {
|
||||
return $.post(awardUrl, {
|
||||
name: emoji
|
||||
}, function(data) {
|
||||
if (data.ok) {
|
||||
return callback();
|
||||
}
|
||||
});
|
||||
};
|
||||
AwardsHandler.prototype.decrementCounter = function 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 {
|
||||
this.removeEmoji($emojiButton);
|
||||
}
|
||||
return $emojiButton.removeClass('active');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.findEmojiIcon = function(votesBlock, emoji) {
|
||||
return votesBlock.find(".js-emoji-btn [data-emoji='" + emoji + "']");
|
||||
};
|
||||
AwardsHandler.prototype.removeEmoji = function removeEmoji($emojiButton) {
|
||||
$emojiButton.tooltip('destroy');
|
||||
$emojiButton.remove();
|
||||
const $votesBlock = this.getVotesBlock();
|
||||
if ($votesBlock.find('.js-emoji-btn').length === 0) {
|
||||
$votesBlock.addClass('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.scrollToAwards = function() {
|
||||
var options;
|
||||
options = {
|
||||
scrollTop: $('.awards').offset().top - 110
|
||||
};
|
||||
return $('body, html').animate(options, 200);
|
||||
};
|
||||
AwardsHandler.prototype.getAwardTooltip = function getAwardTooltip($awardBlock) {
|
||||
return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.normilizeEmojiName = function(emoji) {
|
||||
return this.aliases[emoji] || emoji;
|
||||
};
|
||||
AwardsHandler.prototype.toSentence = function toSentence(list) {
|
||||
let sentence;
|
||||
if (list.length <= 2) {
|
||||
sentence = list.join(' and ');
|
||||
} else {
|
||||
sentence = `${list.slice(0, -1).join(', ')}, and ${list[list.length - 1]}`;
|
||||
}
|
||||
|
||||
AwardsHandler.prototype.addEmojiToFrequentlyUsedList = function(emoji) {
|
||||
var frequentlyUsedEmojis;
|
||||
frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
|
||||
frequentlyUsedEmojis.push(emoji);
|
||||
Cookies.set('frequently_used_emojis', frequentlyUsedEmojis.join(','), { expires: 365 });
|
||||
};
|
||||
return sentence;
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.getFrequentlyUsedEmojis = function() {
|
||||
var frequentlyUsedEmojis;
|
||||
frequentlyUsedEmojis = (Cookies.get('frequently_used_emojis') || '').split(',');
|
||||
return _.compact(_.uniq(frequentlyUsedEmojis));
|
||||
};
|
||||
AwardsHandler.prototype.removeYouFromUserList = function 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');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.renderFrequentlyUsedBlock = function() {
|
||||
var emoji, frequentlyUsedEmojis, i, len, ul;
|
||||
if (Cookies.get('frequently_used_emojis')) {
|
||||
frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
|
||||
ul = $("<ul class='clearfix emoji-menu-list frequent-emojis'>");
|
||||
for (i = 0, len = frequentlyUsedEmojis.length; i < len; i += 1) {
|
||||
emoji = frequentlyUsedEmojis[i];
|
||||
$(".emoji-menu-content [data-emoji='" + emoji + "']").closest('li').clone().appendTo(ul);
|
||||
}
|
||||
$('.emoji-menu-content').prepend(ul).prepend($('<h5>').text('Frequently used'));
|
||||
}
|
||||
return this.frequentEmojiBlockRendered = true;
|
||||
};
|
||||
AwardsHandler.prototype.addYouToUserList = function 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');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.setupSearch = function() {
|
||||
return $('input.emoji-search').on('keyup', (function(_this) {
|
||||
return function(ev) {
|
||||
var found_emojis, h5, term, ul;
|
||||
term = $(ev.target).val();
|
||||
// Clean previous search results
|
||||
$('ul.emoji-menu-search, h5.emoji-search').remove();
|
||||
if (term) {
|
||||
// Generate a search result block
|
||||
h5 = $('<h5 class="emoji-search" />').text('Search results');
|
||||
found_emojis = _this.searchEmojis(term).show();
|
||||
ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(found_emojis);
|
||||
$('.emoji-menu-content ul, .emoji-menu-content h5').hide();
|
||||
return $('.emoji-menu-content').append(h5).append(ul);
|
||||
} else {
|
||||
return $('.emoji-menu-content').children().show();
|
||||
}
|
||||
};
|
||||
})(this));
|
||||
};
|
||||
AwardsHandler
|
||||
.prototype
|
||||
.createAwardButtonForVotesBlock = function createAwardButtonForVotesBlock(votesBlock, emojiName) {
|
||||
const buttonHtml = `
|
||||
<button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom">
|
||||
${glEmojiTag(emojiName)}
|
||||
<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');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.searchEmojis = function(term) {
|
||||
return $(".emoji-menu-list:not(.frequent-emojis) [data-emoji*='" + term + "']").closest('li').clone();
|
||||
};
|
||||
AwardsHandler.prototype.animateEmoji = function animateEmoji($emoji) {
|
||||
const className = 'pulse animated once short';
|
||||
$emoji.addClass(className);
|
||||
|
||||
return AwardsHandler;
|
||||
})();
|
||||
}).call(window);
|
||||
this.registerEventListener('on', $emoji, animationEndEventString, (e) => {
|
||||
$(e.currentTarget).removeClass(className);
|
||||
});
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.createEmoji = function createEmoji(votesBlock, emoji) {
|
||||
if ($('.emoji-menu').length) {
|
||||
this.createAwardButtonForVotesBlock(votesBlock, emoji);
|
||||
}
|
||||
this.createEmojiMenu(() => {
|
||||
this.createAwardButtonForVotesBlock(votesBlock, emoji);
|
||||
});
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.postEmoji = function postEmoji(awardUrl, emoji, callback) {
|
||||
return $.post(awardUrl, {
|
||||
name: emoji,
|
||||
}, (data) => {
|
||||
if (data.ok) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.findEmojiIcon = function findEmojiIcon(votesBlock, emoji) {
|
||||
return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`);
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.scrollToAwards = function scrollToAwards() {
|
||||
const options = {
|
||||
scrollTop: $('.awards').offset().top - 110,
|
||||
};
|
||||
return $('body, html').animate(options, 200);
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.normalizeEmojiName = function normalizeEmojiName(emoji) {
|
||||
return this.aliases[emoji] || emoji;
|
||||
};
|
||||
|
||||
AwardsHandler
|
||||
.prototype
|
||||
.addEmojiToFrequentlyUsedList = function addEmojiToFrequentlyUsedList(emoji) {
|
||||
const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
|
||||
frequentlyUsedEmojis.push(emoji);
|
||||
Cookies.set('frequently_used_emojis', frequentlyUsedEmojis.join(','), { expires: 365 });
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.getFrequentlyUsedEmojis = function getFrequentlyUsedEmojis() {
|
||||
const frequentlyUsedEmojis = (Cookies.get('frequently_used_emojis') || '').split(',');
|
||||
return _.compact(_.uniq(frequentlyUsedEmojis));
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.renderFrequentlyUsedBlock = function renderFrequentlyUsedBlock() {
|
||||
if (Cookies.get('frequently_used_emojis')) {
|
||||
const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
|
||||
const ul = $('<ul class="clearfix emoji-menu-list frequent-emojis">');
|
||||
for (let i = 0, len = frequentlyUsedEmojis.length; i < len; i += 1) {
|
||||
const emoji = frequentlyUsedEmojis[i];
|
||||
$(`.emoji-menu-content [data-name="${emoji}"]`).closest('li').clone().appendTo(ul);
|
||||
}
|
||||
$('.emoji-menu-content').prepend(ul).prepend($('<h5>').text('Frequently used'));
|
||||
}
|
||||
this.frequentEmojiBlockRendered = true;
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.setupSearch = function setupSearch() {
|
||||
this.registerEventListener('on', $('input.emoji-search'), 'input', (e) => {
|
||||
const term = $(e.target).val().trim();
|
||||
// Clean previous search results
|
||||
$('ul.emoji-menu-search, h5.emoji-search').remove();
|
||||
if (term.length > 0) {
|
||||
// Generate a search result block
|
||||
const h5 = $('<h5 class="emoji-search" />').text('Search results');
|
||||
const foundEmojis = this.searchEmojis(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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.searchEmojis = function searchEmojis(term) {
|
||||
const safeTerm = term.toLowerCase();
|
||||
|
||||
const namesMatchingAlias = [];
|
||||
Object.keys(emojiAliases).forEach((alias) => {
|
||||
if (alias.indexOf(safeTerm) >= 0) {
|
||||
namesMatchingAlias.push(emojiAliases[alias]);
|
||||
}
|
||||
});
|
||||
const $matchingElements = namesMatchingAlias.concat(safeTerm)
|
||||
.reduce(
|
||||
($result, searchTerm) =>
|
||||
$result.add($(`.emoji-menu-list:not(.frequent-emojis) [data-name*="${searchTerm}"]`)),
|
||||
$([]),
|
||||
);
|
||||
return $matchingElements.closest('li').clone();
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.destroy = function destroy() {
|
||||
this.eventListeners.forEach((entry) => {
|
||||
entry.element.off.call(entry.element, ...entry.args);
|
||||
});
|
||||
$('.emoji-menu').remove();
|
||||
};
|
||||
|
||||
module.exports = AwardsHandler;
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
const installCustomElements = require('document-register-element');
|
||||
const emojiMap = require('emoji-map');
|
||||
const emojiAliases = require('emoji-aliases');
|
||||
const generatedUnicodeSupportMap = require('./gl_emoji/unicode_support_map');
|
||||
const spreadString = require('./gl_emoji/spread_string');
|
||||
|
||||
installCustomElements(window);
|
||||
|
||||
function emojiImageTag(name, src) {
|
||||
return `<img class="emoji" title=":${name}:" alt=":${name}:" src="${src}" width="20" height="20" align="absmiddle" />`;
|
||||
}
|
||||
|
||||
const glEmojiTagDefaults = {
|
||||
sprite: false,
|
||||
forceFallback: false,
|
||||
};
|
||||
function glEmojiTag(inputName, options) {
|
||||
const opts = Object.assign({}, glEmojiTagDefaults, options);
|
||||
const name = emojiAliases[inputName] || inputName;
|
||||
const emojiInfo = emojiMap[name];
|
||||
const fallbackImageSrc = `${gon.relative_url_root || ''}/assets/emoji/${name}-${emojiInfo.digest}.png`;
|
||||
const fallbackSpriteClass = `emoji-${name}`;
|
||||
|
||||
const classList = [];
|
||||
if (opts.forceFallback && opts.sprite) {
|
||||
classList.push('emoji-icon');
|
||||
classList.push(fallbackSpriteClass);
|
||||
}
|
||||
const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : '';
|
||||
const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : '';
|
||||
let contents = emojiInfo.moji;
|
||||
if (opts.forceFallback && !opts.sprite) {
|
||||
contents = emojiImageTag(name, fallbackImageSrc);
|
||||
}
|
||||
|
||||
return `
|
||||
<gl-emoji
|
||||
${classAttribute}
|
||||
data-name="${name}"
|
||||
data-fallback-src="${fallbackImageSrc}"
|
||||
${fallbackSpriteAttribute}
|
||||
data-unicode-version="${emojiInfo.unicodeVersion}"
|
||||
>
|
||||
${contents}
|
||||
</gl-emoji>
|
||||
`;
|
||||
}
|
||||
|
||||
// On Windows, flags render as two-letter country codes, see http://emojipedia.org/flags/
|
||||
const flagACodePoint = 127462; // parseInt('1F1E6', 16)
|
||||
const flagZCodePoint = 127487; // parseInt('1F1FF', 16)
|
||||
function isFlagEmoji(emojiUnicode) {
|
||||
const cp = emojiUnicode.codePointAt(0);
|
||||
// Length 4 because flags are made of 2 characters which are surrogate pairs
|
||||
return emojiUnicode.length === 4 && cp >= flagACodePoint && cp <= flagZCodePoint;
|
||||
}
|
||||
|
||||
// Chrome <57 renders keycaps oddly
|
||||
// See https://bugs.chromium.org/p/chromium/issues/detail?id=632294
|
||||
// Same issue on Windows also fixed in Chrome 57, http://i.imgur.com/rQF7woO.png
|
||||
function isKeycapEmoji(emojiUnicode) {
|
||||
return emojiUnicode.length === 3 && emojiUnicode[2] === '\u20E3';
|
||||
}
|
||||
|
||||
// Check for a skin tone variation emoji which aren't always supported
|
||||
const tone1 = 127995;// parseInt('1F3FB', 16)
|
||||
const tone5 = 127999;// parseInt('1F3FF', 16)
|
||||
function isSkinToneComboEmoji(emojiUnicode) {
|
||||
return emojiUnicode.length > 2 && spreadString(emojiUnicode).some((char) => {
|
||||
const cp = char.codePointAt(0);
|
||||
return cp >= tone1 && cp <= tone5;
|
||||
});
|
||||
}
|
||||
|
||||
// macOS supports most skin tone emoji's but
|
||||
// doesn't support the skin tone versions of horse racing
|
||||
const horseRacingCodePoint = 127943;// parseInt('1F3C7', 16)
|
||||
function isHorceRacingSkinToneComboEmoji(emojiUnicode) {
|
||||
return spreadString(emojiUnicode)[0].codePointAt(0) === horseRacingCodePoint &&
|
||||
isSkinToneComboEmoji(emojiUnicode);
|
||||
}
|
||||
|
||||
// Check for `family_*`, `kiss_*`, `couple_*`
|
||||
// For ex. Windows 8.1 Firefox 51.0.1, doesn't support these
|
||||
const zwj = 8205; // parseInt('200D', 16)
|
||||
const personStartCodePoint = 128102; // parseInt('1F466', 16)
|
||||
const personEndCodePoint = 128105; // parseInt('1F469', 16)
|
||||
function isPersonZwjEmoji(emojiUnicode) {
|
||||
let hasPersonEmoji = false;
|
||||
let hasZwj = false;
|
||||
spreadString(emojiUnicode).forEach((character) => {
|
||||
const cp = character.codePointAt(0);
|
||||
if (cp === zwj) {
|
||||
hasZwj = true;
|
||||
} else if (cp >= personStartCodePoint && cp <= personEndCodePoint) {
|
||||
hasPersonEmoji = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasPersonEmoji && hasZwj;
|
||||
}
|
||||
|
||||
// Helper so we don't have to run `isFlagEmoji` twice
|
||||
// in `isEmojiUnicodeSupported` logic
|
||||
function checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) {
|
||||
const isFlagResult = isFlagEmoji(emojiUnicode);
|
||||
return (
|
||||
(unicodeSupportMap.flag && isFlagResult) ||
|
||||
!isFlagResult
|
||||
);
|
||||
}
|
||||
|
||||
// Helper so we don't have to run `isSkinToneComboEmoji` twice
|
||||
// in `isEmojiUnicodeSupported` logic
|
||||
function checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) {
|
||||
const isSkinToneResult = isSkinToneComboEmoji(emojiUnicode);
|
||||
return (
|
||||
(unicodeSupportMap.skinToneModifier && isSkinToneResult) ||
|
||||
!isSkinToneResult
|
||||
);
|
||||
}
|
||||
|
||||
// Helper func so we don't have to run `isHorceRacingSkinToneComboEmoji` twice
|
||||
// in `isEmojiUnicodeSupported` logic
|
||||
function checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) {
|
||||
const isHorseRacingSkinToneResult = isHorceRacingSkinToneComboEmoji(emojiUnicode);
|
||||
return (
|
||||
(unicodeSupportMap.horseRacing && isHorseRacingSkinToneResult) ||
|
||||
!isHorseRacingSkinToneResult
|
||||
);
|
||||
}
|
||||
|
||||
// Helper so we don't have to run `isPersonZwjEmoji` twice
|
||||
// in `isEmojiUnicodeSupported` logic
|
||||
function checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode) {
|
||||
const isPersonZwjResult = isPersonZwjEmoji(emojiUnicode);
|
||||
return (
|
||||
(unicodeSupportMap.personZwj && isPersonZwjResult) ||
|
||||
!isPersonZwjResult
|
||||
);
|
||||
}
|
||||
|
||||
// Takes in a support map and determines whether
|
||||
// the given unicode emoji is supported on the platform.
|
||||
//
|
||||
// Combines all the edge case tests into a one-stop shop method
|
||||
function isEmojiUnicodeSupported(unicodeSupportMap = {}, emojiUnicode, unicodeVersion) {
|
||||
const isOlderThanChrome57 = unicodeSupportMap.meta && unicodeSupportMap.meta.isChrome &&
|
||||
unicodeSupportMap.meta.chromeVersion < 57;
|
||||
|
||||
// For comments about each scenario, see the comments above each individual respective function
|
||||
return unicodeSupportMap[unicodeVersion] &&
|
||||
!(isOlderThanChrome57 && isKeycapEmoji(emojiUnicode)) &&
|
||||
checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) &&
|
||||
checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) &&
|
||||
checkHorseRacingSkinToneComboEmojiSupport(unicodeSupportMap, emojiUnicode) &&
|
||||
checkPersonEmojiSupport(unicodeSupportMap, emojiUnicode);
|
||||
}
|
||||
|
||||
const GlEmojiElementProto = Object.create(HTMLElement.prototype);
|
||||
GlEmojiElementProto.createdCallback = function createdCallback() {
|
||||
const emojiUnicode = this.textContent.trim();
|
||||
const {
|
||||
unicodeVersion,
|
||||
fallbackSrc,
|
||||
fallbackSpriteClass,
|
||||
} = this.dataset;
|
||||
|
||||
const isEmojiUnicode = this.childNodes && Array.prototype.every.call(
|
||||
this.childNodes,
|
||||
childNode => childNode.nodeType === 3,
|
||||
);
|
||||
const hasImageFallback = fallbackSrc && fallbackSrc.length > 0;
|
||||
const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
|
||||
|
||||
if (
|
||||
isEmojiUnicode &&
|
||||
!isEmojiUnicodeSupported(generatedUnicodeSupportMap, emojiUnicode, unicodeVersion)
|
||||
) {
|
||||
// CSS sprite fallback takes precedence over image fallback
|
||||
if (hasCssSpriteFalback) {
|
||||
// IE 11 doesn't like adding multiple at once :(
|
||||
this.classList.add('emoji-icon');
|
||||
this.classList.add(fallbackSpriteClass);
|
||||
} else if (hasImageFallback) {
|
||||
const emojiName = this.dataset.name;
|
||||
this.innerHTML = emojiImageTag(emojiName, fallbackSrc);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.registerElement('gl-emoji', {
|
||||
prototype: GlEmojiElementProto,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
emojiImageTag,
|
||||
glEmojiTag,
|
||||
isEmojiUnicodeSupported,
|
||||
isFlagEmoji,
|
||||
isKeycapEmoji,
|
||||
isSkinToneComboEmoji,
|
||||
isHorceRacingSkinToneComboEmoji,
|
||||
isPersonZwjEmoji,
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/charCodeAt#Fixing_charCodeAt()_to_handle_non-Basic-Multilingual-Plane_characters_if_their_presence_earlier_in_the_string_is_known
|
||||
function knownCharCodeAt(givenString, index) {
|
||||
const str = `${givenString}`;
|
||||
const end = str.length;
|
||||
|
||||
const surrogatePairs = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
|
||||
let idx = index;
|
||||
while ((surrogatePairs.exec(str)) != null) {
|
||||
const li = surrogatePairs.lastIndex;
|
||||
if (li - 2 < idx) {
|
||||
idx += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (idx >= end || idx < 0) {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
const code = str.charCodeAt(idx);
|
||||
|
||||
let high;
|
||||
let low;
|
||||
if (code >= 0xD800 && code <= 0xDBFF) {
|
||||
high = code;
|
||||
low = str.charCodeAt(idx + 1);
|
||||
// Go one further, since one of the "characters" is part of a surrogate pair
|
||||
return ((high - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
// See http://stackoverflow.com/a/38901550/796832
|
||||
// ES5/PhantomJS compatible version of spreading a string
|
||||
//
|
||||
// [...'foo'] -> ['f', 'o', 'o']
|
||||
// [...'🖐🏿'] -> ['🖐', '🏿']
|
||||
function spreadString(str) {
|
||||
const arr = [];
|
||||
let i = 0;
|
||||
while (!isNaN(knownCharCodeAt(str, i))) {
|
||||
const codePoint = knownCharCodeAt(str, i);
|
||||
arr.push(String.fromCodePoint(codePoint));
|
||||
i += 1;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
module.exports = spreadString;
|
|
@ -0,0 +1,151 @@
|
|||
const unicodeSupportTestMap = {
|
||||
// man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
|
||||
// occupationZwj: '\u{1F468}\u{200D}\u{1F393}',
|
||||
// woman, biking (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/
|
||||
// sexZwj: '\u{1F6B4}\u{200D}\u{2640}',
|
||||
// family_mwgb
|
||||
// Windows 8.1, Firefox 51.0.1 does not support `family_`, `kiss_`, `couple_`
|
||||
personZwj: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}',
|
||||
// horse_racing_tone5
|
||||
// Special case that is not supported on macOS 10.12 even though `skinToneModifier` succeeds
|
||||
horseRacing: '\u{1F3C7}\u{1F3FF}',
|
||||
// US flag, http://emojipedia.org/flags/
|
||||
flag: '\u{1F1FA}\u{1F1F8}',
|
||||
// http://emojipedia.org/modifiers/
|
||||
skinToneModifier: [
|
||||
// spy_tone5
|
||||
'\u{1F575}\u{1F3FF}',
|
||||
// person_with_ball_tone5
|
||||
'\u{26F9}\u{1F3FF}',
|
||||
// angel_tone5
|
||||
'\u{1F47C}\u{1F3FF}',
|
||||
],
|
||||
// rofl, http://emojipedia.org/unicode-9.0/
|
||||
'9.0': '\u{1F923}',
|
||||
// metal, http://emojipedia.org/unicode-8.0/
|
||||
'8.0': '\u{1F918}',
|
||||
// spy, http://emojipedia.org/unicode-7.0/
|
||||
'7.0': '\u{1F575}',
|
||||
// expressionless, http://emojipedia.org/unicode-6.1/
|
||||
6.1: '\u{1F611}',
|
||||
// japanese_goblin, http://emojipedia.org/unicode-6.0/
|
||||
'6.0': '\u{1F47A}',
|
||||
// sailboat, http://emojipedia.org/unicode-5.2/
|
||||
5.2: '\u{26F5}',
|
||||
// mahjong, http://emojipedia.org/unicode-5.1/
|
||||
5.1: '\u{1F004}',
|
||||
// gear, http://emojipedia.org/unicode-4.1/
|
||||
4.1: '\u{2699}',
|
||||
// zap, http://emojipedia.org/unicode-4.0/
|
||||
'4.0': '\u{26A1}',
|
||||
// recycle, http://emojipedia.org/unicode-3.2/
|
||||
3.2: '\u{267B}',
|
||||
// information_source, http://emojipedia.org/unicode-3.0/
|
||||
'3.0': '\u{2139}',
|
||||
// heart, http://emojipedia.org/unicode-1.1/
|
||||
1.1: '\u{2764}',
|
||||
};
|
||||
|
||||
function checkPixelInImageDataArray(pixelOffset, imageDataArray) {
|
||||
// `4 *` because RGBA
|
||||
const indexOffset = 4 * pixelOffset;
|
||||
const hasColor = imageDataArray[indexOffset + 0] ||
|
||||
imageDataArray[indexOffset + 1] ||
|
||||
imageDataArray[indexOffset + 2];
|
||||
const isVisible = imageDataArray[indexOffset + 3];
|
||||
// Check for some sort of color other than black
|
||||
if (hasColor && isVisible) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const chromeMatches = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
|
||||
const isChrome = chromeMatches && chromeMatches.length > 0;
|
||||
const chromeVersion = chromeMatches && chromeMatches[1] && parseInt(chromeMatches[1], 10);
|
||||
|
||||
// We use 16px because mobile Safari (iOS 9.3) doesn't properly scale emojis :/
|
||||
// See 32px, https://i.imgur.com/htY6Zym.png
|
||||
// See 16px, https://i.imgur.com/FPPsIF8.png
|
||||
const fontSize = 16;
|
||||
function testUnicodeSupportMap(testMap) {
|
||||
const testMapKeys = Object.keys(testMap);
|
||||
const numTestEntries = testMapKeys
|
||||
.reduce((list, testKey) => list.concat(testMap[testKey]), []).length;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = (2 * fontSize);
|
||||
canvas.height = (numTestEntries * fontSize);
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`;
|
||||
// Write each emoji to the canvas vertically
|
||||
let writeIndex = 0;
|
||||
testMapKeys.forEach((testKey) => {
|
||||
const testEntry = testMap[testKey];
|
||||
[].concat(testEntry).forEach((emojiUnicode) => {
|
||||
ctx.fillText(emojiUnicode, 0, (writeIndex * fontSize) + (fontSize / 2));
|
||||
writeIndex += 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Read from the canvas
|
||||
const resultMap = {};
|
||||
let readIndex = 0;
|
||||
testMapKeys.forEach((testKey) => {
|
||||
const testEntry = testMap[testKey];
|
||||
const isTestSatisfied = [].concat(testEntry).every(() => {
|
||||
// Sample along the vertical-middle for a couple of characters
|
||||
const imageData = ctx.getImageData(
|
||||
0,
|
||||
(readIndex * fontSize) + (fontSize / 2),
|
||||
2 * fontSize,
|
||||
1,
|
||||
).data;
|
||||
|
||||
let isValidEmoji = false;
|
||||
for (let currentPixel = 0; currentPixel < 64; currentPixel += 1) {
|
||||
const isLookingAtFirstChar = currentPixel < fontSize;
|
||||
const isLookingAtSecondChar = currentPixel >= (fontSize + (fontSize / 2));
|
||||
// Check for the emoji somewhere along the row
|
||||
if (isLookingAtFirstChar && checkPixelInImageDataArray(currentPixel, imageData)) {
|
||||
isValidEmoji = true;
|
||||
|
||||
// Check to see that nothing is rendered next to the first character
|
||||
// to ensure that the ZWJ sequence rendered as one piece
|
||||
} else if (isLookingAtSecondChar && checkPixelInImageDataArray(currentPixel, imageData)) {
|
||||
isValidEmoji = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
readIndex += 1;
|
||||
return isValidEmoji;
|
||||
});
|
||||
|
||||
resultMap[testKey] = isTestSatisfied;
|
||||
});
|
||||
|
||||
resultMap.meta = {
|
||||
isChrome,
|
||||
chromeVersion,
|
||||
};
|
||||
|
||||
return resultMap;
|
||||
}
|
||||
|
||||
let unicodeSupportMap;
|
||||
const userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent');
|
||||
try {
|
||||
unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map'));
|
||||
} catch (err) {
|
||||
// swallow
|
||||
}
|
||||
if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) {
|
||||
unicodeSupportMap = testUnicodeSupportMap(unicodeSupportTestMap);
|
||||
window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent);
|
||||
window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap));
|
||||
}
|
||||
|
||||
module.exports = unicodeSupportMap;
|
|
@ -46,8 +46,8 @@ require('./lib/utils/common_utils');
|
|||
},
|
||||
},
|
||||
EmojiFilter: {
|
||||
'img.emoji'(el, text) {
|
||||
return el.getAttribute('alt');
|
||||
'gl-emoji'(el, text) {
|
||||
return `:${el.getAttribute('data-name')}:`;
|
||||
},
|
||||
},
|
||||
ImageLinkFilter: {
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
require('string.prototype.codepointat');
|
||||
require('string.fromcodepoint');
|
|
@ -1,5 +1,11 @@
|
|||
/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */
|
||||
|
||||
const emojiMap = require('emoji-map');
|
||||
const emojiAliases = require('emoji-aliases');
|
||||
const glEmoji = require('./behaviors/gl_emoji');
|
||||
|
||||
const glEmojiTag = glEmoji.glEmojiTag;
|
||||
|
||||
// Creates the variables for setting up GFM auto-completion
|
||||
(function() {
|
||||
if (window.gl == null) {
|
||||
|
@ -26,7 +32,12 @@
|
|||
},
|
||||
// Emoji
|
||||
Emoji: {
|
||||
template: '<li>${name} <img alt="${name}" height="20" src="${path}" width="20" /></li>'
|
||||
templateFunction: function(name) {
|
||||
return `<li>
|
||||
${name} ${glEmojiTag(name)}
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
},
|
||||
// Team Members
|
||||
Members: {
|
||||
|
@ -113,7 +124,7 @@
|
|||
$input.atwho({
|
||||
at: ':',
|
||||
displayTpl: function(value) {
|
||||
return value.path != null ? this.Emoji.template : this.Loading.template;
|
||||
return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template;
|
||||
}.bind(this),
|
||||
insertTpl: ':${name}:',
|
||||
skipSpecialCharacterTest: true,
|
||||
|
@ -355,6 +366,8 @@
|
|||
this.isLoadingData[at] = true;
|
||||
if (this.cachedData[at]) {
|
||||
this.loadData($input, at, this.cachedData[at]);
|
||||
} else if (this.atTypeMap[at] === 'emojis') {
|
||||
this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases)));
|
||||
} else {
|
||||
$.getJSON(this.dataSources[this.atTypeMap[at]], (data) => {
|
||||
this.loadData($input, at, data);
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
/* global Cookies */
|
||||
/* global Flash */
|
||||
/* global ConfirmDangerModal */
|
||||
/* global AwardsHandler */
|
||||
/* global Aside */
|
||||
|
||||
import jQuery from 'jquery';
|
||||
|
@ -19,6 +18,15 @@ require('mousetrap/plugins/pause/mousetrap-pause');
|
|||
require('vendor/fuzzaldrin-plus');
|
||||
require('es6-promise').polyfill();
|
||||
|
||||
// extensions
|
||||
require('./extensions/string');
|
||||
require('./extensions/array');
|
||||
require('./extensions/custom_event');
|
||||
require('./extensions/element');
|
||||
require('./extensions/jquery');
|
||||
require('./extensions/object');
|
||||
require('es6-promise').polyfill();
|
||||
|
||||
// expose common libraries as globals (TODO: remove these)
|
||||
window.jQuery = jQuery;
|
||||
window.$ = jQuery;
|
||||
|
@ -61,13 +69,6 @@ require('./templates/issuable_template_selectors');
|
|||
require('./commit/file.js');
|
||||
require('./commit/image_file.js');
|
||||
|
||||
// extensions
|
||||
require('./extensions/array');
|
||||
require('./extensions/custom_event');
|
||||
require('./extensions/element');
|
||||
require('./extensions/jquery');
|
||||
require('./extensions/object');
|
||||
|
||||
// lib/utils
|
||||
require('./lib/utils/animate');
|
||||
require('./lib/utils/bootstrap_linked_tabs');
|
||||
|
@ -99,7 +100,7 @@ require('./ajax_loading_spinner');
|
|||
require('./api');
|
||||
require('./aside');
|
||||
require('./autosave');
|
||||
require('./awards_handler');
|
||||
const AwardsHandler = require('./awards_handler');
|
||||
require('./breakpoints');
|
||||
require('./broadcast_message');
|
||||
require('./build');
|
||||
|
|
|
@ -44,5 +44,6 @@
|
|||
@import "framework/images.scss";
|
||||
@import "framework/broadcast-messages";
|
||||
@import "framework/emojis.scss";
|
||||
@import "framework/emoji-sprites.scss";
|
||||
@import "framework/icons.scss";
|
||||
@import "framework/snippets.scss";
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
.emoji-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
margin-top: 3px;
|
||||
padding: $gl-padding;
|
||||
z-index: 9;
|
||||
|
@ -20,7 +21,7 @@
|
|||
opacity: 0;
|
||||
transform: scale(.2);
|
||||
transform-origin: 0 -45px;
|
||||
transition: .3s cubic-bezier(.87,-.41,.19,1.44);
|
||||
transition: .3s cubic-bezier(.67,.06,.19,1.44);
|
||||
transition-property: transform, opacity;
|
||||
|
||||
&.is-aligned-right {
|
||||
|
@ -47,12 +48,13 @@
|
|||
}
|
||||
|
||||
.emoji-menu-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.emoji-menu-list-item {
|
||||
float: left;
|
||||
padding: 3px;
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -248,7 +248,7 @@ $diff-view-modes-border: #c1c1c1;
|
|||
* Fonts
|
||||
*/
|
||||
$monospace_font: 'Menlo', 'Liberation Mono', 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
|
||||
$regular_font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
$regular_font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
|
||||
/*
|
||||
* Dropdowns
|
||||
|
|
|
@ -188,6 +188,9 @@ ul.notes {
|
|||
.note-body {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
// Help with emoji cut-off (most noticable in Safari)
|
||||
// See https://i.imgur.com/0dg87Y9.png
|
||||
padding-top: 1px;
|
||||
|
||||
.note-text {
|
||||
word-wrap: break-word;
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
class EmojisController < ApplicationController
|
||||
layout false
|
||||
|
||||
def index
|
||||
end
|
||||
end
|
|
@ -1,9 +1,5 @@
|
|||
class Projects::AutocompleteSourcesController < Projects::ApplicationController
|
||||
before_action :load_autocomplete_service, except: [:emojis, :members]
|
||||
|
||||
def emojis
|
||||
render json: Gitlab::AwardEmoji.urls
|
||||
end
|
||||
before_action :load_autocomplete_service, except: [:members]
|
||||
|
||||
def members
|
||||
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
module EmojiHelper
|
||||
def emoji_icon(*args)
|
||||
raw Gitlab::Emoji.gl_emoji_tag(*args)
|
||||
end
|
||||
end
|
|
@ -87,34 +87,6 @@ module IssuesHelper
|
|||
icon('eye-slash') if issue.confidential?
|
||||
end
|
||||
|
||||
def emoji_icon(name, unicode = nil, aliases = [], sprite: true)
|
||||
unicode ||= Gitlab::Emoji.emoji_filename(name) rescue ""
|
||||
|
||||
data = {
|
||||
aliases: aliases.join(" "),
|
||||
emoji: name,
|
||||
unicode_name: unicode
|
||||
}
|
||||
|
||||
if sprite
|
||||
# Emoji icons for the emoji menu, these use a spritesheet.
|
||||
content_tag :div, "",
|
||||
class: "icon emoji-icon emoji-#{unicode}",
|
||||
title: name,
|
||||
data: data
|
||||
else
|
||||
# Emoji icons displayed separately, used for the awards already given
|
||||
# to an issue or merge request.
|
||||
content_tag :img, "",
|
||||
class: "icon emoji",
|
||||
title: name,
|
||||
height: "20px",
|
||||
width: "20px",
|
||||
src: url_to_image("#{unicode}.png"),
|
||||
data: data
|
||||
end
|
||||
end
|
||||
|
||||
def award_user_list(awards, current_user, limit: 10)
|
||||
names = awards.map do |award|
|
||||
award.user == current_user ? 'You' : award.user.name
|
||||
|
|
|
@ -16,4 +16,4 @@
|
|||
- else
|
||||
.empty-state
|
||||
.text-center
|
||||
%h4 There are no abuse reports! #{emoji_icon 'tada'}
|
||||
%h4 There are no abuse reports! #{emoji_icon('tada')}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
%button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button",
|
||||
class: (award_state_class(awards, current_user)),
|
||||
data: { placement: "bottom", title: award_user_list(awards, current_user) } }
|
||||
= emoji_icon(emoji, sprite: false)
|
||||
= emoji_icon(emoji)
|
||||
%span.award-control-text.js-counter
|
||||
= awards.count
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
.emoji-menu
|
||||
= text_field_tag :emoji_search, "", class: "emoji-search search-input form-control", placeholder: "Search emoji"
|
||||
.emoji-menu-content
|
||||
- Gitlab::AwardEmoji.emoji_by_category.each do |category, emojis|
|
||||
%h5.emoji-menu-title
|
||||
= Gitlab::AwardEmoji::CATEGORIES[category]
|
||||
%ul.clearfix.emoji-menu-list
|
||||
- emojis.each do |emoji|
|
||||
%li.pull-left.text-center.emoji-menu-list-item
|
||||
%button.emoji-menu-btn.text-center.js-emoji-btn{ type: "button" }
|
||||
= emoji_icon(emoji["name"], emoji["unicode"], emoji["aliases"])
|
|
@ -4,7 +4,6 @@
|
|||
- if project
|
||||
:javascript
|
||||
gl.GfmAutoComplete.dataSources = {
|
||||
emojis: "#{emojis_namespace_project_autocomplete_sources_path(project.namespace, project)}",
|
||||
members: "#{members_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}",
|
||||
issues: "#{issues_namespace_project_autocomplete_sources_path(project.namespace, project)}",
|
||||
mergeRequests: "#{merge_requests_namespace_project_autocomplete_sources_path(project.namespace, project)}",
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Use native unicode emojis
|
||||
merge_request:
|
||||
author:
|
|
@ -91,7 +91,6 @@ module Gitlab
|
|||
|
||||
# Enable the asset pipeline
|
||||
config.assets.enabled = true
|
||||
config.assets.paths << Gemojione.images_path
|
||||
config.assets.paths << "vendor/assets/fonts"
|
||||
config.assets.precompile << "*.png"
|
||||
config.assets.precompile << "print.css"
|
||||
|
|
|
@ -27,9 +27,6 @@ Rails.application.routes.draw do
|
|||
get '/autocomplete/users/:id' => 'autocomplete#user'
|
||||
get '/autocomplete/projects' => 'autocomplete#projects'
|
||||
|
||||
# Emojis
|
||||
resources :emojis, only: :index
|
||||
|
||||
# Search
|
||||
get 'search' => 'search#show'
|
||||
get 'search/autocomplete' => 'search#autocomplete', as: :search_autocomplete
|
||||
|
|
|
@ -13,7 +13,6 @@ constraints(ProjectUrlConstrainer.new) do
|
|||
|
||||
resources :autocomplete_sources, only: [] do
|
||||
collection do
|
||||
get 'emojis'
|
||||
get 'members'
|
||||
get 'issues'
|
||||
get 'merge_requests'
|
||||
|
|
|
@ -132,6 +132,7 @@ var config = {
|
|||
extensions: ['.js', '.es6', '.js.es6'],
|
||||
alias: {
|
||||
'~': path.join(ROOT_PATH, 'app/assets/javascripts'),
|
||||
'emoji-map$': path.join(ROOT_PATH, 'fixtures/emojis/digests.json'),
|
||||
'emoji-aliases$': path.join(ROOT_PATH, 'fixtures/emojis/aliases.json'),
|
||||
'icons': path.join(ROOT_PATH, 'app/views/shared/icons'),
|
||||
'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'),
|
||||
|
|
|
@ -90,7 +90,7 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps
|
|||
|
||||
step 'I see search result for "hand"' do
|
||||
page.within '.emoji-menu-content' do
|
||||
expect(page).to have_selector '[data-emoji="raised_hand"]'
|
||||
expect(page).to have_selector '[data-name="raised_hand"]'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -17,8 +17,8 @@ module Banzai
|
|||
|
||||
next unless content.include?(':') || node.text.match(emoji_unicode_pattern)
|
||||
|
||||
html = emoji_name_image_filter(content)
|
||||
html = emoji_unicode_image_filter(html)
|
||||
html = emoji_unicode_element_unicode_filter(content)
|
||||
html = emoji_name_element_unicode_filter(html)
|
||||
|
||||
next if html == content
|
||||
|
||||
|
@ -27,33 +27,30 @@ module Banzai
|
|||
doc
|
||||
end
|
||||
|
||||
# Replace :emoji: with corresponding images.
|
||||
# Replace :emoji: with corresponding gl-emoji unicode.
|
||||
#
|
||||
# text - String text to replace :emoji: in.
|
||||
#
|
||||
# Returns a String with :emoji: replaced with images.
|
||||
def emoji_name_image_filter(text)
|
||||
# Returns a String with :emoji: replaced with gl-emoji unicode.
|
||||
def emoji_name_element_unicode_filter(text)
|
||||
text.gsub(emoji_pattern) do |match|
|
||||
name = $1
|
||||
emoji_image_tag(name, emoji_url(name))
|
||||
Gitlab::Emoji.gl_emoji_tag(name)
|
||||
end
|
||||
end
|
||||
|
||||
# Replace unicode emoji with corresponding images if they exist.
|
||||
# Replace unicode emoji with corresponding gl-emoji unicode.
|
||||
#
|
||||
# text - String text to replace unicode emoji in.
|
||||
#
|
||||
# Returns a String with unicode emoji replaced with images.
|
||||
def emoji_unicode_image_filter(text)
|
||||
# Returns a String with unicode emoji replaced with gl-emoji unicode.
|
||||
def emoji_unicode_element_unicode_filter(text)
|
||||
text.gsub(emoji_unicode_pattern) do |moji|
|
||||
emoji_image_tag(Gitlab::Emoji.emojis_by_moji[moji]['name'], emoji_unicode_url(moji))
|
||||
emoji_info = Gitlab::Emoji.emojis_by_moji[moji]
|
||||
Gitlab::Emoji.gl_emoji_tag(emoji_info['name'])
|
||||
end
|
||||
end
|
||||
|
||||
def emoji_image_tag(emoji_name, emoji_url)
|
||||
"<img class='emoji' title=':#{emoji_name}:' alt=':#{emoji_name}:' src='#{emoji_url}' height='20' width='20' align='absmiddle' />"
|
||||
end
|
||||
|
||||
# Build a regexp that matches all valid :emoji: names.
|
||||
def self.emoji_pattern
|
||||
@emoji_pattern ||= /:(#{Gitlab::Emoji.emojis_names.map { |name| Regexp.escape(name) }.join('|')}):/
|
||||
|
@ -66,52 +63,13 @@ module Banzai
|
|||
|
||||
private
|
||||
|
||||
def emoji_url(name)
|
||||
emoji_path = emoji_filename(name)
|
||||
|
||||
if context[:asset_host]
|
||||
# Asset host is specified.
|
||||
url_to_image(emoji_path)
|
||||
elsif context[:asset_root]
|
||||
# Gitlab url is specified
|
||||
File.join(context[:asset_root], url_to_image(emoji_path))
|
||||
else
|
||||
# All other cases
|
||||
url_to_image(emoji_path)
|
||||
end
|
||||
end
|
||||
|
||||
def emoji_unicode_url(moji)
|
||||
emoji_unicode_path = emoji_unicode_filename(moji)
|
||||
|
||||
if context[:asset_host]
|
||||
url_to_image(emoji_unicode_path)
|
||||
elsif context[:asset_root]
|
||||
File.join(context[:asset_root], url_to_image(emoji_unicode_path))
|
||||
else
|
||||
url_to_image(emoji_unicode_path)
|
||||
end
|
||||
end
|
||||
|
||||
def url_to_image(image)
|
||||
ActionController::Base.helpers.url_to_image(image)
|
||||
end
|
||||
|
||||
def emoji_pattern
|
||||
self.class.emoji_pattern
|
||||
end
|
||||
|
||||
def emoji_filename(name)
|
||||
"#{Gitlab::Emoji.emoji_filename(name)}.png"
|
||||
end
|
||||
|
||||
def emoji_unicode_pattern
|
||||
self.class.emoji_unicode_pattern
|
||||
end
|
||||
|
||||
def emoji_unicode_filename(name)
|
||||
"#{Gitlab::Emoji.emoji_unicode_filename(name)}.png"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,84 +1,15 @@
|
|||
module Gitlab
|
||||
class AwardEmoji
|
||||
CATEGORIES = {
|
||||
objects: "Objects",
|
||||
travel: "Travel",
|
||||
symbols: "Symbols",
|
||||
nature: "Nature",
|
||||
people: "People",
|
||||
activity: "Activity",
|
||||
flags: "Flags",
|
||||
food: "Food"
|
||||
}.with_indifferent_access
|
||||
|
||||
def self.normalize_emoji_name(name)
|
||||
aliases[name] || name
|
||||
end
|
||||
|
||||
def self.emoji_by_category
|
||||
unless @emoji_by_category
|
||||
@emoji_by_category = Hash.new { |h, key| h[key] = [] }
|
||||
|
||||
emojis.each do |emoji_name, data|
|
||||
data["name"] = emoji_name
|
||||
|
||||
# Skip Fitzpatrick(tone) modifiers
|
||||
next if data["category"] == "modifier"
|
||||
|
||||
category = data["category"]
|
||||
|
||||
@emoji_by_category[category] << data
|
||||
end
|
||||
|
||||
@emoji_by_category = @emoji_by_category.sort.to_h
|
||||
end
|
||||
|
||||
@emoji_by_category
|
||||
end
|
||||
|
||||
def self.emojis
|
||||
@emojis ||=
|
||||
begin
|
||||
json_path = File.join(Rails.root, 'fixtures', 'emojis', 'index.json' )
|
||||
JSON.parse(File.read(json_path))
|
||||
end
|
||||
Gitlab::Emoji.emojis
|
||||
end
|
||||
|
||||
def self.aliases
|
||||
@aliases ||=
|
||||
begin
|
||||
json_path = File.join(Rails.root, 'fixtures', 'emojis', 'aliases.json')
|
||||
JSON.parse(File.read(json_path))
|
||||
end
|
||||
end
|
||||
|
||||
# Returns an Array of Emoji names and their asset URLs.
|
||||
def self.urls
|
||||
@urls ||= begin
|
||||
path = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json')
|
||||
# Construct the full asset path ourselves because
|
||||
# ActionView::Helpers::AssetUrlHelper.asset_url is slow for hundreds
|
||||
# of entries since it has to do a lot of extra work (e.g. regexps).
|
||||
prefix = Gitlab::Application.config.assets.prefix
|
||||
digest = Gitlab::Application.config.assets.digest
|
||||
base =
|
||||
if defined?(Gitlab::Application.config.relative_url_root) && Gitlab::Application.config.relative_url_root
|
||||
Gitlab::Application.config.relative_url_root
|
||||
else
|
||||
''
|
||||
end
|
||||
|
||||
JSON.parse(File.read(path)).map do |hash|
|
||||
fname =
|
||||
if digest
|
||||
"#{hash['unicode']}-#{hash['digest']}"
|
||||
else
|
||||
hash['unicode']
|
||||
end
|
||||
|
||||
{ name: hash['name'], path: File.join(base, prefix, "#{fname}.png") }
|
||||
end
|
||||
end
|
||||
Gitlab::Emoji.emojis_aliases
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
module Gitlab
|
||||
module Emoji
|
||||
extend self
|
||||
|
||||
@emoji_unicode_version = JSON.parse(File.read(File.absolute_path(File.dirname(__FILE__) + '/../../node_modules/emoji-unicode-version/emoji-unicode-version-map.json')))
|
||||
@emoji_aliases = JSON.parse(File.read(File.join(Rails.root, 'fixtures', 'emojis', 'aliases.json')))
|
||||
|
||||
def emojis
|
||||
Gemojione.index.instance_variable_get(:@emoji_by_name)
|
||||
end
|
||||
|
@ -18,6 +20,10 @@ module Gitlab
|
|||
emojis.keys
|
||||
end
|
||||
|
||||
def emojis_aliases
|
||||
@emoji_aliases
|
||||
end
|
||||
|
||||
def emoji_filename(name)
|
||||
emojis[name]["unicode"]
|
||||
end
|
||||
|
@ -25,5 +31,22 @@ module Gitlab
|
|||
def emoji_unicode_filename(moji)
|
||||
emojis_by_moji[moji]["unicode"]
|
||||
end
|
||||
|
||||
def emoji_unicode_version(name)
|
||||
@emoji_unicode_version[name]
|
||||
end
|
||||
|
||||
def emoji_image_tag(name, src)
|
||||
"<img class='emoji' title=':#{name}:' alt=':#{name}:' src='#{src}' height='20' width='20' align='absmiddle' />"
|
||||
end
|
||||
|
||||
# CSS sprite fallback takes precedence over image fallback
|
||||
def gl_emoji_tag(name, sprite: false, force_fallback: false)
|
||||
emoji_name = emojis_aliases[name] || name
|
||||
emoji_info = emojis[emoji_name]
|
||||
emoji_fallback_image_source = ActionController::Base.helpers.asset_url("emoji/#{emoji_info['name']}.png")
|
||||
emoji_fallback_sprite_class = "emoji-#{emoji_name}"
|
||||
"<gl-emoji #{force_fallback && sprite ? "class='emoji-icon #{emoji_fallback_sprite_class}'" : ""} data-name='#{emoji_name}' data-fallback-src='#{emoji_fallback_image_source}' #{sprite ? "data-fallback-sprite-class='#{emoji_fallback_sprite_class}'" : ""} data-unicode-version='#{emoji_unicode_version(emoji_name)}'>#{force_fallback && sprite === false ? emoji_image_tag(emoji_name, emoji_fallback_image_source) : emoji_info['moji']}</gl-emoji>"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,7 +7,6 @@ module Gitlab
|
|||
gon.relative_url_root = Gitlab.config.gitlab.relative_url_root
|
||||
gon.shortcuts_path = help_page_path('shortcuts')
|
||||
gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class
|
||||
gon.award_menu_url = emojis_path
|
||||
gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css')
|
||||
gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js')
|
||||
|
||||
|
|
|
@ -5,29 +5,29 @@ namespace :gemojione do
|
|||
require 'json'
|
||||
|
||||
dir = Gemojione.images_path
|
||||
digests = []
|
||||
aliases = Hash.new { |hash, key| hash[key] = [] }
|
||||
aliases_path = File.join(Rails.root, 'fixtures', 'emojis', 'aliases.json')
|
||||
resultant_emoji_map = {}
|
||||
|
||||
JSON.parse(File.read(aliases_path)).each do |alias_name, real_name|
|
||||
aliases[real_name] << alias_name
|
||||
end
|
||||
Gitlab::Emoji.emojis.map do |name, emoji_hash|
|
||||
# Ignore aliases
|
||||
unless Gitlab::Emoji.emojis_aliases.key?(name)
|
||||
fpath = File.join(dir, "#{emoji_hash['unicode']}.png")
|
||||
hash_digest = Digest::SHA256.file(fpath).hexdigest
|
||||
|
||||
Gitlab::AwardEmoji.emojis.map do |name, emoji_hash|
|
||||
fpath = File.join(dir, "#{emoji_hash['unicode']}.png")
|
||||
digest = Digest::SHA256.file(fpath).hexdigest
|
||||
entry = {
|
||||
category: emoji_hash['category'],
|
||||
moji: emoji_hash['moji'],
|
||||
unicodeVersion: Gitlab::Emoji.emoji_unicode_version(name),
|
||||
digest: hash_digest,
|
||||
}
|
||||
|
||||
digests << { name: name, unicode: emoji_hash['unicode'], digest: digest }
|
||||
|
||||
aliases[name].each do |alias_name|
|
||||
digests << { name: alias_name, unicode: emoji_hash['unicode'], digest: digest }
|
||||
resultant_emoji_map[name] = entry
|
||||
end
|
||||
end
|
||||
|
||||
out = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json')
|
||||
|
||||
File.open(out, 'w') do |handle|
|
||||
handle.write(JSON.pretty_generate(digests))
|
||||
handle.write(JSON.pretty_generate(resultant_emoji_map))
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -55,21 +55,42 @@ namespace :gemojione do
|
|||
SPRITESHEET_WIDTH = 860
|
||||
SPRITESHEET_HEIGHT = 840
|
||||
|
||||
# Setup a map to rename image files
|
||||
emoji_uncicode_string_to_name_map = {}
|
||||
Gitlab::Emoji.emojis.map do |name, emoji_hash|
|
||||
# Ignore aliases
|
||||
unless Gitlab::Emoji.emojis_aliases.key?(name)
|
||||
emoji_uncicode_string_to_name_map[emoji_hash['unicode']] = name
|
||||
end
|
||||
end
|
||||
|
||||
# Copy the Gemojione assets to the temporary folder for renaming
|
||||
emoji_dir = "app/assets/images/emoji"
|
||||
FileUtils.rm_rf(emoji_dir)
|
||||
FileUtils.mkdir_p(emoji_dir, mode: 0700)
|
||||
FileUtils.cp_r(File.join(Gemojione.images_path, '.'), emoji_dir)
|
||||
Dir.chdir(emoji_dir) do
|
||||
Dir["**/*.png"].each do |png|
|
||||
image_path = File.join(Dir.pwd, png)
|
||||
rename_to_named_emoji_image!(emoji_uncicode_string_to_name_map, image_path)
|
||||
end
|
||||
end
|
||||
|
||||
Dir.mktmpdir do |tmpdir|
|
||||
# Copy the Gemojione assets to the temporary folder for resizing
|
||||
FileUtils.cp_r(Gemojione.images_path, tmpdir)
|
||||
FileUtils.cp_r(File.join(emoji_dir, '.'), tmpdir)
|
||||
|
||||
Dir.chdir(tmpdir) do
|
||||
Dir["**/*.png"].each do |png|
|
||||
resize!(File.join(tmpdir, png), SIZE)
|
||||
tmp_image_path = File.join(tmpdir, png)
|
||||
resize!(tmp_image_path, SIZE)
|
||||
end
|
||||
end
|
||||
|
||||
style_path = Rails.root.join(*%w(app assets stylesheets pages emojis.scss))
|
||||
style_path = Rails.root.join(*%w(app assets stylesheets framework emoji-sprites.scss))
|
||||
|
||||
# Combine the resized assets into a packed sprite and re-generate the SCSS
|
||||
SpriteFactory.cssurl = "image-url('$IMAGE')"
|
||||
SpriteFactory.run!(File.join(tmpdir, 'png'), {
|
||||
SpriteFactory.run!(tmpdir, {
|
||||
output_style: style_path,
|
||||
output_image: "app/assets/images/emoji.png",
|
||||
selector: '.emoji-',
|
||||
|
@ -83,7 +104,7 @@ namespace :gemojione do
|
|||
# let's simplify it
|
||||
system(%Q(sed -i '' "s/width: #{SIZE}px; height: #{SIZE}px; background: image-url('emoji.png')/background-position:/" #{style_path}))
|
||||
system(%Q(sed -i '' "s/ no-repeat//" #{style_path}))
|
||||
system(%Q(sed -i '' "s/ 0px/ 0/" #{style_path}))
|
||||
system(%Q(sed -i '' "s/ 0px/ 0/g" #{style_path}))
|
||||
|
||||
# Append a generic rule that applies to all Emojis
|
||||
File.open(style_path, 'a') do |f|
|
||||
|
@ -92,6 +113,8 @@ namespace :gemojione do
|
|||
.emoji-icon {
|
||||
background-image: image-url('emoji.png');
|
||||
background-repeat: no-repeat;
|
||||
color: transparent;
|
||||
text-indent: -99em;
|
||||
height: #{SIZE}px;
|
||||
width: #{SIZE}px;
|
||||
|
||||
|
@ -112,16 +135,17 @@ namespace :gemojione do
|
|||
# Now do it again but for Retina
|
||||
Dir.mktmpdir do |tmpdir|
|
||||
# Copy the Gemojione assets to the temporary folder for resizing
|
||||
FileUtils.cp_r(Gemojione.images_path, tmpdir)
|
||||
FileUtils.cp_r(File.join(emoji_dir, '.'), tmpdir)
|
||||
|
||||
Dir.chdir(tmpdir) do
|
||||
Dir["**/*.png"].each do |png|
|
||||
resize!(File.join(tmpdir, png), RETINA)
|
||||
tmp_image_path = File.join(tmpdir, png)
|
||||
resize!(tmp_image_path, RETINA)
|
||||
end
|
||||
end
|
||||
|
||||
# Combine the resized assets into a packed sprite and re-generate the SCSS
|
||||
SpriteFactory.run!(File.join(tmpdir), {
|
||||
SpriteFactory.run!(tmpdir, {
|
||||
output_image: "app/assets/images/emoji@2x.png",
|
||||
style: false,
|
||||
nocomments: true,
|
||||
|
@ -155,4 +179,20 @@ namespace :gemojione do
|
|||
image.write(image_path) { self.quality = 100 }
|
||||
image.destroy!
|
||||
end
|
||||
|
||||
EMOJI_IMAGE_PATH_RE = /(.*?)(([0-9a-f]-?)+)\.png$/i
|
||||
def rename_to_named_emoji_image!(emoji_uncicode_string_to_name_map, image_path)
|
||||
# Rename file from unicode to emoji name
|
||||
matches = EMOJI_IMAGE_PATH_RE.match(image_path)
|
||||
preceding_path = matches[1]
|
||||
unicode_string = matches[2]
|
||||
name = emoji_uncicode_string_to_name_map[unicode_string]
|
||||
if name
|
||||
new_png_path = File.join(preceding_path, "#{name}.png")
|
||||
FileUtils.mv(image_path, new_png_path)
|
||||
new_png_path
|
||||
else
|
||||
puts "Warning: emoji_uncicode_string_to_name_map missing entry for #{unicode_string}. Full path: #{image_path}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,7 +18,9 @@
|
|||
"bootstrap-sass": "^3.3.6",
|
||||
"compression-webpack-plugin": "^0.3.2",
|
||||
"d3": "^3.5.11",
|
||||
"document-register-element": "^1.3.0",
|
||||
"dropzone": "^4.2.0",
|
||||
"emoji-unicode-version": "^0.2.1",
|
||||
"es6-promise": "^4.0.5",
|
||||
"jquery": "^2.2.1",
|
||||
"jquery-ujs": "^1.2.1",
|
||||
|
@ -29,6 +31,8 @@
|
|||
"raw-loader": "^0.5.1",
|
||||
"select2": "3.5.2-browserify",
|
||||
"stats-webpack-plugin": "^0.4.3",
|
||||
"string.fromcodepoint": "^0.2.1",
|
||||
"string.prototype.codepointat": "^0.2.0",
|
||||
"timeago.js": "^2.0.5",
|
||||
"underscore": "^1.8.3",
|
||||
"vue": "^2.1.10",
|
||||
|
|
|
@ -57,7 +57,7 @@ describe "User Feed", feature: true do
|
|||
end
|
||||
|
||||
it 'has XHTML summaries in notes' do
|
||||
expect(body).to match /Bug confirmed <img[^>]*\/>/
|
||||
expect(body).to match /Bug confirmed <gl-emoji[^>]*>/
|
||||
end
|
||||
|
||||
it 'has XHTML summaries in merge request descriptions' do
|
||||
|
|
|
@ -252,7 +252,7 @@ describe 'Copy as GFM', feature: true, js: true do
|
|||
|
||||
<<-GFM.strip_heredoc
|
||||
<a name="named-anchor"></a>
|
||||
|
||||
|
||||
<sub>sub</sub>
|
||||
|
||||
<dl>
|
||||
|
|
|
@ -105,7 +105,7 @@ feature 'Group', feature: true do
|
|||
|
||||
visit path
|
||||
|
||||
expect(page).to have_css('.group-home-desc > p > img')
|
||||
expect(page).to have_css('.group-home-desc > p > gl-emoji')
|
||||
end
|
||||
|
||||
it 'sanitizes unwanted tags' do
|
||||
|
|
|
@ -25,14 +25,14 @@ describe 'Awards Emoji', feature: true do
|
|||
end
|
||||
|
||||
it 'increments the thumbsdown emoji', js: true do
|
||||
find('[data-emoji="thumbsdown"]').click
|
||||
find('[data-name="thumbsdown"]').click
|
||||
wait_for_ajax
|
||||
expect(thumbsdown_emoji).to have_text("1")
|
||||
end
|
||||
|
||||
context 'click the thumbsup emoji' do
|
||||
it 'increments the thumbsup emoji', js: true do
|
||||
find('[data-emoji="thumbsup"]').click
|
||||
find('[data-name="thumbsup"]').click
|
||||
wait_for_ajax
|
||||
expect(thumbsup_emoji).to have_text("1")
|
||||
end
|
||||
|
@ -44,7 +44,7 @@ describe 'Awards Emoji', feature: true do
|
|||
|
||||
context 'click the thumbsdown emoji' do
|
||||
it 'increments the thumbsdown emoji', js: true do
|
||||
find('[data-emoji="thumbsdown"]').click
|
||||
find('[data-name="thumbsdown"]').click
|
||||
wait_for_ajax
|
||||
expect(thumbsdown_emoji).to have_text("1")
|
||||
end
|
||||
|
@ -123,9 +123,9 @@ describe 'Awards Emoji', feature: true do
|
|||
end
|
||||
|
||||
unless status
|
||||
first('[data-emoji="smiley"]').click
|
||||
first('[data-name="smiley"]').click
|
||||
else
|
||||
find('[data-emoji="smiley"]').click
|
||||
find('[data-name="smiley"]').click
|
||||
end
|
||||
|
||||
wait_for_ajax
|
||||
|
|
|
@ -18,7 +18,7 @@ feature 'Project', feature: true do
|
|||
it 'passes through html-pipeline' do
|
||||
project.update_attribute(:description, 'This project is the :poop:')
|
||||
visit path
|
||||
expect(page).to have_css('.project-home-desc > p > img')
|
||||
expect(page).to have_css('.project-home-desc > p > gl-emoji')
|
||||
end
|
||||
|
||||
it 'sanitizes unwanted tags' do
|
||||
|
|
|
@ -113,7 +113,7 @@ describe GitlabMarkdownHelper do
|
|||
it 'replaces commit message with emoji to link' do
|
||||
actual = link_to_gfm(':book:Book', '/foo')
|
||||
expect(actual).
|
||||
to eq %Q(<img class="emoji" title=":book:" alt=":book:" src="http://#{Gitlab.config.gitlab.host}/assets/1F4D6.png" height="20" width="20" align="absmiddle"><a href="/foo">Book</a>)
|
||||
to eq '<gl-emoji data-name="book" data-fallback-src="/assets/emoji/book.png" data-unicode-version="6.0">📖</gl-emoji><a href="/foo">Book</a>'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
/* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, no-unused-expressions, comma-dangle, new-parens, no-unused-vars, quotes, jasmine/no-spec-dupes, prefer-template, max-len */
|
||||
/* global AwardsHandler */
|
||||
|
||||
require('~/awards_handler');
|
||||
require('./fixtures/emoji_menu');
|
||||
require('es6-promise').polyfill();
|
||||
|
||||
const AwardsHandler = require('~/awards_handler');
|
||||
|
||||
(function() {
|
||||
var awardsHandler, lazyAssert, urlRoot;
|
||||
var awardsHandler, lazyAssert, urlRoot, openAndWaitForEmojiMenu;
|
||||
|
||||
awardsHandler = null;
|
||||
|
||||
|
@ -13,14 +13,6 @@ require('./fixtures/emoji_menu');
|
|||
|
||||
window.gon || (window.gon = {});
|
||||
|
||||
gl.emojiAliases = function() {
|
||||
return {
|
||||
'+1': 'thumbsup',
|
||||
'-1': 'thumbsdown'
|
||||
};
|
||||
};
|
||||
|
||||
gon.award_menu_url = '/emojis';
|
||||
urlRoot = gon.relative_url_root;
|
||||
|
||||
lazyAssert = function(done, assertFn) {
|
||||
|
@ -32,22 +24,40 @@ require('./fixtures/emoji_menu');
|
|||
};
|
||||
|
||||
describe('AwardsHandler', function() {
|
||||
preloadFixtures('issues/open-issue.html.raw');
|
||||
preloadFixtures('issues/issue_with_comment.html.raw');
|
||||
beforeEach(function() {
|
||||
loadFixtures('issues/open-issue.html.raw');
|
||||
loadFixtures('issues/issue_with_comment.html.raw');
|
||||
awardsHandler = new AwardsHandler;
|
||||
spyOn(awardsHandler, 'postEmoji').and.callFake((function(_this) {
|
||||
return function(url, emoji, cb) {
|
||||
return cb();
|
||||
};
|
||||
})(this));
|
||||
spyOn(jQuery, 'get').and.callFake(function(req, cb) {
|
||||
return cb(window.emojiMenu);
|
||||
});
|
||||
|
||||
let isEmojiMenuBuilt = false;
|
||||
openAndWaitForEmojiMenu = function() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isEmojiMenuBuilt) {
|
||||
resolve();
|
||||
} else {
|
||||
$('.js-add-award').eq(0).click();
|
||||
const $menu = $('.emoji-menu');
|
||||
$menu.one('build-emoji-menu-finish', () => {
|
||||
isEmojiMenuBuilt = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Fail after 1 second
|
||||
setTimeout(reject, 1000);
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
afterEach(function() {
|
||||
// restore original url root value
|
||||
gon.relative_url_root = urlRoot;
|
||||
|
||||
awardsHandler.destroy();
|
||||
});
|
||||
describe('::showEmojiMenu', function() {
|
||||
it('should show emoji menu when Add emoji button clicked', function(done) {
|
||||
|
@ -62,10 +72,9 @@ require('./fixtures/emoji_menu');
|
|||
});
|
||||
});
|
||||
it('should also show emoji menu for the smiley icon in notes', function(done) {
|
||||
$('.note-action-button').click();
|
||||
$('.js-add-award.note-action-button').click();
|
||||
return lazyAssert(done, function() {
|
||||
var $emojiMenu;
|
||||
$emojiMenu = $('.emoji-menu');
|
||||
var $emojiMenu = $('.emoji-menu');
|
||||
return expect($emojiMenu.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
@ -86,7 +95,7 @@ require('./fixtures/emoji_menu');
|
|||
var $emojiButton, $votesBlock;
|
||||
$votesBlock = $('.js-awards-block').eq(0);
|
||||
awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false);
|
||||
$emojiButton = $votesBlock.find('[data-emoji=heart]');
|
||||
$emojiButton = $votesBlock.find('[data-name=heart]');
|
||||
expect($emojiButton.length).toBe(1);
|
||||
expect($emojiButton.next('.js-counter').text()).toBe('1');
|
||||
return expect($votesBlock.hasClass('hidden')).toBe(false);
|
||||
|
@ -96,14 +105,14 @@ require('./fixtures/emoji_menu');
|
|||
$votesBlock = $('.js-awards-block').eq(0);
|
||||
awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false);
|
||||
awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false);
|
||||
$emojiButton = $votesBlock.find('[data-emoji=heart]');
|
||||
$emojiButton = $votesBlock.find('[data-name=heart]');
|
||||
return expect($emojiButton.length).toBe(0);
|
||||
});
|
||||
return it('should decrement the emoji counter', function() {
|
||||
var $emojiButton, $votesBlock;
|
||||
$votesBlock = $('.js-awards-block').eq(0);
|
||||
awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false);
|
||||
$emojiButton = $votesBlock.find('[data-emoji=heart]');
|
||||
$emojiButton = $votesBlock.find('[data-name=heart]');
|
||||
$emojiButton.next('.js-counter').text(5);
|
||||
awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false);
|
||||
expect($emojiButton.length).toBe(1);
|
||||
|
@ -120,8 +129,8 @@ require('./fixtures/emoji_menu');
|
|||
var $thumbsDownEmoji, $thumbsUpEmoji, $votesBlock, awardUrl;
|
||||
awardUrl = awardsHandler.getAwardUrl();
|
||||
$votesBlock = $('.js-awards-block').eq(0);
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent();
|
||||
$thumbsDownEmoji = $votesBlock.find('[data-emoji=thumbsdown]').parent();
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
|
||||
$thumbsDownEmoji = $votesBlock.find('[data-name=thumbsdown]').parent();
|
||||
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
|
||||
expect($thumbsUpEmoji.hasClass('active')).toBe(true);
|
||||
expect($thumbsDownEmoji.hasClass('active')).toBe(false);
|
||||
|
@ -138,9 +147,9 @@ require('./fixtures/emoji_menu');
|
|||
awardUrl = awardsHandler.getAwardUrl();
|
||||
$votesBlock = $('.js-awards-block').eq(0);
|
||||
awardsHandler.addAward($votesBlock, awardUrl, 'fire', false);
|
||||
expect($votesBlock.find('[data-emoji=fire]').length).toBe(1);
|
||||
awardsHandler.removeEmoji($votesBlock.find('[data-emoji=fire]').closest('button'));
|
||||
return expect($votesBlock.find('[data-emoji=fire]').length).toBe(0);
|
||||
expect($votesBlock.find('[data-name=fire]').length).toBe(1);
|
||||
awardsHandler.removeEmoji($votesBlock.find('[data-name=fire]').closest('button'));
|
||||
return expect($votesBlock.find('[data-name=fire]').length).toBe(0);
|
||||
});
|
||||
});
|
||||
describe('::addYouToUserList', function() {
|
||||
|
@ -148,7 +157,7 @@ require('./fixtures/emoji_menu');
|
|||
var $thumbsUpEmoji, $votesBlock, awardUrl;
|
||||
awardUrl = awardsHandler.getAwardUrl();
|
||||
$votesBlock = $('.js-awards-block').eq(0);
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent();
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
|
||||
$thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy');
|
||||
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
|
||||
$thumbsUpEmoji.tooltip();
|
||||
|
@ -158,7 +167,7 @@ require('./fixtures/emoji_menu');
|
|||
var $thumbsUpEmoji, $votesBlock, awardUrl;
|
||||
awardUrl = awardsHandler.getAwardUrl();
|
||||
$votesBlock = $('.js-awards-block').eq(0);
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent();
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
|
||||
$thumbsUpEmoji.attr('data-title', 'sam');
|
||||
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
|
||||
$thumbsUpEmoji.tooltip();
|
||||
|
@ -170,7 +179,7 @@ require('./fixtures/emoji_menu');
|
|||
var $thumbsUpEmoji, $votesBlock, awardUrl;
|
||||
awardUrl = awardsHandler.getAwardUrl();
|
||||
$votesBlock = $('.js-awards-block').eq(0);
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent();
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
|
||||
$thumbsUpEmoji.attr('data-title', 'You, sam, jerry, max, and andy');
|
||||
$thumbsUpEmoji.addClass('active');
|
||||
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
|
||||
|
@ -181,7 +190,7 @@ require('./fixtures/emoji_menu');
|
|||
var $thumbsUpEmoji, $votesBlock, awardUrl;
|
||||
awardUrl = awardsHandler.getAwardUrl();
|
||||
$votesBlock = $('.js-awards-block').eq(0);
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent();
|
||||
$thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
|
||||
$thumbsUpEmoji.attr('data-title', 'You and sam');
|
||||
$thumbsUpEmoji.addClass('active');
|
||||
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
|
||||
|
@ -190,42 +199,58 @@ require('./fixtures/emoji_menu');
|
|||
});
|
||||
});
|
||||
describe('search', function() {
|
||||
return it('should filter the emoji', function() {
|
||||
$('.js-add-award').eq(0).click();
|
||||
expect($('[data-emoji=angel]').is(':visible')).toBe(true);
|
||||
expect($('[data-emoji=anger]').is(':visible')).toBe(true);
|
||||
$('#emoji_search').val('ali').trigger('keyup');
|
||||
expect($('[data-emoji=angel]').is(':visible')).toBe(false);
|
||||
expect($('[data-emoji=anger]').is(':visible')).toBe(false);
|
||||
return expect($('[data-emoji=alien]').is(':visible')).toBe(true);
|
||||
return it('should filter the emoji', function(done) {
|
||||
return openAndWaitForEmojiMenu()
|
||||
.then(() => {
|
||||
expect($('[data-name=angel]').is(':visible')).toBe(true);
|
||||
expect($('[data-name=anger]').is(':visible')).toBe(true);
|
||||
$('#emoji_search').val('ali').trigger('input');
|
||||
expect($('[data-name=angel]').is(':visible')).toBe(false);
|
||||
expect($('[data-name=anger]').is(':visible')).toBe(false);
|
||||
expect($('[data-name=alien]').is(':visible')).toBe(true);
|
||||
})
|
||||
.then(done)
|
||||
.catch(() => {
|
||||
done.fail('Failed to open and build emoji menu');
|
||||
});
|
||||
});
|
||||
});
|
||||
return describe('emoji menu', function() {
|
||||
var openEmojiMenuAndAddEmoji, selector;
|
||||
selector = '[data-emoji=sunglasses]';
|
||||
openEmojiMenuAndAddEmoji = function() {
|
||||
var $block, $emoji, $menu;
|
||||
$('.js-add-award').eq(0).click();
|
||||
$menu = $('.emoji-menu');
|
||||
$block = $('.js-awards-block');
|
||||
$emoji = $menu.find('.emoji-menu-list:not(.frequent-emojis) ' + selector);
|
||||
expect($emoji.length).toBe(1);
|
||||
expect($block.find(selector).length).toBe(0);
|
||||
$emoji.click();
|
||||
expect($menu.hasClass('.is-visible')).toBe(false);
|
||||
return expect($block.find(selector).length).toBe(1);
|
||||
describe('emoji menu', function() {
|
||||
const emojiSelector = '[data-name=sunglasses]';
|
||||
const openEmojiMenuAndAddEmoji = function() {
|
||||
return openAndWaitForEmojiMenu()
|
||||
.then(() => {
|
||||
const $menu = $('.emoji-menu');
|
||||
const $block = $('.js-awards-block');
|
||||
const $emoji = $menu.find('.emoji-menu-list:not(.frequent-emojis) ' + emojiSelector);
|
||||
|
||||
expect($emoji.length).toBe(1);
|
||||
expect($block.find(emojiSelector).length).toBe(0);
|
||||
$emoji.click();
|
||||
expect($menu.hasClass('.is-visible')).toBe(false);
|
||||
expect($block.find(emojiSelector).length).toBe(1);
|
||||
});
|
||||
};
|
||||
it('should add selected emoji to awards block', function() {
|
||||
return openEmojiMenuAndAddEmoji();
|
||||
it('should add selected emoji to awards block', function(done) {
|
||||
return openEmojiMenuAndAddEmoji()
|
||||
.then(done)
|
||||
.catch(() => {
|
||||
done.fail('Failed to open and build emoji menu');
|
||||
});
|
||||
});
|
||||
return it('should remove already selected emoji', function() {
|
||||
var $block, $emoji;
|
||||
openEmojiMenuAndAddEmoji();
|
||||
$('.js-add-award').eq(0).click();
|
||||
$block = $('.js-awards-block');
|
||||
$emoji = $('.emoji-menu').find(".emoji-menu-list:not(.frequent-emojis) " + selector);
|
||||
$emoji.click();
|
||||
return expect($block.find(selector).length).toBe(0);
|
||||
it('should remove already selected emoji', function(done) {
|
||||
return openEmojiMenuAndAddEmoji()
|
||||
.then(() => {
|
||||
$('.js-add-award').eq(0).click();
|
||||
const $block = $('.js-awards-block');
|
||||
const $emoji = $('.emoji-menu').find(`.emoji-menu-list:not(.frequent-emojis) ${emojiSelector}`);
|
||||
$emoji.click();
|
||||
expect($block.find(emojiSelector).length).toBe(0);
|
||||
})
|
||||
.then(done)
|
||||
.catch((err) => {
|
||||
done.fail('Failed to open and build emoji menu');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,367 @@
|
|||
|
||||
require('~/extensions/string');
|
||||
require('~/extensions/array');
|
||||
|
||||
const glEmoji = require('~/behaviors/gl_emoji');
|
||||
|
||||
const glEmojiTag = glEmoji.glEmojiTag;
|
||||
const isEmojiUnicodeSupported = glEmoji.isEmojiUnicodeSupported;
|
||||
const isFlagEmoji = glEmoji.isFlagEmoji;
|
||||
const isKeycapEmoji = glEmoji.isKeycapEmoji;
|
||||
const isSkinToneComboEmoji = glEmoji.isSkinToneComboEmoji;
|
||||
const isHorceRacingSkinToneComboEmoji = glEmoji.isHorceRacingSkinToneComboEmoji;
|
||||
const isPersonZwjEmoji = glEmoji.isPersonZwjEmoji;
|
||||
|
||||
const emptySupportMap = {
|
||||
personZwj: false,
|
||||
horseRacing: false,
|
||||
flag: false,
|
||||
skinToneModifier: false,
|
||||
'9.0': false,
|
||||
'8.0': false,
|
||||
'7.0': false,
|
||||
6.1: false,
|
||||
'6.0': false,
|
||||
5.2: false,
|
||||
5.1: false,
|
||||
4.1: false,
|
||||
'4.0': false,
|
||||
3.2: false,
|
||||
'3.0': false,
|
||||
1.1: false,
|
||||
};
|
||||
|
||||
const emojiFixtureMap = {
|
||||
bomb: {
|
||||
name: 'bomb',
|
||||
moji: '💣',
|
||||
unicodeVersion: '6.0',
|
||||
},
|
||||
construction_worker_tone5: {
|
||||
name: 'construction_worker_tone5',
|
||||
moji: '👷🏿',
|
||||
unicodeVersion: '8.0',
|
||||
},
|
||||
five: {
|
||||
name: 'five',
|
||||
moji: '5️⃣',
|
||||
unicodeVersion: '3.0',
|
||||
},
|
||||
};
|
||||
|
||||
function markupToDomElement(markup) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = markup;
|
||||
return div.firstElementChild;
|
||||
}
|
||||
|
||||
function testGlEmojiImageFallback(element, name, src) {
|
||||
expect(element.tagName.toLowerCase()).toBe('img');
|
||||
expect(element.getAttribute('src')).toBe(src);
|
||||
expect(element.getAttribute('title')).toBe(`:${name}:`);
|
||||
expect(element.getAttribute('alt')).toBe(`:${name}:`);
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
forceFallback: false,
|
||||
sprite: false,
|
||||
};
|
||||
|
||||
function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options = {}) {
|
||||
const opts = Object.assign({}, defaults, options);
|
||||
expect(element.tagName.toLowerCase()).toBe('gl-emoji');
|
||||
expect(element.dataset.name).toBe(name);
|
||||
expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0);
|
||||
expect(element.dataset.unicodeVersion).toBe(unicodeVersion);
|
||||
|
||||
const fallbackSpriteClass = `emoji-${name}`;
|
||||
if (opts.sprite) {
|
||||
expect(element.dataset.fallbackSpriteClass).toBe(fallbackSpriteClass);
|
||||
}
|
||||
|
||||
if (opts.forceFallback && opts.sprite) {
|
||||
expect(element.getAttribute('class')).toBe(`emoji-icon ${fallbackSpriteClass}`);
|
||||
}
|
||||
|
||||
if (opts.forceFallback && !opts.sprite) {
|
||||
// Check for image fallback
|
||||
testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc);
|
||||
} else {
|
||||
// Otherwise make sure things are still unicode text
|
||||
expect(element.textContent.trim()).toBe(unicodeMoji);
|
||||
}
|
||||
}
|
||||
|
||||
describe('gl_emoji', () => {
|
||||
describe('glEmojiTag', () => {
|
||||
it('bomb emoji', () => {
|
||||
const emojiKey = 'bomb';
|
||||
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name);
|
||||
const glEmojiElement = markupToDomElement(markup);
|
||||
testGlEmojiElement(
|
||||
glEmojiElement,
|
||||
emojiFixtureMap[emojiKey].name,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
);
|
||||
});
|
||||
|
||||
it('bomb emoji with image fallback', () => {
|
||||
const emojiKey = 'bomb';
|
||||
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
|
||||
forceFallback: true,
|
||||
});
|
||||
const glEmojiElement = markupToDomElement(markup);
|
||||
testGlEmojiElement(
|
||||
glEmojiElement,
|
||||
emojiFixtureMap[emojiKey].name,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
{
|
||||
forceFallback: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('bomb emoji with sprite fallback readiness', () => {
|
||||
const emojiKey = 'bomb';
|
||||
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
|
||||
sprite: true,
|
||||
});
|
||||
const glEmojiElement = markupToDomElement(markup);
|
||||
testGlEmojiElement(
|
||||
glEmojiElement,
|
||||
emojiFixtureMap[emojiKey].name,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
{
|
||||
sprite: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
it('bomb emoji with sprite fallback', () => {
|
||||
const emojiKey = 'bomb';
|
||||
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
|
||||
forceFallback: true,
|
||||
sprite: true,
|
||||
});
|
||||
const glEmojiElement = markupToDomElement(markup);
|
||||
testGlEmojiElement(
|
||||
glEmojiElement,
|
||||
emojiFixtureMap[emojiKey].name,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
{
|
||||
forceFallback: true,
|
||||
sprite: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFlagEmoji', () => {
|
||||
it('should detect flag_ac', () => {
|
||||
expect(isFlagEmoji('🇦🇨')).toBeTruthy();
|
||||
});
|
||||
it('should detect flag_us', () => {
|
||||
expect(isFlagEmoji('🇺🇸')).toBeTruthy();
|
||||
});
|
||||
it('should detect flag_zw', () => {
|
||||
expect(isFlagEmoji('🇿🇼')).toBeTruthy();
|
||||
});
|
||||
it('should not detect flags', () => {
|
||||
expect(isFlagEmoji('🎏')).toBeFalsy();
|
||||
});
|
||||
it('should not detect triangular_flag_on_post', () => {
|
||||
expect(isFlagEmoji('🚩')).toBeFalsy();
|
||||
});
|
||||
it('should not detect single letter', () => {
|
||||
expect(isFlagEmoji('🇦')).toBeFalsy();
|
||||
});
|
||||
it('should not detect >2 letters', () => {
|
||||
expect(isFlagEmoji('🇦🇧🇨')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isKeycapEmoji', () => {
|
||||
it('should detect one(keycap)', () => {
|
||||
expect(isKeycapEmoji('1️⃣')).toBeTruthy();
|
||||
});
|
||||
it('should detect nine(keycap)', () => {
|
||||
expect(isKeycapEmoji('9️⃣')).toBeTruthy();
|
||||
});
|
||||
it('should not detect ten(keycap)', () => {
|
||||
expect(isKeycapEmoji('🔟')).toBeFalsy();
|
||||
});
|
||||
it('should not detect hash(keycap)', () => {
|
||||
expect(isKeycapEmoji('#⃣')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSkinToneComboEmoji', () => {
|
||||
it('should detect hand_splayed_tone5', () => {
|
||||
expect(isSkinToneComboEmoji('🖐🏿')).toBeTruthy();
|
||||
});
|
||||
it('should not detect hand_splayed', () => {
|
||||
expect(isSkinToneComboEmoji('🖐')).toBeFalsy();
|
||||
});
|
||||
it('should detect lifter_tone1', () => {
|
||||
expect(isSkinToneComboEmoji('🏋🏻')).toBeTruthy();
|
||||
});
|
||||
it('should not detect lifter', () => {
|
||||
expect(isSkinToneComboEmoji('🏋')).toBeFalsy();
|
||||
});
|
||||
it('should detect rowboat_tone4', () => {
|
||||
expect(isSkinToneComboEmoji('🚣🏾')).toBeTruthy();
|
||||
});
|
||||
it('should not detect rowboat', () => {
|
||||
expect(isSkinToneComboEmoji('🚣')).toBeFalsy();
|
||||
});
|
||||
it('should not detect individual tone emoji', () => {
|
||||
expect(isSkinToneComboEmoji('🏻')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHorceRacingSkinToneComboEmoji', () => {
|
||||
it('should detect horse_racing_tone2', () => {
|
||||
expect(isHorceRacingSkinToneComboEmoji('🏇🏼')).toBeTruthy();
|
||||
});
|
||||
it('should not detect horse_racing', () => {
|
||||
expect(isHorceRacingSkinToneComboEmoji('🏇')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPersonZwjEmoji', () => {
|
||||
it('should detect couple_mm', () => {
|
||||
expect(isPersonZwjEmoji('👨❤️👨')).toBeTruthy();
|
||||
});
|
||||
it('should not detect couple_with_heart', () => {
|
||||
expect(isPersonZwjEmoji('💑')).toBeFalsy();
|
||||
});
|
||||
it('should not detect couplekiss', () => {
|
||||
expect(isPersonZwjEmoji('💏')).toBeFalsy();
|
||||
});
|
||||
it('should detect family_mmb', () => {
|
||||
expect(isPersonZwjEmoji('👨👨👦')).toBeTruthy();
|
||||
});
|
||||
it('should detect family_mwgb', () => {
|
||||
expect(isPersonZwjEmoji('👨👩👧👦')).toBeTruthy();
|
||||
});
|
||||
it('should not detect family', () => {
|
||||
expect(isPersonZwjEmoji('👪')).toBeFalsy();
|
||||
});
|
||||
it('should detect kiss_ww', () => {
|
||||
expect(isPersonZwjEmoji('👩❤️💋👩')).toBeTruthy();
|
||||
});
|
||||
it('should not detect girl', () => {
|
||||
expect(isPersonZwjEmoji('👧')).toBeFalsy();
|
||||
});
|
||||
it('should not detect girl_tone5', () => {
|
||||
expect(isPersonZwjEmoji('👧🏿')).toBeFalsy();
|
||||
});
|
||||
it('should not detect man', () => {
|
||||
expect(isPersonZwjEmoji('👨')).toBeFalsy();
|
||||
});
|
||||
it('should not detect woman', () => {
|
||||
expect(isPersonZwjEmoji('👩')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmojiUnicodeSupported', () => {
|
||||
it('bomb(6.0) with 6.0 support', () => {
|
||||
const emojiKey = 'bomb';
|
||||
const unicodeSupportMap = Object.assign({}, emptySupportMap, {
|
||||
'6.0': true,
|
||||
});
|
||||
const isSupported = isEmojiUnicodeSupported(
|
||||
unicodeSupportMap,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
);
|
||||
expect(isSupported).toBeTruthy();
|
||||
});
|
||||
|
||||
it('bomb(6.0) without 6.0 support', () => {
|
||||
const emojiKey = 'bomb';
|
||||
const unicodeSupportMap = emptySupportMap;
|
||||
const isSupported = isEmojiUnicodeSupported(
|
||||
unicodeSupportMap,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
);
|
||||
expect(isSupported).toBeFalsy();
|
||||
});
|
||||
|
||||
it('bomb(6.0) without 6.0 but with 9.0 support', () => {
|
||||
const emojiKey = 'bomb';
|
||||
const unicodeSupportMap = Object.assign({}, emptySupportMap, {
|
||||
'9.0': true,
|
||||
});
|
||||
const isSupported = isEmojiUnicodeSupported(
|
||||
unicodeSupportMap,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
);
|
||||
expect(isSupported).toBeFalsy();
|
||||
});
|
||||
|
||||
it('construction_worker_tone5(8.0) without skin tone modifier support', () => {
|
||||
const emojiKey = 'construction_worker_tone5';
|
||||
const unicodeSupportMap = Object.assign({}, emptySupportMap, {
|
||||
skinToneModifier: false,
|
||||
'9.0': true,
|
||||
'8.0': true,
|
||||
'7.0': true,
|
||||
6.1: true,
|
||||
'6.0': true,
|
||||
5.2: true,
|
||||
5.1: true,
|
||||
4.1: true,
|
||||
'4.0': true,
|
||||
3.2: true,
|
||||
'3.0': true,
|
||||
1.1: true,
|
||||
});
|
||||
const isSupported = isEmojiUnicodeSupported(
|
||||
unicodeSupportMap,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
);
|
||||
expect(isSupported).toBeFalsy();
|
||||
});
|
||||
|
||||
it('use native keycap on >=57 chrome', () => {
|
||||
const emojiKey = 'five';
|
||||
const unicodeSupportMap = Object.assign({}, emptySupportMap, {
|
||||
'3.0': true,
|
||||
meta: {
|
||||
isChrome: true,
|
||||
chromeVersion: 57,
|
||||
},
|
||||
});
|
||||
const isSupported = isEmojiUnicodeSupported(
|
||||
unicodeSupportMap,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
);
|
||||
expect(isSupported).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fallback keycap on <57 chrome', () => {
|
||||
const emojiKey = 'five';
|
||||
const unicodeSupportMap = Object.assign({}, emptySupportMap, {
|
||||
'3.0': true,
|
||||
meta: {
|
||||
isChrome: true,
|
||||
chromeVersion: 50,
|
||||
},
|
||||
});
|
||||
const isSupported = isEmojiUnicodeSupported(
|
||||
unicodeSupportMap,
|
||||
emojiFixtureMap[emojiKey].moji,
|
||||
emojiFixtureMap[emojiKey].unicodeVersion,
|
||||
);
|
||||
expect(isSupported).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -14,12 +14,12 @@ describe Banzai::Filter::EmojiFilter, lib: true do
|
|||
|
||||
it 'replaces supported name emoji' do
|
||||
doc = filter('<p>:heart:</p>')
|
||||
expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/2764.png'
|
||||
expect(doc.css('gl-emoji').first.text).to eq '❤'
|
||||
end
|
||||
|
||||
it 'replaces supported unicode emoji' do
|
||||
doc = filter('<p>❤️</p>')
|
||||
expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/2764.png'
|
||||
expect(doc.css('gl-emoji').first.text).to eq '❤'
|
||||
end
|
||||
|
||||
it 'ignores unsupported emoji' do
|
||||
|
@ -30,152 +30,97 @@ describe Banzai::Filter::EmojiFilter, lib: true do
|
|||
|
||||
it 'correctly encodes the URL' do
|
||||
doc = filter('<p>:+1:</p>')
|
||||
expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/1F44D.png'
|
||||
expect(doc.css('gl-emoji').first.text).to eq '👍'
|
||||
end
|
||||
|
||||
it 'correctly encodes unicode to the URL' do
|
||||
doc = filter('<p>👍</p>')
|
||||
expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/1F44D.png'
|
||||
expect(doc.css('gl-emoji').first.text).to eq '👍'
|
||||
end
|
||||
|
||||
it 'matches at the start of a string' do
|
||||
doc = filter(':+1:')
|
||||
expect(doc.css('img').size).to eq 1
|
||||
expect(doc.css('gl-emoji').size).to eq 1
|
||||
end
|
||||
|
||||
it 'unicode matches at the start of a string' do
|
||||
doc = filter("'👍'")
|
||||
expect(doc.css('img').size).to eq 1
|
||||
expect(doc.css('gl-emoji').size).to eq 1
|
||||
end
|
||||
|
||||
it 'matches at the end of a string' do
|
||||
doc = filter('This gets a :-1:')
|
||||
expect(doc.css('img').size).to eq 1
|
||||
expect(doc.css('gl-emoji').size).to eq 1
|
||||
end
|
||||
|
||||
it 'unicode matches at the end of a string' do
|
||||
doc = filter('This gets a 👍')
|
||||
expect(doc.css('img').size).to eq 1
|
||||
expect(doc.css('gl-emoji').size).to eq 1
|
||||
end
|
||||
|
||||
it 'matches with adjacent text' do
|
||||
doc = filter('+1 (:+1:)')
|
||||
expect(doc.css('img').size).to eq 1
|
||||
expect(doc.css('gl-emoji').size).to eq 1
|
||||
end
|
||||
|
||||
it 'unicode matches with adjacent text' do
|
||||
doc = filter('+1 (👍)')
|
||||
expect(doc.css('img').size).to eq 1
|
||||
expect(doc.css('gl-emoji').size).to eq 1
|
||||
end
|
||||
|
||||
it 'matches multiple emoji in a row' do
|
||||
doc = filter(':see_no_evil::hear_no_evil::speak_no_evil:')
|
||||
expect(doc.css('img').size).to eq 3
|
||||
expect(doc.css('gl-emoji').size).to eq 3
|
||||
end
|
||||
|
||||
it 'unicode matches multiple emoji in a row' do
|
||||
doc = filter("'🙈🙉🙊'")
|
||||
expect(doc.css('img').size).to eq 3
|
||||
expect(doc.css('gl-emoji').size).to eq 3
|
||||
end
|
||||
|
||||
it 'mixed matches multiple emoji in a row' do
|
||||
doc = filter("'🙈:see_no_evil:🙉:hear_no_evil:🙊:speak_no_evil:'")
|
||||
expect(doc.css('img').size).to eq 6
|
||||
expect(doc.css('gl-emoji').size).to eq 6
|
||||
end
|
||||
|
||||
it 'has a title attribute' do
|
||||
it 'has a data-name attribute' do
|
||||
doc = filter(':-1:')
|
||||
expect(doc.css('img').first.attr('title')).to eq ':-1:'
|
||||
expect(doc.css('gl-emoji').first.attr('data-name')).to eq 'thumbsdown'
|
||||
end
|
||||
|
||||
it 'unicode has a title attribute' do
|
||||
doc = filter("'👎'")
|
||||
expect(doc.css('img').first.attr('title')).to eq ':thumbsdown:'
|
||||
end
|
||||
|
||||
it 'has an alt attribute' do
|
||||
it 'has a data-fallback-src attribute' do
|
||||
doc = filter(':-1:')
|
||||
expect(doc.css('img').first.attr('alt')).to eq ':-1:'
|
||||
expect(doc.css('gl-emoji').first.attr('data-fallback-src')).to end_with '.png'
|
||||
end
|
||||
|
||||
it 'unicode has an alt attribute' do
|
||||
doc = filter("'👎'")
|
||||
expect(doc.css('img').first.attr('alt')).to eq ':thumbsdown:'
|
||||
end
|
||||
|
||||
it 'has an align attribute' do
|
||||
doc = filter(':8ball:')
|
||||
expect(doc.css('img').first.attr('align')).to eq 'absmiddle'
|
||||
end
|
||||
|
||||
it 'unicode has an align attribute' do
|
||||
doc = filter("'🎱'")
|
||||
expect(doc.css('img').first.attr('align')).to eq 'absmiddle'
|
||||
end
|
||||
|
||||
it 'has an emoji class' do
|
||||
doc = filter(':cat:')
|
||||
expect(doc.css('img').first.attr('class')).to eq 'emoji'
|
||||
end
|
||||
|
||||
it 'unicode has an emoji class' do
|
||||
doc = filter("'🐱'")
|
||||
expect(doc.css('img').first.attr('class')).to eq 'emoji'
|
||||
end
|
||||
|
||||
it 'has height and width attributes' do
|
||||
doc = filter(':dog:')
|
||||
img = doc.css('img').first
|
||||
|
||||
expect(img.attr('width')).to eq '20'
|
||||
expect(img.attr('height')).to eq '20'
|
||||
end
|
||||
|
||||
it 'unicode has height and width attributes' do
|
||||
doc = filter("'🐶'")
|
||||
img = doc.css('img').first
|
||||
|
||||
expect(img.attr('width')).to eq '20'
|
||||
expect(img.attr('height')).to eq '20'
|
||||
it 'has a data-unicode-version attribute' do
|
||||
doc = filter(':-1:')
|
||||
expect(doc.css('gl-emoji').first.attr('data-unicode-version')).to eq '6.0'
|
||||
end
|
||||
|
||||
it 'keeps whitespace intact' do
|
||||
doc = filter('This deserves a :+1:, big time.')
|
||||
|
||||
expect(doc.to_html).to match(/^This deserves a <img.+>, big time\.\z/)
|
||||
expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/)
|
||||
end
|
||||
|
||||
it 'unicode keeps whitespace intact' do
|
||||
doc = filter('This deserves a 🎱, big time.')
|
||||
|
||||
expect(doc.to_html).to match(/^This deserves a <img.+>, big time\.\z/)
|
||||
end
|
||||
|
||||
it 'uses a custom asset_root context' do
|
||||
root = Gitlab.config.gitlab.url + 'gitlab/root'
|
||||
|
||||
doc = filter(':smile:', asset_root: root)
|
||||
expect(doc.css('img').first.attr('src')).to start_with(root)
|
||||
expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/)
|
||||
end
|
||||
|
||||
it 'uses a custom asset_host context' do
|
||||
ActionController::Base.asset_host = 'https://cdn.example.com'
|
||||
|
||||
doc = filter(':frowning:', asset_host: 'https://this-is-ignored-i-guess?')
|
||||
expect(doc.css('img').first.attr('src')).to start_with('https://cdn.example.com')
|
||||
end
|
||||
|
||||
it 'uses a custom asset_root context' do
|
||||
root = Gitlab.config.gitlab.url + 'gitlab/root'
|
||||
|
||||
doc = filter("'🎱'", asset_root: root)
|
||||
expect(doc.css('img').first.attr('src')).to start_with(root)
|
||||
expect(doc.css('gl-emoji').first.attr('data-fallback-src')).to start_with('https://cdn.example.com')
|
||||
end
|
||||
|
||||
it 'uses a custom asset_host context' do
|
||||
ActionController::Base.asset_host = 'https://cdn.example.com'
|
||||
|
||||
doc = filter("'🎱'", asset_host: 'https://this-is-ignored-i-guess?')
|
||||
expect(doc.css('img').first.attr('src')).to start_with('https://cdn.example.com')
|
||||
expect(doc.css('gl-emoji').first.attr('data-fallback-src')).to start_with('https://cdn.example.com')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,41 +1,4 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::AwardEmoji do
|
||||
describe '.urls' do
|
||||
after do
|
||||
Gitlab::AwardEmoji.instance_variable_set(:@urls, nil)
|
||||
end
|
||||
|
||||
subject { Gitlab::AwardEmoji.urls }
|
||||
|
||||
it { is_expected.to be_an_instance_of(Array) }
|
||||
it { is_expected.not_to be_empty }
|
||||
|
||||
context 'every Hash in the Array' do
|
||||
it 'has the correct keys and values' do
|
||||
subject.each do |hash|
|
||||
expect(hash[:name]).to be_an_instance_of(String)
|
||||
expect(hash[:path]).to be_an_instance_of(String)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'handles relative root' do
|
||||
it 'includes the full path' do
|
||||
allow(Gitlab::Application.config).to receive(:relative_url_root).and_return('/gitlab')
|
||||
|
||||
subject.each do |hash|
|
||||
expect(hash[:name]).to be_an_instance_of(String)
|
||||
expect(hash[:path]).to start_with('/gitlab')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.emoji_by_category' do
|
||||
it "only contains known categories" do
|
||||
undefined_categories = Gitlab::AwardEmoji.emoji_by_category.keys - Gitlab::AwardEmoji::CATEGORIES.keys
|
||||
expect(undefined_categories).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -120,7 +120,6 @@ describe 'project routing' do
|
|||
end
|
||||
end
|
||||
|
||||
# emojis_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/emojis(.:format) projects/autocomplete_sources#emojis
|
||||
# members_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/members(.:format) projects/autocomplete_sources#members
|
||||
# issues_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/issues(.:format) projects/autocomplete_sources#issues
|
||||
# merge_requests_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/merge_requests(.:format) projects/autocomplete_sources#merge_requests
|
||||
|
@ -128,7 +127,7 @@ describe 'project routing' do
|
|||
# milestones_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/milestones(.:format) projects/autocomplete_sources#milestones
|
||||
# commands_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/commands(.:format) projects/autocomplete_sources#commands
|
||||
describe Projects::AutocompleteSourcesController, 'routing' do
|
||||
[:emojis, :members, :issues, :merge_requests, :labels, :milestones, :commands].each do |action|
|
||||
[:members, :issues, :merge_requests, :labels, :milestones, :commands].each do |action|
|
||||
it "to ##{action}" do
|
||||
expect(get("/gitlab/gitlabhq/autocomplete_sources/#{action}")).to route_to("projects/autocomplete_sources##{action}", namespace_id: 'gitlab', project_id: 'gitlabhq')
|
||||
end
|
||||
|
|
|
@ -26,10 +26,10 @@ module MarkdownMatchers
|
|||
set_default_markdown_messages
|
||||
|
||||
match do |actual|
|
||||
expect(actual).to have_selector('img.emoji', count: 10)
|
||||
expect(actual).to have_selector('gl-emoji', count: 10)
|
||||
|
||||
image = actual.at_css('img.emoji')
|
||||
expect(image['src'].to_s).to start_with(Gitlab.config.gitlab.url + '/assets')
|
||||
emoji_element = actual.at_css('gl-emoji')
|
||||
expect(emoji_element['data-fallback-src'].to_s).to start_with('/assets')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -1395,6 +1395,10 @@ doctrine@1.5.0, doctrine@^1.2.2:
|
|||
esutils "^2.0.2"
|
||||
isarray "^1.0.0"
|
||||
|
||||
document-register-element@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/document-register-element/-/document-register-element-1.3.0.tgz#fb3babb523c74662be47be19c6bc33e71990d940"
|
||||
|
||||
dom-serialize@^2.2.0:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
|
||||
|
@ -1439,6 +1443,10 @@ elliptic@^6.0.0:
|
|||
hash.js "^1.0.0"
|
||||
inherits "^2.0.1"
|
||||
|
||||
emoji-unicode-version@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/emoji-unicode-version/-/emoji-unicode-version-0.2.1.tgz#0ebf3666b5414097971d34994e299fce75cdbafc"
|
||||
|
||||
emojis-list@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
|
||||
|
@ -4115,6 +4123,14 @@ string-width@^2.0.0:
|
|||
is-fullwidth-code-point "^2.0.0"
|
||||
strip-ansi "^3.0.0"
|
||||
|
||||
string.fromcodepoint@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/string.fromcodepoint/-/string.fromcodepoint-0.2.1.tgz#8d978333c0bc92538f50f383e4888f3e5619d653"
|
||||
|
||||
string.prototype.codepointat@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.0.tgz#6b26e9bd3afcaa7be3b4269b526de1b82000ac78"
|
||||
|
||||
string_decoder@^0.10.25, string_decoder@~0.10.x:
|
||||
version "0.10.31"
|
||||
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
|
||||
|
|
Loading…
Reference in New Issue