From 4a74113dee37ac3de2df27cef5c15e800b4f649d Mon Sep 17 00:00:00 2001
From: Bram Hagens <bram@bramh.me>
Date: Wed, 17 Jul 2024 01:37:20 +0200
Subject: [PATCH] feat(ui): add more emoji and code block rendering in issues

---
 modules/markup/html.go                        |  49 ++++++
 modules/templates/helper.go                   |  11 +-
 modules/templates/util_render.go              |  11 ++
 modules/templates/util_render_test.go         |  40 ++++-
 templates/repo/issue/card.tmpl                |   2 +-
 .../repo/issue/view_content/comments.tmpl     |  14 +-
 .../view_content/sidebar/dependencies.tmpl    |  13 +-
 templates/repo/issue/view_title.tmpl          |   3 +-
 templates/repo/pulse.tmpl                     |  12 +-
 .../user/notification/notification_div.tmpl   |   2 +-
 tests/integration/repo_issue_title_test.go    | 162 ++++++++++++++++++
 vitest.config.js                              |   4 +
 web_src/js/features/repo-issue.js             |   9 +-
 web_src/js/features/repo-issue.test.js        |  24 +++
 14 files changed, 322 insertions(+), 34 deletions(-)
 create mode 100644 tests/integration/repo_issue_title_test.go
 create mode 100644 web_src/js/features/repo-issue.test.js

diff --git a/modules/markup/html.go b/modules/markup/html.go
index 9280a67d62..2e65827bf7 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -73,6 +73,8 @@ var (
 
 	// EmojiShortCodeRegex find emoji by alias like :smile:
 	EmojiShortCodeRegex = regexp.MustCompile(`:[-+\w]+:`)
+
+	InlineCodeBlockRegex = regexp.MustCompile("`[^`]+`")
 )
 
 // CSS class for action keywords (e.g. "closes: #1")
@@ -243,6 +245,7 @@ func RenderIssueTitle(
 	title string,
 ) (string, error) {
 	return renderProcessString(ctx, []processor{
+		inlineCodeBlockProcessor,
 		issueIndexPatternProcessor,
 		commitCrossReferencePatternProcessor,
 		hashCurrentPatternProcessor,
@@ -251,6 +254,19 @@ func RenderIssueTitle(
 	}, title)
 }
 
+// RenderRefIssueTitle to process title on places where an issue is referenced
+func RenderRefIssueTitle(
+	ctx *RenderContext,
+	title string,
+) (string, error) {
+	return renderProcessString(ctx, []processor{
+		inlineCodeBlockProcessor,
+		issueIndexPatternProcessor,
+		emojiShortCodeProcessor,
+		emojiProcessor,
+	}, title)
+}
+
 func renderProcessString(ctx *RenderContext, procs []processor, content string) (string, error) {
 	var buf strings.Builder
 	if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil {
@@ -438,6 +454,24 @@ func createKeyword(content string) *html.Node {
 	return span
 }
 
+func createInlineCode(content string) *html.Node {
+	code := &html.Node{
+		Type: html.ElementNode,
+		Data: atom.Code.String(),
+		Attr: []html.Attribute{},
+	}
+
+	code.Attr = append(code.Attr, html.Attribute{Key: "class", Val: "inline-code-block"})
+
+	text := &html.Node{
+		Type: html.TextNode,
+		Data: content,
+	}
+
+	code.AppendChild(text)
+	return code
+}
+
 func createEmoji(content, class, name string) *html.Node {
 	span := &html.Node{
 		Type: html.ElementNode,
@@ -1070,6 +1104,21 @@ func filePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
 	}
 }
 
+func inlineCodeBlockProcessor(ctx *RenderContext, node *html.Node) {
+	start := 0
+	next := node.NextSibling
+	for node != nil && node != next && start < len(node.Data) {
+		m := InlineCodeBlockRegex.FindStringSubmatchIndex(node.Data[start:])
+		if m == nil {
+			return
+		}
+
+		code := node.Data[m[0]+1 : m[1]-1]
+		replaceContent(node, m[0], m[1], createInlineCode(code))
+		node = node.NextSibling.NextSibling
+	}
+}
+
 // emojiShortCodeProcessor for rendering text like :smile: into emoji
 func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
 	start := 0
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index f1ae1c8bb1..aeae8204ad 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -172,11 +172,12 @@ func NewFuncMap() template.FuncMap {
 		"RenderCommitMessage":            RenderCommitMessage,
 		"RenderCommitMessageLinkSubject": RenderCommitMessageLinkSubject,
 
-		"RenderCommitBody": RenderCommitBody,
-		"RenderCodeBlock":  RenderCodeBlock,
-		"RenderIssueTitle": RenderIssueTitle,
-		"RenderEmoji":      RenderEmoji,
-		"ReactionToEmoji":  ReactionToEmoji,
+		"RenderCommitBody":    RenderCommitBody,
+		"RenderCodeBlock":     RenderCodeBlock,
+		"RenderIssueTitle":    RenderIssueTitle,
+		"RenderRefIssueTitle": RenderRefIssueTitle,
+		"RenderEmoji":         RenderEmoji,
+		"ReactionToEmoji":     ReactionToEmoji,
 
 		"RenderMarkdownToHtml": RenderMarkdownToHtml,
 		"RenderLabel":          RenderLabel,
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index 76790b63d5..c53bdd876f 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -130,6 +130,17 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
 	return template.HTML(renderedText)
 }
 
+// RenderRefIssueTitle renders referenced issue/pull title with defined post processors
+func RenderRefIssueTitle(ctx context.Context, text string) template.HTML {
+	renderedText, err := markup.RenderRefIssueTitle(&markup.RenderContext{Ctx: ctx}, template.HTMLEscapeString(text))
+	if err != nil {
+		log.Error("RenderRefIssueTitle: %v", err)
+		return ""
+	}
+
+	return template.HTML(renderedText)
+}
+
 // RenderLabel renders a label
 // locale is needed due to an import cycle with our context providing the `Tr` function
 func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go
index ea01612ac3..da74298ef7 100644
--- a/modules/templates/util_render_test.go
+++ b/modules/templates/util_render_test.go
@@ -35,8 +35,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
 mail@domain.com
 @mention-user test
 #123
-  space  
-`
+  space
+` + "`code :+1: #123 code`\n"
 
 var testMetas = map[string]string{
 	"user":     "user13",
@@ -115,8 +115,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
 <a href="mailto:mail@domain.com" class="mailto">mail@domain.com</a>
 <a href="/mention-user" class="mention">@mention-user</a> test
 <a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
-  space`
-
+  space
+` + "`code <span class=\"emoji\" aria-label=\"thumbs up\">👍</span> <a href=\"/user13/repo11/issues/123\" class=\"ref-issue\">#123</a> code`"
 	assert.EqualValues(t, expected, RenderCommitBody(context.Background(), testInput, testMetas))
 }
 
@@ -152,11 +152,38 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
 mail@domain.com
 @mention-user test
 <a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
-  space  
+  space
+<code class="inline-code-block">code :+1: #123 code</code>
 `
 	assert.EqualValues(t, expected, RenderIssueTitle(context.Background(), testInput, testMetas))
 }
 
+func TestRenderRefIssueTitle(t *testing.T) {
+	expected := `  space @mention-user  
+/just/a/path.bin
+https://example.com/file.bin
+[local link](file.bin)
+[remote link](https://example.com)
+[[local link|file.bin]]
+[[remote link|https://example.com]]
+![local image](image.jpg)
+![remote image](https://example.com/image.jpg)
+[[local image|image.jpg]]
+[[remote link|https://example.com/image.jpg]]
+https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
+https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb
+com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
+<span class="emoji" aria-label="thumbs up">👍</span>
+mail@domain.com
+@mention-user test
+#123
+  space
+<code class="inline-code-block">code :+1: #123 code</code>
+`
+	assert.EqualValues(t, expected, RenderRefIssueTitle(context.Background(), testInput))
+}
+
 func TestRenderMarkdownToHtml(t *testing.T) {
 	expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
 /just/a/path.bin
@@ -177,7 +204,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
 <a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
 <a href="/mention-user" rel="nofollow">@mention-user</a> test
 #123
-space</p>
+space
+<code>code :+1: #123 code</code></p>
 `
 	assert.EqualValues(t, expected, RenderMarkdownToHtml(context.Background(), testInput))
 }
diff --git a/templates/repo/issue/card.tmpl b/templates/repo/issue/card.tmpl
index 4c22c28329..0b255d6705 100644
--- a/templates/repo/issue/card.tmpl
+++ b/templates/repo/issue/card.tmpl
@@ -14,7 +14,7 @@
 			<div class="issue-card-icon">
 				{{template "shared/issueicon" .}}
 			</div>
-			<a class="issue-card-title muted issue-title tw-break-anywhere" href="{{.Link}}">{{.Title | RenderEmoji ctx | RenderCodeBlock}}</a>
+			<a class="issue-card-title muted issue-title tw-break-anywhere" href="{{.Link}}">{{RenderRefIssueTitle $.Context .Title}}</a>
 			{{if and $.isPinnedIssueCard $.Page.IsRepoAdmin}}
 				<a role="button" class="issue-card-unpin muted tw-flex tw-items-center" data-tooltip-content={{ctx.Locale.Tr "repo.issues.unpin_issue"}} data-issue-id="{{.ID}}" data-unpin-url="{{$.Page.Link}}/unpin/{{.Index}}">
 					{{svg "octicon-x" 16}}
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index 019638bfb0..08c83c07d7 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -149,7 +149,7 @@
 				{{if eq .RefAction 3}}</del>{{end}}
 
 				<div class="detail flex-text-block">
-					<span class="text grey muted-links"><a href="{{.RefIssueLink ctx}}"><b>{{.RefIssueTitle ctx}}</b> {{.RefIssueIdent ctx}}</a></span>
+					<span class="text grey muted-links"><a href="{{.RefIssueLink ctx}}"><b>{{.RefIssueTitle ctx | RenderEmoji $.Context | RenderCodeBlock}}</b> {{.RefIssueIdent ctx}}</a></span>
 				</div>
 			</div>
 		{{else if eq .Type 4}}
@@ -226,7 +226,7 @@
 				{{template "shared/user/avatarlink" dict "user" .Poster}}
 				<span class="text grey muted-links">
 					{{template "shared/user/authorlink" .Poster}}
-					{{ctx.Locale.Tr "repo.issues.change_title_at" (.OldTitle|RenderEmoji $.Context) (.NewTitle|RenderEmoji $.Context) $createdStr}}
+					{{ctx.Locale.Tr "repo.issues.change_title_at" (RenderRefIssueTitle $.Context .OldTitle) (RenderRefIssueTitle $.Context .NewTitle) $createdStr}}
 				</span>
 			</div>
 		{{else if eq .Type 11}}
@@ -339,10 +339,11 @@
 						{{svg "octicon-plus"}}
 						<span class="text grey muted-links">
 							<a href="{{.DependentIssue.Link}}">
+								{{$strTitle := RenderRefIssueTitle $.Context .DependentIssue.Title}}
 								{{if eq .DependentIssue.RepoID .Issue.RepoID}}
-									#{{.DependentIssue.Index}} {{.DependentIssue.Title}}
+									#{{.DependentIssue.Index}} {{$strTitle}}
 								{{else}}
-									{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{.DependentIssue.Title}}
+									{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{$strTitle}}
 								{{end}}
 							</a>
 						</span>
@@ -362,10 +363,11 @@
 						{{svg "octicon-trash"}}
 						<span class="text grey muted-links">
 							<a href="{{.DependentIssue.Link}}">
+								{{$strTitle := RenderRefIssueTitle $.Context .DependentIssue.Title}}
 								{{if eq .DependentIssue.RepoID .Issue.RepoID}}
-									#{{.DependentIssue.Index}} {{.DependentIssue.Title}}
+									#{{.DependentIssue.Index}} {{$strTitle}}
 								{{else}}
-									{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{.DependentIssue.Title}}
+									{{.DependentIssue.Repo.FullName}}#{{.DependentIssue.Index}} - {{$strTitle}}
 								{{end}}
 							</a>
 						</span>
diff --git a/templates/repo/issue/view_content/sidebar/dependencies.tmpl b/templates/repo/issue/view_content/sidebar/dependencies.tmpl
index 791bd5c4a1..6a9b651e7d 100644
--- a/templates/repo/issue/view_content/sidebar/dependencies.tmpl
+++ b/templates/repo/issue/view_content/sidebar/dependencies.tmpl
@@ -19,8 +19,8 @@
 			{{range .BlockingDependencies}}
 				<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
 					<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
-						<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}">
-							#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}
+						<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}}">
+							#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}}
 						</a>
 						<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
 							{{.Repository.OwnerName}}/{{.Repository.Name}}
@@ -51,8 +51,9 @@
 			{{range .BlockedByDependencies}}
 				<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
 					<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
-						<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}">
-							#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}
+						{{$title := RenderRefIssueTitle $.Context .Issue.Title}}
+						<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}">
+							#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .Issue.Title}}
 						</a>
 						<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
 							{{.Repository.OwnerName}}/{{.Repository.Name}}
@@ -73,8 +74,8 @@
 						<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
 							<div class="gt-ellipsis">
 								<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dependency.no_permission.can_remove"}}">{{svg "octicon-lock" 16}}</span>
-								<span class="title" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}">
-									#{{.Issue.Index}} {{.Issue.Title | RenderEmoji $.Context}}
+								<span class="title" data-tooltip-content="#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .DependentIssue.Title}}">
+									#{{.Issue.Index}} {{RenderRefIssueTitle $.Context .DependentIssue.Title}}
 								</span>
 							</div>
 							<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
diff --git a/templates/repo/issue/view_title.tmpl b/templates/repo/issue/view_title.tmpl
index db21adae2c..f60d958a1c 100644
--- a/templates/repo/issue/view_title.tmpl
+++ b/templates/repo/issue/view_title.tmpl
@@ -7,8 +7,7 @@
 	{{$canEditIssueTitle := and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .Repository.IsArchived)}}
 	<div class="issue-title" id="issue-title-display">
 		<h1 class="tw-break-anywhere">
-			{{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx) | RenderCodeBlock}}
-			<span class="index">#{{.Issue.Index}}</span>
+			{{RenderIssueTitle $.Context .Issue.Title ($.Repository.ComposeMetas ctx)}}				<span class="index">#{{.Issue.Index}}</span>
 		</h1>
 		<div class="button-row">
 			{{if $canEditIssueTitle}}
diff --git a/templates/repo/pulse.tmpl b/templates/repo/pulse.tmpl
index 0494883a85..3554cf6a19 100644
--- a/templates/repo/pulse.tmpl
+++ b/templates/repo/pulse.tmpl
@@ -153,7 +153,7 @@
 		{{range .Activity.MergedPRs}}
 			<p class="desc">
 				<span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span>
-				#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{RenderRefIssueTitle $.Context .Issue.Title}}</a>
 				{{TimeSinceUnix .MergedUnix ctx.Locale}}
 			</p>
 		{{end}}
@@ -172,7 +172,7 @@
 		{{range .Activity.OpenedPRs}}
 			<p class="desc">
 				<span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span>
-				#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{RenderRefIssueTitle $.Context .Issue.Title}}</a>
 				{{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}}
 			</p>
 		{{end}}
@@ -191,7 +191,7 @@
 		{{range .Activity.ClosedIssues}}
 			<p class="desc">
 				<span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span>
-				#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
 				{{TimeSinceUnix .ClosedUnix ctx.Locale}}
 			</p>
 		{{end}}
@@ -210,7 +210,7 @@
 		{{range .Activity.OpenedIssues}}
 			<p class="desc">
 				<span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span>
-				#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
 				{{TimeSinceUnix .CreatedUnix ctx.Locale}}
 			</p>
 		{{end}}
@@ -228,9 +228,9 @@
 				<span class="ui green label">{{ctx.Locale.Tr "repo.activity.unresolved_conv_label"}}</span>
 				#{{.Index}}
 				{{if .IsPull}}
-				<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				<a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
 				{{else}}
-				<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
+				<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{RenderRefIssueTitle $.Context .Title}}</a>
 				{{end}}
 				{{TimeSinceUnix .UpdatedUnix ctx.Locale}}
 			</p>
diff --git a/templates/user/notification/notification_div.tmpl b/templates/user/notification/notification_div.tmpl
index 4329ffdb7a..5c27ba8b41 100644
--- a/templates/user/notification/notification_div.tmpl
+++ b/templates/user/notification/notification_div.tmpl
@@ -58,7 +58,7 @@
 								<div class="notifications-bottom-row tw-text-16 tw-py-0.5">
 									<span class="issue-title tw-break-anywhere">
 										{{if .Issue}}
-											{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}
+											{{RenderRefIssueTitle $.Context .Issue.Title}}
 										{{else}}
 											{{.Repository.FullName}}
 										{{end}}
diff --git a/tests/integration/repo_issue_title_test.go b/tests/integration/repo_issue_title_test.go
new file mode 100644
index 0000000000..5199be91bc
--- /dev/null
+++ b/tests/integration/repo_issue_title_test.go
@@ -0,0 +1,162 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	"code.gitea.io/gitea/models/db"
+	issues_model "code.gitea.io/gitea/models/issues"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/git"
+	issue_service "code.gitea.io/gitea/services/issue"
+	pull_service "code.gitea.io/gitea/services/pull"
+	files_service "code.gitea.io/gitea/services/repository/files"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestIssueTitles(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+		repo, _, f := tests.CreateDeclarativeRepo(t, user, "issue-titles", nil, nil, nil)
+		defer f()
+
+		session := loginUser(t, user.LoginName)
+
+		title := "Title :+1: `code`"
+		issue1 := createIssue(t, user, repo, title, "Test issue")
+		issue2 := createIssue(t, user, repo, title, "Ref #1")
+
+		titleHTML := []string{
+			"Title",
+			`<span class="emoji" aria-label="thumbs up">👍</span>`,
+			`<code class="inline-code-block">code</code>`,
+		}
+
+		t.Run("Main issue title", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			html := extractHTML(t, session, issue1, "div.issue-title-header > * > h1")
+			assertContainsAll(t, titleHTML, html)
+		})
+
+		t.Run("Referenced issue comment", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			html := extractHTML(t, session, issue1, "div.timeline > div.timeline-item:nth-child(3) > div.detail > * > a")
+			assertContainsAll(t, titleHTML, html)
+		})
+
+		t.Run("Dependent issue comment", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			err := issues_model.CreateIssueDependency(db.DefaultContext, user, issue1, issue2)
+			require.NoError(t, err)
+
+			html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(3) > div.detail > * > a")
+			assertContainsAll(t, titleHTML, html)
+		})
+
+		t.Run("Dependent issue sidebar", func(t *testing.T) {
+			defer tests.PrintCurrentTest(t)()
+
+			html := extractHTML(t, session, issue1, "div.item.dependency > * > a.title")
+			assertContainsAll(t, titleHTML, html)
+		})
+
+		t.Run("Referenced pull comment", func(t *testing.T) {
+			_, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{
+				Files: []*files_service.ChangeRepoFile{
+					{
+						Operation:     "update",
+						TreePath:      "README.md",
+						ContentReader: strings.NewReader("Update README"),
+					},
+				},
+				Message:   "Update README",
+				OldBranch: "main",
+				NewBranch: "branch",
+				Author: &files_service.IdentityOptions{
+					Name:  user.Name,
+					Email: user.Email,
+				},
+				Committer: &files_service.IdentityOptions{
+					Name:  user.Name,
+					Email: user.Email,
+				},
+				Dates: &files_service.CommitDateOptions{
+					Author:    time.Now(),
+					Committer: time.Now(),
+				},
+			})
+
+			require.NoError(t, err)
+
+			pullIssue := &issues_model.Issue{
+				RepoID:   repo.ID,
+				Title:    title,
+				Content:  "Closes #1",
+				PosterID: user.ID,
+				Poster:   user,
+				IsPull:   true,
+			}
+
+			pullRequest := &issues_model.PullRequest{
+				HeadRepoID: repo.ID,
+				BaseRepoID: repo.ID,
+				HeadBranch: "branch",
+				BaseBranch: "main",
+				HeadRepo:   repo,
+				BaseRepo:   repo,
+				Type:       issues_model.PullRequestGitea,
+			}
+
+			err = pull_service.NewPullRequest(git.DefaultContext, repo, pullIssue, nil, nil, pullRequest, nil)
+			require.NoError(t, err)
+
+			html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(4) > div.detail > * > a")
+			assertContainsAll(t, titleHTML, html)
+		})
+	})
+}
+
+func createIssue(t *testing.T, user *user_model.User, repo *repo_model.Repository, title, content string) *issues_model.Issue {
+	issue := &issues_model.Issue{
+		RepoID:   repo.ID,
+		Title:    title,
+		Content:  content,
+		PosterID: user.ID,
+		Poster:   user,
+	}
+
+	err := issue_service.NewIssue(db.DefaultContext, repo, issue, nil, nil, nil)
+	require.NoError(t, err)
+
+	return issue
+}
+
+func extractHTML(t *testing.T, session *TestSession, issue *issues_model.Issue, query string) string {
+	req := NewRequest(t, "GET", issue.HTMLURL())
+	resp := session.MakeRequest(t, req, http.StatusOK)
+	doc := NewHTMLParser(t, resp.Body)
+	res, err := doc.doc.Find(query).Html()
+	require.NoError(t, err)
+
+	return res
+}
+
+func assertContainsAll(t *testing.T, expected []string, actual string) {
+	for i := range expected {
+		assert.Contains(t, actual, expected[i])
+	}
+}
diff --git a/vitest.config.js b/vitest.config.js
index ea0fafeee8..776247c5ee 100644
--- a/vitest.config.js
+++ b/vitest.config.js
@@ -1,6 +1,7 @@
 import {defineConfig} from 'vitest/config';
 import vuePlugin from '@vitejs/plugin-vue';
 import {stringPlugin} from 'vite-string-plugin';
+import {resolve} from 'node:path';
 
 export default defineConfig({
   test: {
@@ -13,6 +14,9 @@ export default defineConfig({
     passWithNoTests: true,
     globals: true,
     watch: false,
+    alias: {
+      'monaco-editor': resolve(import.meta.dirname, '/node_modules/monaco-editor/esm/vs/editor/editor.api'),
+    },
   },
   plugins: [
     stringPlugin(),
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index ff8faa94f6..feba449c16 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -8,6 +8,7 @@ import {toAbsoluteUrl} from '../utils.js';
 import {initDropzone} from './common-global.js';
 import {POST, GET} from '../modules/fetch.js';
 import {showErrorToast} from '../modules/toast.js';
+import {emojiHTML} from './emoji.js';
 
 const {appSubUrl} = window.config;
 
@@ -124,7 +125,7 @@ export function initRepoIssueSidebarList() {
               return;
             }
             filteredResponse.results.push({
-              name: `#${issue.number} ${htmlEscape(issue.title)
+              name: `#${issue.number} ${issueTitleHTML(htmlEscape(issue.title))
               }<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
               value: issue.id,
             });
@@ -731,3 +732,9 @@ export function initArchivedLabelHandler() {
     toggleElem(label, label.classList.contains('checked'));
   }
 }
+
+// Render the issue's title. It converts emojis and code blocks syntax into their respective HTML equivalent.
+export function issueTitleHTML(title) {
+  return title.replaceAll(/:[-+\w]+:/g, (emoji) => emojiHTML(emoji.substring(1, emoji.length - 1)))
+    .replaceAll(/`[^`]+`/g, (code) => `<code class="inline-code-block">${code.substring(1, code.length - 1)}</code>`);
+}
diff --git a/web_src/js/features/repo-issue.test.js b/web_src/js/features/repo-issue.test.js
new file mode 100644
index 0000000000..8c9734b0c6
--- /dev/null
+++ b/web_src/js/features/repo-issue.test.js
@@ -0,0 +1,24 @@
+import {vi} from 'vitest';
+
+import {issueTitleHTML} from './repo-issue.js';
+
+// monaco-editor does not have any exports fields, which trips up vitest
+vi.mock('./comp/ComboMarkdownEditor.js', () => ({}));
+// jQuery is missing
+vi.mock('./common-global.js', () => ({}));
+
+test('Convert issue title to html', () => {
+  expect(issueTitleHTML('')).toEqual('');
+  expect(issueTitleHTML('issue title')).toEqual('issue title');
+
+  const expected_thumbs_up = `<span class="emoji" title=":+1:">👍</span>`;
+  expect(issueTitleHTML(':+1:')).toEqual(expected_thumbs_up);
+  expect(issueTitleHTML(':invalid emoji:')).toEqual(':invalid emoji:');
+
+  const expected_code_block = `<code class="inline-code-block">code</code>`;
+  expect(issueTitleHTML('`code`')).toEqual(expected_code_block);
+  expect(issueTitleHTML('`invalid code')).toEqual('`invalid code');
+  expect(issueTitleHTML('invalid code`')).toEqual('invalid code`');
+
+  expect(issueTitleHTML('issue title :+1: `code`')).toEqual(`issue title ${expected_thumbs_up} ${expected_code_block}`);
+});