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

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 {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);
}

View file

@ -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 = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
} else {
inner = emojiString(name);

View file

@ -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);

View file

@ -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', () => {

View file

@ -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'},