feat: improve custom emojis (#8855)

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8855
Reviewed-by: Beowulf <beowulf@beocode.eu>
This commit is contained in:
Earl Warren 2025-08-24 16:16:46 +02:00
commit 0a8d7826a4
13 changed files with 123 additions and 30 deletions

View file

@ -1142,7 +1142,7 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
converted := emoji.FromAlias(alias) converted := emoji.FromAlias(alias)
if converted == nil { if converted == nil {
// check if this is a custom reaction // check if this is a custom reaction
if _, exist := setting.UI.CustomEmojisMap[alias]; exist { if setting.UI.CustomEmojisLookup.Contains(alias) {
replaceContent(node, m[0], m[1], createCustomEmoji(alias)) replaceContent(node, m[0], m[1], createCustomEmoji(alias))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
start = 0 start = 0

View file

@ -358,7 +358,7 @@ func TestRender_emoji(t *testing.T) {
test( test(
":custom-emoji:", ":custom-emoji:",
`<p>:custom-emoji:</p>`) `<p>:custom-emoji:</p>`)
setting.UI.CustomEmojisMap["custom-emoji"] = ":custom-emoji:" setting.UI.CustomEmojisLookup.Add("custom-emoji")
test( test(
":custom-emoji:", ":custom-emoji:",
`<p><span class="emoji" aria-label="custom-emoji" data-alias="custom-emoji"><img alt=":custom-emoji:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/custom-emoji.png"/></span></p>`) `<p><span class="emoji" aria-label="custom-emoji" data-alias="custom-emoji"><img alt=":custom-emoji:" src="`+setting.StaticURLPrefix+`/assets/img/emoji/custom-emoji.png"/></span></p>`)

View file

@ -31,7 +31,7 @@ var UI = struct {
Reactions []string Reactions []string
ReactionsLookup container.Set[string] `ini:"-"` ReactionsLookup container.Set[string] `ini:"-"`
CustomEmojis []string CustomEmojis []string
CustomEmojisMap map[string]string `ini:"-"` CustomEmojisLookup container.Set[string] `ini:"-"`
SearchRepoDescription bool SearchRepoDescription bool
OnlyShowRelevantRepos bool OnlyShowRelevantRepos bool
ExploreDefaultSort string `ini:"EXPLORE_PAGING_DEFAULT_SORT"` ExploreDefaultSort string `ini:"EXPLORE_PAGING_DEFAULT_SORT"`
@ -87,7 +87,6 @@ var UI = struct {
Themes: []string{`forgejo-auto`, `forgejo-light`, `forgejo-dark`, `gitea-auto`, `gitea-light`, `gitea-dark`, `forgejo-auto-deuteranopia-protanopia`, `forgejo-light-deuteranopia-protanopia`, `forgejo-dark-deuteranopia-protanopia`, `forgejo-auto-tritanopia`, `forgejo-light-tritanopia`, `forgejo-dark-tritanopia`}, Themes: []string{`forgejo-auto`, `forgejo-light`, `forgejo-dark`, `gitea-auto`, `gitea-light`, `gitea-dark`, `forgejo-auto-deuteranopia-protanopia`, `forgejo-light-deuteranopia-protanopia`, `forgejo-dark-deuteranopia-protanopia`, `forgejo-auto-tritanopia`, `forgejo-light-tritanopia`, `forgejo-dark-tritanopia`},
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`, `forgejo`}, CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`, `forgejo`},
CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:", "forgejo": ":forgejo:"},
ExploreDefaultSort: "recentupdate", ExploreDefaultSort: "recentupdate",
PreferredTimestampTense: "mixed", PreferredTimestampTense: "mixed",
@ -163,8 +162,6 @@ func loadUIFrom(rootCfg ConfigProvider) {
for _, reaction := range UI.Reactions { for _, reaction := range UI.Reactions {
UI.ReactionsLookup.Add(reaction) UI.ReactionsLookup.Add(reaction)
} }
UI.CustomEmojisMap = make(map[string]string) UI.CustomEmojisLookup = make(container.Set[string])
for _, emoji := range UI.CustomEmojis { UI.CustomEmojisLookup.AddMultiple(UI.CustomEmojis...)
UI.CustomEmojisMap[emoji] = ":" + emoji + ":"
}
} }

View file

@ -147,8 +147,8 @@ func NewFuncMap() template.FuncMap {
"AllowedReactions": func() []string { "AllowedReactions": func() []string {
return setting.UI.Reactions return setting.UI.Reactions
}, },
"CustomEmojis": func() map[string]string { "CustomEmojis": func() []string {
return setting.UI.CustomEmojisMap return setting.UI.CustomEmojis
}, },
"MetaAuthor": func() string { "MetaAuthor": func() string {
return setting.UI.Meta.Author return setting.UI.Meta.Author

View file

@ -13,7 +13,7 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
assetVersionEncoded: encodeURIComponent('{{AssetVersion}}'), // will be used in URL construction directly assetVersionEncoded: encodeURIComponent('{{AssetVersion}}'), // will be used in URL construction directly
assetUrlPrefix: '{{AssetUrlPrefix}}', assetUrlPrefix: '{{AssetUrlPrefix}}',
runModeIsProd: {{.RunModeIsProd}}, runModeIsProd: {{.RunModeIsProd}},
customEmojis: {{CustomEmojis}}, customEmojis: new Set({{CustomEmojis}}),
csrfToken: '{{.CsrfToken}}', csrfToken: '{{.CsrfToken}}',
pageData: {{.PageData}}, pageData: {{.PageData}},
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}} notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}

View file

@ -203,3 +203,36 @@ test('Pull quote reply', async ({page}, workerInfo) => {
await editorTextarea.fill(''); await editorTextarea.fill('');
}); });
test('Emoji suggestions', async ({page}) => {
const response = await page.goto('/user2/repo1/issues/1');
expect(response?.status()).toBe(200);
const textarea = page.locator('#comment-form textarea[name=content]');
await textarea.focus();
await textarea.pressSequentially(':');
const suggestionList = page.locator('#comment-form .suggestions');
await expect(suggestionList).toBeVisible();
const expectedSuggestions = [
{emoji: '👍', name: '+1'},
{emoji: '👎', name: '-1'},
{emoji: '💯', name: '100'},
{emoji: '🔢', name: '1234'},
{emoji: '🥇', name: '1st_place_medal'},
{emoji: '🥈', name: '2nd_place_medal'},
];
for (const {emoji, name} of expectedSuggestions) {
const item = suggestionList.locator(`li:has-text("${name}")`);
await expect(item).toContainText(`${emoji} ${name}`);
}
await textarea.pressSequentially('forge');
await expect(suggestionList).toBeVisible();
const item = suggestionList.locator(`li:has-text("forgejo")`);
await expect(item.locator('img')).toHaveAttribute('src', '/assets/img/emoji/forgejo.png');
});

View file

@ -0,0 +1,20 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"net/http"
"testing"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
)
func TestWindowConfig(t *testing.T) {
defer tests.PrepareTestEnv(t)()
resp := MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
assert.Contains(t, resp.Body.String(), `customEmojis: new Set(["git","gitea","codeberg","gitlab","github","gogs","forgejo"]),`)
}

27
web_src/js/emoji.test.js Normal file
View file

@ -0,0 +1,27 @@
import {emojiString, emojiHTML} from './features/emoji.js';
test('emojiString', () => {
expect(emojiString('+1')).toEqual('👍');
expect(emojiString('arrow_right')).toEqual('➡️');
expect(emojiString('european_union')).toEqual('🇪🇺');
expect(emojiString('eu')).toEqual('🇪🇺');
expect(emojiString('forgejo')).toEqual(':forgejo:');
expect(emojiString('frogejo')).toEqual(':frogejo:');
expect(emojiString('blobnom')).toEqual(':blobnom:');
expect(emojiString('not-a-emoji')).toEqual(':not-a-emoji:');
});
test('emojiHTML', () => {
expect(emojiHTML('+1')).toEqual('<span class="emoji" title=":+1:">👍</span>');
expect(emojiHTML('arrow_right')).toEqual('<span class="emoji" title=":arrow_right:">➡️</span>');
expect(emojiHTML('european_union')).toEqual('<span class="emoji" title=":european_union:">🇪🇺</span>');
expect(emojiHTML('eu')).toEqual('<span class="emoji" title=":eu:">🇪🇺</span>');
expect(emojiHTML('forgejo')).toEqual('<span class="emoji" title=":forgejo:"><img alt=":forgejo:" src="/assets/img/emoji/forgejo.png"></span>');
expect(emojiHTML('frogejo')).toEqual('<span class="emoji" title=":frogejo:"><img alt=":frogejo:" src="/assets/img/emoji/frogejo.png"></span>');
expect(emojiHTML('blobnom')).toEqual('<span class="emoji" title=":blobnom:"><img alt=":blobnom:" src="/assets/img/emoji/blobnom.png"></span>');
expect(emojiHTML('not-a-emoji')).toEqual('<span class="emoji" title=":not-a-emoji:">:not-a-emoji:</span>');
});

View file

@ -1,5 +1,6 @@
import {matchEmoji, matchMention} from '../../utils/match.js'; import {matchEmoji, matchMention} from '../../utils/match.js';
import {emojiString} from '../emoji.js'; import {emojiHTML, emojiString} from '../emoji.js';
const {customEmojis} = window.config;
export function initTextExpander(expander) { export function initTextExpander(expander) {
expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
@ -10,11 +11,16 @@ export function initTextExpander(expander) {
const ul = document.createElement('ul'); const ul = document.createElement('ul');
ul.classList.add('suggestions'); ul.classList.add('suggestions');
for (const name of matches) { for (const name of matches) {
const emoji = emojiString(name);
const li = document.createElement('li'); const li = document.createElement('li');
li.setAttribute('role', 'option'); li.setAttribute('role', 'option');
li.setAttribute('data-value', emoji); li.setAttribute('data-value', emojiString(name));
li.textContent = `${emoji} ${name}`; if (customEmojis.has(name)) {
li.style.gap = '0.25rem';
li.innerHTML = emojiHTML(name);
li.append(name);
} else {
li.textContent = `${emojiString(name)} ${name}`;
}
ul.append(li); ul.append(li);
} }

View file

@ -2,7 +2,7 @@ import emojis from '../../../assets/emoji.json';
const {assetUrlPrefix, customEmojis} = window.config; const {assetUrlPrefix, customEmojis} = window.config;
const tempMap = {...customEmojis}; const tempMap = Object.assign(...Array.from(customEmojis, (v) => ({[v]: `:${v}:`})));
for (const {emoji, aliases} of emojis) { for (const {emoji, aliases} of emojis) {
for (const alias of aliases || []) { for (const alias of aliases || []) {
tempMap[alias] = emoji; tempMap[alias] = emoji;
@ -10,6 +10,7 @@ for (const {emoji, aliases} of emojis) {
} }
export const emojiKeys = Object.keys(tempMap).sort((a, b) => { export const emojiKeys = Object.keys(tempMap).sort((a, b) => {
if (b === '+1' && a === '-1') return 1;
if (a === '+1' || a === '-1') return -1; if (a === '+1' || a === '-1') return -1;
if (b === '+1' || b === '-1') return 1; if (b === '+1' || b === '-1') return 1;
return a.localeCompare(b); return a.localeCompare(b);
@ -23,7 +24,7 @@ for (const key of emojiKeys) {
// retrieve HTML for given emoji name // retrieve HTML for given emoji name
export function emojiHTML(name) { export function emojiHTML(name) {
let inner; let inner;
if (Object.hasOwn(customEmojis, name)) { if (customEmojis.has(name)) {
inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`; inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
} else { } else {
inner = emojiString(name); inner = emojiString(name);

View file

@ -1,4 +1,4 @@
import emojis from '../../../assets/emoji.json'; import {emojiKeys} from '../features/emoji.js';
const maxMatches = 6; const maxMatches = 6;
@ -9,19 +9,14 @@ function sortAndReduce(map) {
export function matchEmoji(queryText) { export function matchEmoji(queryText) {
const query = queryText.toLowerCase().replaceAll('_', ' '); const query = queryText.toLowerCase().replaceAll('_', ' ');
if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]); if (!query) return emojiKeys.slice(0, maxMatches);
// results is a map of weights, lower is better // results is a map of weights, lower is better
const results = new Map(); const results = new Map();
for (const {aliases} of emojis) { for (const emojiKey of emojiKeys) {
const mainAlias = aliases[0]; const index = emojiKey.replaceAll('_', ' ').indexOf(query);
for (const [aliasIndex, alias] of aliases.entries()) { if (index === -1) continue;
const index = alias.replaceAll('_', ' ').indexOf(query); results.set(emojiKey, index);
if (index === -1) continue;
const existing = results.get(mainAlias);
const rankedIndex = index + aliasIndex;
results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex);
}
} }
return sortAndReduce(results); return sortAndReduce(results);

View file

@ -30,7 +30,7 @@ test('matchEmoji', () => {
expect(matchEmoji('poo')).toEqual([ expect(matchEmoji('poo')).toEqual([
'poodle', 'poodle',
'hankey', 'poop',
'spoon', 'spoon',
'bowl_with_spoon', 'bowl_with_spoon',
]); ]);
@ -42,6 +42,19 @@ test('matchEmoji', () => {
expect(matchEmoji('jellyfis')).toEqual([ expect(matchEmoji('jellyfis')).toEqual([
'jellyfish', 'jellyfish',
]); ]);
expect(matchEmoji('forge')).toEqual([
'forgejo',
]);
expect(matchEmoji('frog')).toEqual([
'frog',
'frogejo',
]);
expect(matchEmoji('blob')).toEqual([
'blobnom',
]);
}); });
test('matchMention', () => { test('matchMention', () => {

View file

@ -8,8 +8,9 @@ window.config = {
csrfToken: 'test-csrf-token-123456', csrfToken: 'test-csrf-token-123456',
pageData: {}, pageData: {},
i18n: {}, i18n: {},
customEmojis: {}, customEmojis: new Set(['forgejo', 'frogejo', 'blobnom']),
appSubUrl: '', appSubUrl: '',
assetUrlPrefix: '/assets',
mentionValues: [ mentionValues: [
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'}, {key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'}, {key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},