mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-09-13 06:17:26 +00:00
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:
commit
0a8d7826a4
13 changed files with 123 additions and 30 deletions
|
@ -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
|
||||||
|
|
|
@ -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>`)
|
||||||
|
|
|
@ -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 + ":"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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*/}}
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
20
tests/integration/window_config_test.go
Normal file
20
tests/integration/window_config_test.go
Normal 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
27
web_src/js/emoji.test.js
Normal 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>');
|
||||||
|
});
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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()) {
|
|
||||||
const index = alias.replaceAll('_', ' ').indexOf(query);
|
|
||||||
if (index === -1) continue;
|
if (index === -1) continue;
|
||||||
const existing = results.get(mainAlias);
|
results.set(emojiKey, index);
|
||||||
const rankedIndex = index + aliasIndex;
|
|
||||||
results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortAndReduce(results);
|
return sortAndReduce(results);
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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'},
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue