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