Commit d372b7c2 authored by Toon Claes's avatar Toon Claes

Merge branch 'toon-custom-award-emoji' into 'master'

Allow custom award emoji through the API

See merge request gitlab-org/gitlab!77478
parents 2537a682 8f8eb1a8
import { import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
initEmojiMap,
getEmojiInfo,
emojiFallbackImageSrc,
emojiImageTag,
FALLBACK_EMOJI_KEY,
} from '../emoji';
import isEmojiUnicodeSupported from '../emoji/support'; import isEmojiUnicodeSupported from '../emoji/support';
class GlEmoji extends HTMLElement { class GlEmoji extends HTMLElement {
...@@ -22,10 +16,6 @@ class GlEmoji extends HTMLElement { ...@@ -22,10 +16,6 @@ class GlEmoji extends HTMLElement {
if (emojiInfo) { if (emojiInfo) {
if (name !== emojiInfo.name) { if (name !== emojiInfo.name) {
if (emojiInfo.name === FALLBACK_EMOJI_KEY && this.innerHTML) {
return; // When fallback emoji is used, but there is a <img> provided, use the <img> instead
}
({ name } = emojiInfo); ({ name } = emojiInfo);
this.dataset.name = emojiInfo.name; this.dataset.name = emojiInfo.name;
} }
...@@ -43,34 +33,29 @@ class GlEmoji extends HTMLElement { ...@@ -43,34 +33,29 @@ class GlEmoji extends HTMLElement {
this.childNodes && this.childNodes &&
Array.prototype.every.call(this.childNodes, (childNode) => childNode.nodeType === 3); Array.prototype.every.call(this.childNodes, (childNode) => childNode.nodeType === 3);
if ( const hasImageFallback = fallbackSrc?.length > 0;
emojiUnicode && const hasCssSpriteFallback = fallbackSpriteClass?.length > 0;
isEmojiUnicode &&
!isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)
) {
const hasImageFallback = fallbackSrc && fallbackSrc.length > 0;
const hasCssSpriteFallback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
// CSS sprite fallback takes precedence over image fallback if (emojiUnicode && isEmojiUnicode && isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) {
if (hasCssSpriteFallback) { // noop
if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { } else if (hasCssSpriteFallback) {
const emojiSpriteLinkTag = document.createElement('link'); if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) {
emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); const emojiSpriteLinkTag = document.createElement('link');
emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); emojiSpriteLinkTag.setAttribute('rel', 'stylesheet');
document.head.appendChild(emojiSpriteLinkTag); emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path);
gon.emoji_sprites_css_added = true; document.head.appendChild(emojiSpriteLinkTag);
} gon.emoji_sprites_css_added = true;
// IE 11 doesn't like adding multiple at once :(
this.classList.add('emoji-icon');
this.classList.add(fallbackSpriteClass);
} else if (hasImageFallback) {
this.innerHTML = '';
this.appendChild(emojiImageTag(name, fallbackSrc));
} else {
const src = emojiFallbackImageSrc(name);
this.innerHTML = '';
this.appendChild(emojiImageTag(name, src));
} }
// IE 11 doesn't like adding multiple at once :(
this.classList.add('emoji-icon');
this.classList.add(fallbackSpriteClass);
} else if (hasImageFallback) {
this.innerHTML = '';
this.appendChild(emojiImageTag(name, fallbackSrc));
} else {
const src = emojiFallbackImageSrc(name);
this.innerHTML = '';
this.appendChild(emojiImageTag(name, src));
} }
}); });
} }
......
...@@ -245,5 +245,12 @@ export function glEmojiTag(inputName, options) { ...@@ -245,5 +245,12 @@ export function glEmojiTag(inputName, options) {
? `data-fallback-sprite-class="${escape(fallbackSpriteClass)}" ` ? `data-fallback-sprite-class="${escape(fallbackSpriteClass)}" `
: ''; : '';
return `<gl-emoji ${fallbackSpriteAttribute}data-name="${escape(name)}"></gl-emoji>`; const fallbackUrl = opts.url;
const fallbackSrcAttribute = fallbackUrl
? `data-fallback-src="${fallbackUrl}" data-unicode-version="custom"`
: '';
return `<gl-emoji ${fallbackSrcAttribute}${fallbackSpriteAttribute}data-name="${escape(
name,
)}"></gl-emoji>`;
} }
...@@ -93,12 +93,14 @@ export default { ...@@ -93,12 +93,14 @@ export default {
return awardList.some((award) => award.user.id === this.currentUserId); return awardList.some((award) => award.user.id === this.currentUserId);
}, },
createAwardList(name, list) { createAwardList(name, list) {
const url = list.length ? list[0].url : null;
return { return {
name, name,
list, list,
title: this.getAwardListTitle(list, name), title: this.getAwardListTitle(list, name),
classes: this.getAwardClassBindings(list), classes: this.getAwardClassBindings(list),
html: glEmojiTag(name), html: glEmojiTag(name, { url }),
}; };
}, },
getAwardListTitle(awardsList, name) { getAwardListTitle(awardsList, name) {
......
...@@ -244,9 +244,13 @@ ...@@ -244,9 +244,13 @@
// above will be deprecated once all instances of "award emoji" are // above will be deprecated once all instances of "award emoji" are
// migrated to Vue. // migrated to Vue.
.gl-button .award-emoji-block gl-emoji { .gl-button .award-emoji-block {
margin-top: -1px; display: contents;
margin-bottom: -1px;
gl-emoji {
margin-top: -1px;
margin-bottom: -1px;
}
} }
.add-reaction-button { .add-reaction-button {
......
...@@ -254,10 +254,9 @@ li.note { ...@@ -254,10 +254,9 @@ li.note {
} }
img.emoji { img.emoji {
height: 20px; height: 16px;
vertical-align: top; vertical-align: top;
width: 20px; width: 20px;
margin-top: 1px;
} }
.chart { .chart {
......
...@@ -60,6 +60,10 @@ class AwardEmoji < ApplicationRecord ...@@ -60,6 +60,10 @@ class AwardEmoji < ApplicationRecord
self.name == UPVOTE_NAME self.name == UPVOTE_NAME
end end
def url
awardable.try(:namespace)&.custom_emoji&.by_name(name)&.first&.url
end
def expire_cache def expire_cache
awardable.try(:bump_updated_at) awardable.try(:bump_updated_at)
awardable.try(:expire_etag_cache) awardable.try(:expire_etag_cache)
......
...@@ -11,9 +11,24 @@ ...@@ -11,9 +11,24 @@
module Gitlab module Gitlab
class EmojiNameValidator < ActiveModel::EachValidator class EmojiNameValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value) def validate_each(record, attribute, value)
unless TanukiEmoji.find_by_alpha_code(value.to_s) return if valid_tanuki_emoji?(value)
record.errors.add(attribute, (options[:message] || 'is not a valid emoji name')) return if valid_custom_emoji?(record, value)
end
record.errors.add(attribute, (options[:message] || 'is not a valid emoji name'))
end
private
def valid_tanuki_emoji?(value)
TanukiEmoji.find_by_alpha_code(value.to_s)
end
def valid_custom_emoji?(record, value)
namespace = record.try(:awardable).try(:namespace)
return unless namespace
namespace.custom_emoji&.by_name(value.to_s)&.any?
end end
end end
end end
...@@ -8,6 +8,7 @@ module API ...@@ -8,6 +8,7 @@ module API
expose :user, using: Entities::UserBasic expose :user, using: Entities::UserBasic
expose :created_at, :updated_at expose :created_at, :updated_at
expose :awardable_id, :awardable_type expose :awardable_id, :awardable_type
expose :url
end end
end end
end end
...@@ -46,12 +46,13 @@ module Gitlab ...@@ -46,12 +46,13 @@ module Gitlab
def custom_emoji_tag(name, image_source) def custom_emoji_tag(name, image_source)
data = { data = {
name: name name: name,
fallback_src: image_source,
unicode_version: 'custom' # Prevents frontend to check for Unicode support
} }
options = { title: name, data: data }
ActionController::Base.helpers.content_tag('gl-emoji', title: name, data: data) do ActionController::Base.helpers.content_tag('gl-emoji', "", options)
emoji_image_tag(name, image_source).html_safe
end
end end
end end
end end
...@@ -3,9 +3,8 @@ ...@@ -3,9 +3,8 @@
FactoryBot.define do FactoryBot.define do
factory :custom_emoji, class: 'CustomEmoji' do factory :custom_emoji, class: 'CustomEmoji' do
sequence(:name) { |n| "custom_emoji#{n}" } sequence(:name) { |n| "custom_emoji#{n}" }
namespace
group group
file { 'https://gitlab.com/images/partyparrot.png' } file { 'https://gitlab.com/images/partyparrot.png' }
creator { namespace.owner } creator factory: :user
end end
end end
...@@ -77,6 +77,12 @@ describe('gl_emoji', () => { ...@@ -77,6 +77,12 @@ describe('gl_emoji', () => {
'<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>', '<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>',
`<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>`, `<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>`,
], ],
[
'custom emoji with image fallback',
'<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"></gl-emoji>',
'<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>',
'<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>',
],
])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => { ])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => {
it(`renders correctly with emoji support`, async () => { it(`renders correctly with emoji support`, async () => {
jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true); jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true);
......
...@@ -18,31 +18,30 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do ...@@ -18,31 +18,30 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do
doc = filter('<p>:tanuki:</p>', project: project) doc = filter('<p>:tanuki:</p>', project: project)
expect(doc.css('gl-emoji').first.attributes['title'].value).to eq('tanuki') expect(doc.css('gl-emoji').first.attributes['title'].value).to eq('tanuki')
expect(doc.css('gl-emoji img').size).to eq 1
end end
it 'correctly uses the custom emoji URL' do it 'correctly uses the custom emoji URL' do
doc = filter('<p>:tanuki:</p>') doc = filter('<p>:tanuki:</p>')
expect(doc.css('img').first.attributes['src'].value).to eq(custom_emoji.file) expect(doc.css('gl-emoji').first.attributes['data-fallback-src'].value).to eq(custom_emoji.file)
end end
it 'matches multiple same custom emoji' do it 'matches multiple same custom emoji' do
doc = filter(':tanuki: :tanuki:') doc = filter(':tanuki: :tanuki:')
expect(doc.css('img').size).to eq 2 expect(doc.css('gl-emoji').size).to eq 2
end end
it 'matches multiple custom emoji' do it 'matches multiple custom emoji' do
doc = filter(':tanuki: (:happy_tanuki:)') doc = filter(':tanuki: (:happy_tanuki:)')
expect(doc.css('img').size).to eq 2 expect(doc.css('gl-emoji').size).to eq 2
end end
it 'does not match enclosed colons' do it 'does not match enclosed colons' do
doc = filter('tanuki:tanuki:') doc = filter('tanuki:tanuki:')
expect(doc.css('img').size).to be 0 expect(doc.css('gl-emoji').size).to be 0
end end
it 'does not do N+1 query' do it 'does not do N+1 query' do
......
...@@ -58,6 +58,19 @@ RSpec.describe AwardEmoji do ...@@ -58,6 +58,19 @@ RSpec.describe AwardEmoji do
end end
end end
end end
it 'accepts custom emoji' do
user = create(:user)
group = create(:group)
group.add_maintainer(user)
project = create(:project, namespace: group)
issue = create(:issue, project: project)
emoji = create(:custom_emoji, name: 'partyparrot', namespace: group)
new_award = build(:award_emoji, user: user, awardable: issue, name: emoji.name)
expect(new_award).to be_valid
end
end end
describe 'scopes' do describe 'scopes' do
......
...@@ -38,7 +38,7 @@ RSpec.describe CustomEmoji do ...@@ -38,7 +38,7 @@ RSpec.describe CustomEmoji do
new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group) new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group)
expect(new_emoji).not_to be_valid expect(new_emoji).not_to be_valid
expect(new_emoji.errors.messages).to eq(creator: ["can't be blank"], name: ["has already been taken"]) expect(new_emoji.errors.messages).to eq(name: ["has already been taken"])
end end
it 'disallows non http and https file value' do it 'disallows non http and https file value' do
......
...@@ -26,6 +26,23 @@ RSpec.describe API::AwardEmoji do ...@@ -26,6 +26,23 @@ RSpec.describe API::AwardEmoji do
expect(json_response.first['name']).to eq(award_emoji.name) expect(json_response.first['name']).to eq(award_emoji.name)
end end
it "includes custom emoji attributes" do
group = create(:group)
group.add_maintainer(user)
project = create(:project, namespace: group)
custom_emoji = create(:custom_emoji, name: 'partyparrot', namespace: group)
issue = create(:issue, project: project)
create(:award_emoji, awardable: issue, user: user, name: custom_emoji.name)
get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.first['name']).to eq(custom_emoji.name)
expect(json_response.first['url']).to eq(custom_emoji.file)
end
it "returns a 404 error when issue id not found" do it "returns a 404 error when issue id not found" do
get api("/projects/#{project.id}/issues/#{non_existing_record_iid}/award_emoji", user) get api("/projects/#{project.id}/issues/#{non_existing_record_iid}/award_emoji", user)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment