mirror of
				https://codeberg.org/forgejo/forgejo.git
				synced 2025-10-30 22:11:07 +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) | ||||
| 		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 | ||||
|  |  | |||
|  | @ -358,7 +358,7 @@ func TestRender_emoji(t *testing.T) { | |||
| 	test( | ||||
| 		":custom-emoji:", | ||||
| 		`<p>:custom-emoji:</p>`) | ||||
| 	setting.UI.CustomEmojisMap["custom-emoji"] = ":custom-emoji:" | ||||
| 	setting.UI.CustomEmojisLookup.Add("custom-emoji") | ||||
| 	test( | ||||
| 		":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>`) | ||||
|  |  | |||
|  | @ -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...) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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*/}} | ||||
|  |  | |||
|  | @ -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'); | ||||
| }); | ||||
|  |  | |||
							
								
								
									
										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 {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); | ||||
|       } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -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', () => { | ||||
|  |  | |||
|  | @ -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'}, | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue