mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-27 00:23:41 +09:00 
			
		
		
		
	Avoid emoji mismatch and allow to only enable chosen emojis (#35692)
Fix #23635
This commit is contained in:
		| @@ -1343,6 +1343,10 @@ LEVEL = Info | |||||||
| ;; Dont mistake it for Reactions. | ;; Dont mistake it for Reactions. | ||||||
| ;CUSTOM_EMOJIS = gitea, codeberg, gitlab, git, github, gogs | ;CUSTOM_EMOJIS = gitea, codeberg, gitlab, git, github, gogs | ||||||
| ;; | ;; | ||||||
|  | ;; Comma separated list of enabled emojis, for example: smile, thumbsup, thumbsdown | ||||||
|  | ;; Leave it empty to enable all emojis. | ||||||
|  | ;ENABLED_EMOJIS = | ||||||
|  | ;; | ||||||
| ;; Whether the full name of the users should be shown where possible. If the full name isn't set, the username will be used. | ;; Whether the full name of the users should be shown where possible. If the full name isn't set, the username will be used. | ||||||
| ;DEFAULT_SHOW_FULL_NAME = false | ;DEFAULT_SHOW_FULL_NAME = false | ||||||
| ;; | ;; | ||||||
|   | |||||||
| @@ -8,7 +8,9 @@ import ( | |||||||
| 	"io" | 	"io" | ||||||
| 	"sort" | 	"sort" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"sync" | 	"sync/atomic" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Gemoji is a set of emoji data. | // Gemoji is a set of emoji data. | ||||||
| @@ -23,74 +25,78 @@ type Emoji struct { | |||||||
| 	SkinTones      bool | 	SkinTones      bool | ||||||
| } | } | ||||||
|  |  | ||||||
| var ( | type globalVarsStruct struct { | ||||||
| 	// codeMap provides a map of the emoji unicode code to its emoji data. | 	codeMap       map[string]int    // emoji unicode code to its emoji data. | ||||||
| 	codeMap map[string]int | 	aliasMap      map[string]int    // the alias to its emoji data. | ||||||
|  | 	emptyReplacer *strings.Replacer // string replacer for emoji codes, used for finding emoji positions. | ||||||
|  | 	codeReplacer  *strings.Replacer // string replacer for emoji codes. | ||||||
|  | 	aliasReplacer *strings.Replacer // string replacer for emoji aliases. | ||||||
|  | } | ||||||
|  |  | ||||||
| 	// aliasMap provides a map of the alias to its emoji data. | var globalVarsStore atomic.Pointer[globalVarsStruct] | ||||||
| 	aliasMap map[string]int |  | ||||||
|  |  | ||||||
| 	// emptyReplacer is the string replacer for emoji codes. | func globalVars() *globalVarsStruct { | ||||||
| 	emptyReplacer *strings.Replacer | 	vars := globalVarsStore.Load() | ||||||
|  | 	if vars != nil { | ||||||
|  | 		return vars | ||||||
|  | 	} | ||||||
|  | 	// although there can be concurrent calls, the result should be the same, and there is no performance problem | ||||||
|  | 	vars = &globalVarsStruct{} | ||||||
|  | 	vars.codeMap = make(map[string]int, len(GemojiData)) | ||||||
|  | 	vars.aliasMap = make(map[string]int, len(GemojiData)) | ||||||
|  |  | ||||||
| 	// codeReplacer is the string replacer for emoji codes. | 	// process emoji codes and aliases | ||||||
| 	codeReplacer *strings.Replacer | 	codePairs := make([]string, 0) | ||||||
|  | 	emptyPairs := make([]string, 0) | ||||||
|  | 	aliasPairs := make([]string, 0) | ||||||
|  |  | ||||||
| 	// aliasReplacer is the string replacer for emoji aliases. | 	// sort from largest to small so we match combined emoji first | ||||||
| 	aliasReplacer *strings.Replacer | 	sort.Slice(GemojiData, func(i, j int) bool { | ||||||
|  | 		return len(GemojiData[i].Emoji) > len(GemojiData[j].Emoji) | ||||||
|  | 	}) | ||||||
|  |  | ||||||
| 	once sync.Once | 	for idx, emoji := range GemojiData { | ||||||
| ) | 		if emoji.Emoji == "" || len(emoji.Aliases) == 0 { | ||||||
|  | 			continue | ||||||
| func loadMap() { |  | ||||||
| 	once.Do(func() { |  | ||||||
| 		// initialize |  | ||||||
| 		codeMap = make(map[string]int, len(GemojiData)) |  | ||||||
| 		aliasMap = make(map[string]int, len(GemojiData)) |  | ||||||
|  |  | ||||||
| 		// process emoji codes and aliases |  | ||||||
| 		codePairs := make([]string, 0) |  | ||||||
| 		emptyPairs := make([]string, 0) |  | ||||||
| 		aliasPairs := make([]string, 0) |  | ||||||
|  |  | ||||||
| 		// sort from largest to small so we match combined emoji first |  | ||||||
| 		sort.Slice(GemojiData, func(i, j int) bool { |  | ||||||
| 			return len(GemojiData[i].Emoji) > len(GemojiData[j].Emoji) |  | ||||||
| 		}) |  | ||||||
|  |  | ||||||
| 		for i, e := range GemojiData { |  | ||||||
| 			if e.Emoji == "" || len(e.Aliases) == 0 { |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			// setup codes |  | ||||||
| 			codeMap[e.Emoji] = i |  | ||||||
| 			codePairs = append(codePairs, e.Emoji, ":"+e.Aliases[0]+":") |  | ||||||
| 			emptyPairs = append(emptyPairs, e.Emoji, e.Emoji) |  | ||||||
|  |  | ||||||
| 			// setup aliases |  | ||||||
| 			for _, a := range e.Aliases { |  | ||||||
| 				if a == "" { |  | ||||||
| 					continue |  | ||||||
| 				} |  | ||||||
|  |  | ||||||
| 				aliasMap[a] = i |  | ||||||
| 				aliasPairs = append(aliasPairs, ":"+a+":", e.Emoji) |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		// create replacers | 		// process aliases | ||||||
| 		emptyReplacer = strings.NewReplacer(emptyPairs...) | 		firstAlias := "" | ||||||
| 		codeReplacer = strings.NewReplacer(codePairs...) | 		for _, alias := range emoji.Aliases { | ||||||
| 		aliasReplacer = strings.NewReplacer(aliasPairs...) | 			if alias == "" { | ||||||
| 	}) | 				continue | ||||||
|  | 			} | ||||||
|  | 			enabled := len(setting.UI.EnabledEmojisSet) == 0 || setting.UI.EnabledEmojisSet.Contains(alias) | ||||||
|  | 			if !enabled { | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 			if firstAlias == "" { | ||||||
|  | 				firstAlias = alias | ||||||
|  | 			} | ||||||
|  | 			vars.aliasMap[alias] = idx | ||||||
|  | 			aliasPairs = append(aliasPairs, ":"+alias+":", emoji.Emoji) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// process emoji code | ||||||
|  | 		if firstAlias != "" { | ||||||
|  | 			vars.codeMap[emoji.Emoji] = idx | ||||||
|  | 			codePairs = append(codePairs, emoji.Emoji, ":"+emoji.Aliases[0]+":") | ||||||
|  | 			emptyPairs = append(emptyPairs, emoji.Emoji, emoji.Emoji) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// create replacers | ||||||
|  | 	vars.emptyReplacer = strings.NewReplacer(emptyPairs...) | ||||||
|  | 	vars.codeReplacer = strings.NewReplacer(codePairs...) | ||||||
|  | 	vars.aliasReplacer = strings.NewReplacer(aliasPairs...) | ||||||
|  | 	globalVarsStore.Store(vars) | ||||||
|  | 	return vars | ||||||
| } | } | ||||||
|  |  | ||||||
| // FromCode retrieves the emoji data based on the provided unicode code (ie, | // FromCode retrieves the emoji data based on the provided unicode code (ie, | ||||||
| // "\u2618" will return the Gemoji data for "shamrock"). | // "\u2618" will return the Gemoji data for "shamrock"). | ||||||
| func FromCode(code string) *Emoji { | func FromCode(code string) *Emoji { | ||||||
| 	loadMap() | 	i, ok := globalVars().codeMap[code] | ||||||
| 	i, ok := codeMap[code] |  | ||||||
| 	if !ok { | 	if !ok { | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
| @@ -102,12 +108,11 @@ func FromCode(code string) *Emoji { | |||||||
| // "alias" or ":alias:" (ie, "shamrock" or ":shamrock:" will return the Gemoji | // "alias" or ":alias:" (ie, "shamrock" or ":shamrock:" will return the Gemoji | ||||||
| // data for "shamrock"). | // data for "shamrock"). | ||||||
| func FromAlias(alias string) *Emoji { | func FromAlias(alias string) *Emoji { | ||||||
| 	loadMap() |  | ||||||
| 	if strings.HasPrefix(alias, ":") && strings.HasSuffix(alias, ":") { | 	if strings.HasPrefix(alias, ":") && strings.HasSuffix(alias, ":") { | ||||||
| 		alias = alias[1 : len(alias)-1] | 		alias = alias[1 : len(alias)-1] | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	i, ok := aliasMap[alias] | 	i, ok := globalVars().aliasMap[alias] | ||||||
| 	if !ok { | 	if !ok { | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
| @@ -119,15 +124,13 @@ func FromAlias(alias string) *Emoji { | |||||||
| // alias (in the form of ":alias:") (ie, "\u2618" will be converted to | // alias (in the form of ":alias:") (ie, "\u2618" will be converted to | ||||||
| // ":shamrock:"). | // ":shamrock:"). | ||||||
| func ReplaceCodes(s string) string { | func ReplaceCodes(s string) string { | ||||||
| 	loadMap() | 	return globalVars().codeReplacer.Replace(s) | ||||||
| 	return codeReplacer.Replace(s) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ReplaceAliases replaces all aliases of the form ":alias:" with its | // ReplaceAliases replaces all aliases of the form ":alias:" with its | ||||||
| // corresponding unicode value. | // corresponding unicode value. | ||||||
| func ReplaceAliases(s string) string { | func ReplaceAliases(s string) string { | ||||||
| 	loadMap() | 	return globalVars().aliasReplacer.Replace(s) | ||||||
| 	return aliasReplacer.Replace(s) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| type rememberSecondWriteWriter struct { | type rememberSecondWriteWriter struct { | ||||||
| @@ -163,7 +166,6 @@ func (n *rememberSecondWriteWriter) WriteString(s string) (int, error) { | |||||||
|  |  | ||||||
| // FindEmojiSubmatchIndex returns index pair of longest emoji in a string | // FindEmojiSubmatchIndex returns index pair of longest emoji in a string | ||||||
| func FindEmojiSubmatchIndex(s string) []int { | func FindEmojiSubmatchIndex(s string) []int { | ||||||
| 	loadMap() |  | ||||||
| 	secondWriteWriter := rememberSecondWriteWriter{} | 	secondWriteWriter := rememberSecondWriteWriter{} | ||||||
|  |  | ||||||
| 	// A faster and clean implementation would copy the trie tree formation in strings.NewReplacer but | 	// A faster and clean implementation would copy the trie tree formation in strings.NewReplacer but | ||||||
| @@ -175,7 +177,7 @@ func FindEmojiSubmatchIndex(s string) []int { | |||||||
| 	// Therefore we can simply take the index of the second write as our first emoji | 	// Therefore we can simply take the index of the second write as our first emoji | ||||||
| 	// | 	// | ||||||
| 	// FIXME: just copy the trie implementation from strings.NewReplacer | 	// FIXME: just copy the trie implementation from strings.NewReplacer | ||||||
| 	_, _ = emptyReplacer.WriteString(&secondWriteWriter, s) | 	_, _ = globalVars().emptyReplacer.WriteString(&secondWriteWriter, s) | ||||||
|  |  | ||||||
| 	// if we wrote less than twice then we never "replaced" | 	// if we wrote less than twice then we never "replaced" | ||||||
| 	if secondWriteWriter.writecount < 2 { | 	if secondWriteWriter.writecount < 2 { | ||||||
|   | |||||||
| @@ -7,14 +7,13 @@ package emoji | |||||||
| import ( | import ( | ||||||
| 	"testing" | 	"testing" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/modules/container" | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
|  | 	"code.gitea.io/gitea/modules/test" | ||||||
|  |  | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func TestDumpInfo(t *testing.T) { |  | ||||||
| 	t.Logf("codes: %d", len(codeMap)) |  | ||||||
| 	t.Logf("aliases: %d", len(aliasMap)) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestLookup(t *testing.T) { | func TestLookup(t *testing.T) { | ||||||
| 	a := FromCode("\U0001f37a") | 	a := FromCode("\U0001f37a") | ||||||
| 	b := FromCode("🍺") | 	b := FromCode("🍺") | ||||||
| @@ -24,7 +23,6 @@ func TestLookup(t *testing.T) { | |||||||
| 	assert.Equal(t, a, b) | 	assert.Equal(t, a, b) | ||||||
| 	assert.Equal(t, b, c) | 	assert.Equal(t, b, c) | ||||||
| 	assert.Equal(t, c, d) | 	assert.Equal(t, c, d) | ||||||
| 	assert.Equal(t, a, d) |  | ||||||
|  |  | ||||||
| 	m := FromCode("\U0001f44d") | 	m := FromCode("\U0001f44d") | ||||||
| 	n := FromAlias(":thumbsup:") | 	n := FromAlias(":thumbsup:") | ||||||
| @@ -32,7 +30,20 @@ func TestLookup(t *testing.T) { | |||||||
|  |  | ||||||
| 	assert.Equal(t, m, n) | 	assert.Equal(t, m, n) | ||||||
| 	assert.Equal(t, m, o) | 	assert.Equal(t, m, o) | ||||||
| 	assert.Equal(t, n, o) |  | ||||||
|  | 	defer test.MockVariableValue(&setting.UI.EnabledEmojisSet, container.SetOf("thumbsup"))() | ||||||
|  | 	defer globalVarsStore.Store(nil) | ||||||
|  | 	globalVarsStore.Store(nil) | ||||||
|  | 	a = FromCode("\U0001f37a") | ||||||
|  | 	c = FromAlias(":beer:") | ||||||
|  | 	m = FromCode("\U0001f44d") | ||||||
|  | 	n = FromAlias(":thumbsup:") | ||||||
|  | 	o = FromAlias("+1") | ||||||
|  | 	assert.Nil(t, a) | ||||||
|  | 	assert.Nil(t, c) | ||||||
|  | 	assert.NotNil(t, m) | ||||||
|  | 	assert.NotNil(t, n) | ||||||
|  | 	assert.Nil(t, o) | ||||||
| } | } | ||||||
|  |  | ||||||
| func TestReplacers(t *testing.T) { | func TestReplacers(t *testing.T) { | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ package markup | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"strings" | 	"strings" | ||||||
|  | 	"unicode" | ||||||
|  |  | ||||||
| 	"code.gitea.io/gitea/modules/emoji" | 	"code.gitea.io/gitea/modules/emoji" | ||||||
| 	"code.gitea.io/gitea/modules/setting" | 	"code.gitea.io/gitea/modules/setting" | ||||||
| @@ -66,26 +67,31 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { | |||||||
| 		} | 		} | ||||||
| 		m[0] += start | 		m[0] += start | ||||||
| 		m[1] += start | 		m[1] += start | ||||||
|  |  | ||||||
| 		start = m[1] | 		start = m[1] | ||||||
|  |  | ||||||
| 		alias := node.Data[m[0]:m[1]] | 		alias := node.Data[m[0]:m[1]] | ||||||
| 		alias = strings.ReplaceAll(alias, ":", "") |  | ||||||
| 		converted := emoji.FromAlias(alias) | 		var nextChar byte | ||||||
| 		if converted == nil { | 		if m[1] < len(node.Data) { | ||||||
| 			// check if this is a custom reaction | 			nextChar = node.Data[m[1]] | ||||||
| 			if _, exist := setting.UI.CustomEmojisMap[alias]; exist { | 		} | ||||||
| 				replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias)) | 		if nextChar == ':' || unicode.IsLetter(rune(nextChar)) || unicode.IsDigit(rune(nextChar)) { | ||||||
| 				node = node.NextSibling.NextSibling |  | ||||||
| 				start = 0 |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description)) | 		alias = strings.Trim(alias, ":") | ||||||
| 		node = node.NextSibling.NextSibling | 		converted := emoji.FromAlias(alias) | ||||||
| 		start = 0 | 		if converted != nil { | ||||||
|  | 			// standard emoji | ||||||
|  | 			replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description)) | ||||||
|  | 			node = node.NextSibling.NextSibling | ||||||
|  | 			start = 0 // restart searching start since node has changed | ||||||
|  | 		} else if _, exist := setting.UI.CustomEmojisMap[alias]; exist { | ||||||
|  | 			// custom reaction | ||||||
|  | 			replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias)) | ||||||
|  | 			node = node.NextSibling.NextSibling | ||||||
|  | 			start = 0 // restart searching start since node has changed | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -357,12 +357,9 @@ func TestRender_emoji(t *testing.T) { | |||||||
| 		`<p><span class="emoji" aria-label="smiling face with sunglasses">😎</span><span class="emoji" aria-label="zany face">🤪</span><span class="emoji" aria-label="locked with key">🔐</span><span class="emoji" aria-label="money-mouth face">🤑</span><span class="emoji" aria-label="red question mark">❓</span></p>`) | 		`<p><span class="emoji" aria-label="smiling face with sunglasses">😎</span><span class="emoji" aria-label="zany face">🤪</span><span class="emoji" aria-label="locked with key">🔐</span><span class="emoji" aria-label="money-mouth face">🤑</span><span class="emoji" aria-label="red question mark">❓</span></p>`) | ||||||
|  |  | ||||||
| 	// should match nothing | 	// should match nothing | ||||||
| 	test( | 	test(":100:200", `<p>:100:200</p>`) | ||||||
| 		"2001:0db8:85a3:0000:0000:8a2e:0370:7334", | 	test("std::thread::something", `<p>std::thread::something</p>`) | ||||||
| 		`<p>2001:0db8:85a3:0000:0000:8a2e:0370:7334</p>`) | 	test(":not exist:", `<p>:not exist:</p>`) | ||||||
| 	test( |  | ||||||
| 		":not exist:", |  | ||||||
| 		`<p>:not exist:</p>`) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| func TestRender_ShortLinks(t *testing.T) { | func TestRender_ShortLinks(t *testing.T) { | ||||||
|   | |||||||
| @@ -33,6 +33,8 @@ var UI = struct { | |||||||
| 	ReactionsLookup         container.Set[string] `ini:"-"` | 	ReactionsLookup         container.Set[string] `ini:"-"` | ||||||
| 	CustomEmojis            []string | 	CustomEmojis            []string | ||||||
| 	CustomEmojisMap         map[string]string `ini:"-"` | 	CustomEmojisMap         map[string]string `ini:"-"` | ||||||
|  | 	EnabledEmojis           []string | ||||||
|  | 	EnabledEmojisSet        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"` | ||||||
| @@ -169,4 +171,5 @@ func loadUIFrom(rootCfg ConfigProvider) { | |||||||
| 	for _, emoji := range UI.CustomEmojis { | 	for _, emoji := range UI.CustomEmojis { | ||||||
| 		UI.CustomEmojisMap[emoji] = ":" + emoji + ":" | 		UI.CustomEmojisMap[emoji] = ":" + emoji + ":" | ||||||
| 	} | 	} | ||||||
|  | 	UI.EnabledEmojisSet = container.SetOf(UI.EnabledEmojis...) | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user