diff --git a/modules/markup/html.go b/modules/markup/html.go
index 7961c5c930..aba287e0fd 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -1142,7 +1142,7 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
converted := emoji.FromAlias(alias)
if converted == nil {
// 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))
node = node.NextSibling.NextSibling
start = 0
diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go
index 6bc0f7e56c..22c7e4ca5d 100644
--- a/modules/markup/html_test.go
+++ b/modules/markup/html_test.go
@@ -358,7 +358,7 @@ func TestRender_emoji(t *testing.T) {
test(
":custom-emoji:",
`
:custom-emoji:
`)
- setting.UI.CustomEmojisMap["custom-emoji"] = ":custom-emoji:"
+ setting.UI.CustomEmojisLookup.Add("custom-emoji")
test(
":custom-emoji:",
`
`)
diff --git a/modules/setting/ui.go b/modules/setting/ui.go
index 2e6a3df4c6..9dafe350eb 100644
--- a/modules/setting/ui.go
+++ b/modules/setting/ui.go
@@ -31,7 +31,7 @@ var UI = struct {
Reactions []string
ReactionsLookup container.Set[string] `ini:"-"`
CustomEmojis []string
- CustomEmojisMap map[string]string `ini:"-"`
+ CustomEmojisLookup container.Set[string] `ini:"-"`
SearchRepoDescription bool
OnlyShowRelevantRepos bool
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`},
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
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",
PreferredTimestampTense: "mixed",
@@ -163,8 +162,6 @@ func loadUIFrom(rootCfg ConfigProvider) {
for _, reaction := range UI.Reactions {
UI.ReactionsLookup.Add(reaction)
}
- UI.CustomEmojisMap = make(map[string]string)
- for _, emoji := range UI.CustomEmojis {
- UI.CustomEmojisMap[emoji] = ":" + emoji + ":"
- }
+ UI.CustomEmojisLookup = make(container.Set[string])
+ UI.CustomEmojisLookup.AddMultiple(UI.CustomEmojis...)
}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index 2e33e51e60..848d4b4ad4 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -147,8 +147,8 @@ func NewFuncMap() template.FuncMap {
"AllowedReactions": func() []string {
return setting.UI.Reactions
},
- "CustomEmojis": func() map[string]string {
- return setting.UI.CustomEmojisMap
+ "CustomEmojis": func() []string {
+ return setting.UI.CustomEmojis
},
"MetaAuthor": func() string {
return setting.UI.Meta.Author
diff --git a/templates/base/head_script.tmpl b/templates/base/head_script.tmpl
index d2774010b6..4bc274a73f 100644
--- a/templates/base/head_script.tmpl
+++ b/templates/base/head_script.tmpl
@@ -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
assetUrlPrefix: '{{AssetUrlPrefix}}',
runModeIsProd: {{.RunModeIsProd}},
- customEmojis: {{CustomEmojis}},
+ customEmojis: new Set({{CustomEmojis}}),
csrfToken: '{{.CsrfToken}}',
pageData: {{.PageData}},
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
diff --git a/tests/e2e/issue-comment.test.e2e.ts b/tests/e2e/issue-comment.test.e2e.ts
index 2017e4563e..bc2bc3d691 100644
--- a/tests/e2e/issue-comment.test.e2e.ts
+++ b/tests/e2e/issue-comment.test.e2e.ts
@@ -203,3 +203,36 @@ test('Pull quote reply', async ({page}, workerInfo) => {
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');
+});
diff --git a/tests/integration/window_config_test.go b/tests/integration/window_config_test.go
new file mode 100644
index 0000000000..03fc2af52f
--- /dev/null
+++ b/tests/integration/window_config_test.go
@@ -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"]),`)
+}
diff --git a/web_src/js/emoji.test.js b/web_src/js/emoji.test.js
new file mode 100644
index 0000000000..55e4787793
--- /dev/null
+++ b/web_src/js/emoji.test.js
@@ -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('👍');
+ expect(emojiHTML('arrow_right')).toEqual('➡️');
+ expect(emojiHTML('european_union')).toEqual('🇪🇺');
+ expect(emojiHTML('eu')).toEqual('🇪🇺');
+
+ expect(emojiHTML('forgejo')).toEqual('
');
+ expect(emojiHTML('frogejo')).toEqual('
');
+ expect(emojiHTML('blobnom')).toEqual('
');
+
+ expect(emojiHTML('not-a-emoji')).toEqual(':not-a-emoji:');
+});
diff --git a/web_src/js/features/comp/TextExpander.js b/web_src/js/features/comp/TextExpander.js
index 128a2ddff0..8777f3a334 100644
--- a/web_src/js/features/comp/TextExpander.js
+++ b/web_src/js/features/comp/TextExpander.js
@@ -1,5 +1,6 @@
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) {
expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
@@ -10,11 +11,16 @@ export function initTextExpander(expander) {
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const name of matches) {
- const emoji = emojiString(name);
const li = document.createElement('li');
li.setAttribute('role', 'option');
- li.setAttribute('data-value', emoji);
- li.textContent = `${emoji} ${name}`;
+ li.setAttribute('data-value', emojiString(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);
}
diff --git a/web_src/js/features/emoji.js b/web_src/js/features/emoji.js
index 032a3efe8a..c7a23ecf0f 100644
--- a/web_src/js/features/emoji.js
+++ b/web_src/js/features/emoji.js
@@ -2,7 +2,7 @@ import emojis from '../../../assets/emoji.json';
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 alias of aliases || []) {
tempMap[alias] = emoji;
@@ -10,6 +10,7 @@ for (const {emoji, aliases} of emojis) {
}
export const emojiKeys = Object.keys(tempMap).sort((a, b) => {
+ if (b === '+1' && a === '-1') return 1;
if (a === '+1' || a === '-1') return -1;
if (b === '+1' || b === '-1') return 1;
return a.localeCompare(b);
@@ -23,7 +24,7 @@ for (const key of emojiKeys) {
// retrieve HTML for given emoji name
export function emojiHTML(name) {
let inner;
- if (Object.hasOwn(customEmojis, name)) {
+ if (customEmojis.has(name)) {
inner = `
`;
} else {
inner = emojiString(name);
diff --git a/web_src/js/utils/match.js b/web_src/js/utils/match.js
index 17fdfed113..aa53ad1435 100644
--- a/web_src/js/utils/match.js
+++ b/web_src/js/utils/match.js
@@ -1,4 +1,4 @@
-import emojis from '../../../assets/emoji.json';
+import {emojiKeys} from '../features/emoji.js';
const maxMatches = 6;
@@ -9,19 +9,14 @@ function sortAndReduce(map) {
export function matchEmoji(queryText) {
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
const results = new Map();
- for (const {aliases} of emojis) {
- const mainAlias = aliases[0];
- for (const [aliasIndex, alias] of aliases.entries()) {
- const index = alias.replaceAll('_', ' ').indexOf(query);
- if (index === -1) continue;
- const existing = results.get(mainAlias);
- const rankedIndex = index + aliasIndex;
- results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex);
- }
+ for (const emojiKey of emojiKeys) {
+ const index = emojiKey.replaceAll('_', ' ').indexOf(query);
+ if (index === -1) continue;
+ results.set(emojiKey, index);
}
return sortAndReduce(results);
diff --git a/web_src/js/utils/match.test.js b/web_src/js/utils/match.test.js
index 1e30b451d4..4910af1d3c 100644
--- a/web_src/js/utils/match.test.js
+++ b/web_src/js/utils/match.test.js
@@ -30,7 +30,7 @@ test('matchEmoji', () => {
expect(matchEmoji('poo')).toEqual([
'poodle',
- 'hankey',
+ 'poop',
'spoon',
'bowl_with_spoon',
]);
@@ -42,6 +42,19 @@ test('matchEmoji', () => {
expect(matchEmoji('jellyfis')).toEqual([
'jellyfish',
]);
+
+ expect(matchEmoji('forge')).toEqual([
+ 'forgejo',
+ ]);
+
+ expect(matchEmoji('frog')).toEqual([
+ 'frog',
+ 'frogejo',
+ ]);
+
+ expect(matchEmoji('blob')).toEqual([
+ 'blobnom',
+ ]);
});
test('matchMention', () => {
diff --git a/web_src/js/vitest.setup.js b/web_src/js/vitest.setup.js
index a99b852097..c2b61d7ae9 100644
--- a/web_src/js/vitest.setup.js
+++ b/web_src/js/vitest.setup.js
@@ -8,8 +8,9 @@ window.config = {
csrfToken: 'test-csrf-token-123456',
pageData: {},
i18n: {},
- customEmojis: {},
+ customEmojis: new Set(['forgejo', 'frogejo', 'blobnom']),
appSubUrl: '',
+ assetUrlPrefix: '/assets',
mentionValues: [
{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'},