feat: add release reactions and a GitHub-like reaction bar (#38415)

Adds emoji reactions to releases and moves the reaction picker of
issues, comments and releases into a GitHub-like reaction bar.

- Releases omit 👎 or 😕
- Drafts and tags can't be reacted to
- Moved menu to tippy
- Added keyboard navigation

Fixes: https://github.com/go-gitea/gitea/issues/34268
Fixes: https://github.com/go-gitea/gitea/issues/16676
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 15:04:10 +02:00
1 parent e645afbb3f
commit edd0c0e2ce
42 files changed
+508 -313

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ func GetIssueCommentReactions(ctx *context.APIContext) {
return
}
reactions, _, err := issues_model.FindCommentReactions(ctx, comment.IssueID, comment.ID)
reactions, _, err := issues_model.FindCommentReactions(ctx, comment.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
-1
View File
@@ -669,7 +669,6 @@ func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Contex
}
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypePullRequests)
+10 -14
View File
@@ -25,6 +25,7 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
@@ -45,7 +46,7 @@ const (
tplIssueView templates.TplName = "repo/issue/view"
tplPullMergeBox templates.TplName = "repo/issue/view_content/pull_merge_box"
tplReactions templates.TplName = "repo/issue/view_content/reactions"
tplReactions templates.TplName = "shared/reactions"
issueTemplateKey = "IssueTemplate"
issueTemplateTitleKey = "IssueTemplateTitle"
@@ -536,25 +537,20 @@ func ChangeIssueReaction(ctx *context.Context) {
return
}
if len(issue.Reactions) == 0 {
ctx.JSON(http.StatusOK, map[string]any{
"empty": true,
"html": "",
})
return
}
renderReactions(ctx, fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index), issue.Reactions, setting.UI.Reactions)
}
func renderReactions(ctx *context.Context, actionURL string, reactions issues_model.ReactionList, allowedReactions []string) {
html, err := ctx.RenderToHTML(tplReactions, map[string]any{
"ActionURL": fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index),
"Reactions": issue.Reactions.GroupByType(),
"ActionURL": actionURL,
"Reactions": reactions.GroupByType(),
"AllowedReactions": allowedReactions,
})
if err != nil {
ctx.ServerError("ChangeIssueReaction.HTMLString", err)
ctx.ServerError("RenderToHTML", err)
return
}
ctx.JSON(http.StatusOK, map[string]any{
"html": html,
})
ctx.JSON(http.StatusOK, map[string]any{"html": html})
}
// GetIssueAttachments returns attachments for the issue
+1 -19
View File
@@ -300,25 +300,7 @@ func ChangeCommentReaction(ctx *context.Context) {
return
}
if len(comment.Reactions) == 0 {
ctx.JSON(http.StatusOK, map[string]any{
"empty": true,
"html": "",
})
return
}
html, err := ctx.RenderToHTML(tplReactions, map[string]any{
"ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID),
"Reactions": comment.Reactions.GroupByType(),
})
if err != nil {
ctx.ServerError("ChangeCommentReaction.HTMLString", err)
return
}
ctx.JSON(http.StatusOK, map[string]any{
"html": html,
})
renderReactions(ctx, fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), comment.Reactions, setting.UI.Reactions)
}
// GetCommentAttachments returns attachments for the comment
-1
View File
@@ -111,7 +111,6 @@ func NewIssue(ctx *context.Context) {
isProjectsEnabled := ctx.Repo.Permission.CanRead(unit.TypeProjects)
ctx.Data["IsProjectsEnabled"] = isProjectsEnabled
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
pageMetaData := retrieveRepoIssueMetaDataWithPrefill(ctx, ctx.Repo.Repository, false)
+1 -1
View File
@@ -362,7 +362,7 @@ func ViewIssue(ctx *context.Context) {
}
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanRead(unit.TypeProjects)
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
ctx.Data["AllowedIssueReactions"] = setting.UI.Reactions
upload.AddUploadContext(ctx, "comment")
if err := issue.LoadAttributes(ctx); err != nil {
+2 -1
View File
@@ -908,12 +908,13 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
ctx.Data["IsIssuePoster"] = ctx.Doer != nil && issue.IsPoster(ctx.Doer.ID)
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
// For files changed page
PrepareBranchList(ctx)
if ctx.Written() {
return
}
ctx.Data["AllowedIssueReactions"] = setting.UI.Reactions
upload.AddUploadContext(ctx, "comment")
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
+1 -2
View File
@@ -55,7 +55,6 @@ func RenderNewCodeCommentForm(ctx *context.Context) {
return
}
ctx.Data["AfterCommitID"] = pullHeadCommitID
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
ctx.HTML(http.StatusOK, tplNewComment)
}
@@ -186,7 +185,7 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori
return
}
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
ctx.Data["AllowedIssueReactions"] = setting.UI.Reactions
upload.AddUploadContext(ctx, "comment")
ctx.Data["comments"] = comments
+85 -13
View File
@@ -14,6 +14,7 @@ import (
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
@@ -73,6 +74,7 @@ type ReleaseInfo struct {
Release *repo_model.Release
CommitStatus *git_model.CommitStatus
CommitStatuses []*git_model.CommitStatus
Reactions issues_model.ReactionList
}
func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions) ([]*ReleaseInfo, error) {
@@ -81,17 +83,31 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
return nil, err
}
releaseIDs := make([]int64, 0, len(releases))
for _, release := range releases {
release.Repo = ctx.Repo.Repository
releaseIDs = append(releaseIDs, release.ID)
}
if err = repo_model.GetReleaseAttachments(ctx, releases...); err != nil {
return nil, err
}
reactions, err := issues_model.FindReleaseReactions(ctx, releaseIDs...)
if err != nil {
return nil, err
}
cacheUsers, err := reactions.LoadUsers(ctx, ctx.Repo.Repository)
if err != nil {
return nil, err
}
reactionsByRelease := make(map[int64]issues_model.ReactionList)
for _, reaction := range reactions {
reactionsByRelease[reaction.ReleaseID] = append(reactionsByRelease[reaction.ReleaseID], reaction)
}
// Temporary cache commits count of used branches to speed up.
countCache := make(map[string]int64)
cacheUsers := make(map[int64]*user_model.User)
if ctx.Doer != nil {
cacheUsers[ctx.Doer.ID] = ctx.Doer
}
@@ -138,7 +154,8 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
}
info := &ReleaseInfo{
Release: r,
Release: r,
Reactions: reactionsByRelease[r.ID],
}
if canReadCode {
@@ -154,10 +171,22 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
return releaseInfos, nil
}
// Releases render releases list page
func Releases(ctx *context.Context) {
func prepareReleasePageCommon(ctx *context.Context) (ret struct {
writeAccess bool
},
) {
ctx.Data["PageIsReleaseList"] = true
ctx.Data["Title"] = ctx.Tr("repo.release.releases")
ctx.Data["AllowedReleaseReactions"] = release_service.AllowedReleaseReactions()
ret.writeAccess = ctx.Repo.Permission.CanWrite(unit.TypeReleases)
ctx.Data["CanCreateRelease"] = ret.writeAccess && !ctx.Repo.Repository.IsArchived
return ret
}
// Releases render releases list page
func Releases(ctx *context.Context) {
preparedData := prepareReleasePageCommon(ctx)
listOptions := db.ListOptions{
Page: ctx.FormInt("page"),
@@ -170,13 +199,10 @@ func Releases(ctx *context.Context) {
listOptions.PageSize = setting.API.MaxResponseItems
}
writeAccess := ctx.Repo.Permission.CanWrite(unit.TypeReleases)
ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived
releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{
ListOptions: listOptions,
// only show draft releases for users who can write, read-only users shouldn't see draft releases.
IncludeDrafts: writeAccess,
IncludeDrafts: preparedData.writeAccess,
RepoID: ctx.Repo.Repository.ID,
})
if err != nil {
@@ -275,17 +301,14 @@ func releasesOrTagsFeed(ctx *context.Context, isReleasesOnly bool, formatType st
// SingleRelease renders a single release's page
func SingleRelease(ctx *context.Context) {
ctx.Data["PageIsReleaseList"] = true
writeAccess := ctx.Repo.Permission.CanWrite(unit.TypeReleases)
ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived
preparedData := prepareReleasePageCommon(ctx)
releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{
ListOptions: db.ListOptions{Page: 1, PageSize: 1},
RepoID: ctx.Repo.Repository.ID,
TagNames: []string{ctx.PathParam("*")},
// only show draft releases for users who can write, read-only users shouldn't see draft releases.
IncludeDrafts: writeAccess,
IncludeDrafts: preparedData.writeAccess,
IncludeTags: true,
})
if err != nil {
@@ -681,3 +704,52 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) {
redirect()
}
func ChangeReleaseReaction(ctx *context.Context) {
form := context.GetFetchActionForm[*forms.ReactionForm](ctx)
if form == nil {
return
}
rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
if err != nil {
ctx.ServerError("GetReleaseForRepoByID", err)
return
}
if rel.IsTag || rel.IsDraft {
ctx.NotFound(nil)
return
}
rel.Repo = ctx.Repo.Repository
switch ctx.PathParam("action") {
case "react":
_, err = release_service.CreateReleaseReaction(ctx, ctx.Doer, rel, form.Content)
if errors.Is(err, util.ErrPermissionDenied) {
ctx.HTTPError(http.StatusForbidden)
return
} else if err != nil && !errors.Is(err, util.ErrAlreadyExist) {
ctx.ServerError("CreateReleaseReaction", err)
return
}
case "unreact":
if err = issues_model.DeleteReleaseReaction(ctx, ctx.Doer.ID, rel.ID, form.Content); err != nil {
ctx.ServerError("DeleteReleaseReaction", err)
return
}
default:
ctx.NotFound(nil)
return
}
reactions, err := issues_model.FindReleaseReactions(ctx, rel.ID)
if err != nil {
ctx.ServerError("FindReleaseReactions", err)
return
}
if _, err = reactions.LoadUsers(ctx, ctx.Repo.Repository); err != nil {
ctx.ServerError("LoadUsers", err)
return
}
renderReactions(ctx, fmt.Sprintf("%s/releases/%d/reactions", ctx.Repo.RepoLink, rel.ID), reactions, release_service.AllowedReleaseReactions())
}
+3 -2
View File
@@ -224,13 +224,13 @@ func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.C
}
if options.SignInRequired && !ctx.IsSigned {
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
ctx.RedirectForLogin(middleware.RedirectLinkUserLogin(ctx.Req))
return
}
// Redirect to log in page if auto-signin info is provided and has not signed in.
if !options.SignOutRequired && !ctx.IsSigned && ctx.GetSiteCookie(setting.CookieRememberName) != "" {
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
ctx.RedirectForLogin(middleware.RedirectLinkUserLogin(ctx.Req))
return
}
@@ -1503,6 +1503,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, ctxDataSet(reqctx.ContextData{"EnableFeed": setting.Other.EnableFeed}))
m.Get("/releases/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
m.Get("/releases/download/{vTag}/{fileName}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.RedirectDownload)
m.Post("/releases/{id}/reactions/{action}", reqSignIn, context.RepoMustNotBeArchived(), repo.ChangeReleaseReaction)
m.Group("/releases", func() {
m.Get("/new", repo.NewRelease)
m.Post("/new", web.Bind[*forms.NewReleaseForm](), repo.NewReleasePost)