From edd0c0e2cee34e5c13d563ceef83fe65c0e1d089 Mon Sep 17 00:00:00 2001 From: Shudhanshu Singh Date: Tue, 20 Oct 2026 18:34:10 +0530 Subject: [PATCH] feat: add release reactions and a GitHub-like reaction bar (#38415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: wxiaoguang --- custom/conf/app.example.ini | 5 +- modelmigration/migrations.go | 1 + modelmigration/v29/v363.go | 33 +++ models/issues/comment.go | 7 +- models/issues/issue.go | 8 +- models/issues/reaction.go | 192 ++++++++---------- modules/setting/ui.go | 10 +- modules/templates/helper.go | 3 - routers/api/v1/repo/issue_reaction.go | 2 +- routers/web/repo/compare.go | 1 - routers/web/repo/issue.go | 24 +-- routers/web/repo/issue_comment.go | 20 +- routers/web/repo/issue_new.go | 1 - routers/web/repo/issue_view.go | 2 +- routers/web/repo/pull.go | 3 +- routers/web/repo/pull_review.go | 3 +- routers/web/repo/release.go | 98 +++++++-- routers/web/web.go | 5 +- services/context/base.go | 12 +- services/context/upload/upload.go | 1 + services/issue/reaction.go | 5 +- services/issue/reaction_test.go | 9 +- services/release/reaction.go | 33 +++ services/release/release.go | 5 + services/release/release_test.go | 21 ++ services/repository/delete.go | 5 + services/repository/delete_test.go | 3 + services/user/delete.go | 2 +- templates/repo/diff/comments.tmpl | 12 +- templates/repo/issue/view_content.tmpl | 12 +- .../repo/issue/view_content/add_reaction.tmpl | 10 - .../repo/issue/view_content/comments.tmpl | 22 +- .../repo/issue/view_content/conversation.tmpl | 10 +- .../repo/issue/view_content/reactions.tmpl | 17 -- templates/repo/release/list.tmpl | 7 + templates/shared/reactions.tmpl | 28 +++ tests/e2e/reactions.test.ts | 15 +- tests/integration/release_test.go | 27 +++ web_src/css/repo/reactions.css | 90 ++++---- web_src/js/features/comp/ReactionSelector.ts | 49 +++-- web_src/js/modules/fetch-action.ts | 4 +- web_src/js/modules/tippy.ts | 4 +- 42 files changed, 508 insertions(+), 313 deletions(-) create mode 100644 modelmigration/v29/v363.go create mode 100644 services/release/reaction.go delete mode 100644 templates/repo/issue/view_content/add_reaction.tmpl delete mode 100644 templates/repo/issue/view_content/reactions.tmpl create mode 100644 templates/shared/reactions.tmpl diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index ffc45acb15e..86b63b7875d 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1434,11 +1434,14 @@ LEVEL = Info ;; The icon theme for folders (basic/material) ;FOLDER_ICON_THEME = basic ;; -;; All available reactions users can choose on issues/prs and comments. +;; All available reactions users can choose on issues, pull requests and comments. ;; Values can be emoji alias (:smile:) or a unicode emoji. ;; For custom reactions, add a tightly cropped square image to public/assets/img/emoji/reaction_name.png ;REACTIONS = +1, -1, laugh, hooray, confused, heart, rocket, eyes ;; +;; Reactions available for releases +;;RELEASE_REACTIONS = +1, laugh, hooray, heart, rocket, eyes +;; ;; Change the number of users that are displayed in reactions tooltip (triggered by mouse hover). ;REACTION_MAX_USER_NUM = 10 ;; diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 63fdbbe7330..96c2e26cf42 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -439,6 +439,7 @@ func prepareMigrationTasks() []*migration { newMigration(360, "Move branch protection settings to JSON", v29.MoveBranchProtectionConfigToJSON), newMigration(361, "Drop legacy commit_id column from comment", v29.DropCommentCommitID), newMigration(362, "Add reusable_workflow_ref column to action_run_job", v29.AddReusableWorkflowRefToActionRunJob), + newMigration(363, "Add release_id to reaction table", v29.AddReleaseIDToReaction), } return preparedMigrations } diff --git a/modelmigration/v29/v363.go b/modelmigration/v29/v363.go new file mode 100644 index 00000000000..1bc0153dadb --- /dev/null +++ b/modelmigration/v29/v363.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v29 + +import ( + "context" + + "gitea.dev/modelmigration/base" + "gitea.dev/modules/timeutil" +) + +func AddReleaseIDToReaction(_ctx context.Context, x base.EngineMigration) error { + // rows older than v123 can hold NULL, which the unique index doesn't treat as a duplicate of '' + if _, err := x.Exec("UPDATE reaction SET original_author = '' WHERE original_author IS NULL"); err != nil { + return err + } + + type Reaction struct { + ID int64 `xorm:"pk autoincr"` + + UserID int64 `xorm:"UNIQUE(s) NOT NULL"` // first unique column implies INDEX + IssueID int64 `xorm:"UNIQUE(s) INDEX(issue_comment) NOT NULL"` + CommentID int64 `xorm:"UNIQUE(s) INDEX(issue_comment) INDEX NOT NULL"` + ReleaseID int64 `xorm:"UNIQUE(s) INDEX NOT NULL DEFAULT(0)"` + OriginalAuthorID int64 `xorm:"UNIQUE(s) NOT NULL DEFAULT(0)"` + OriginalAuthor string `xorm:"UNIQUE(s) NOT NULL DEFAULT('')"` + Type string `xorm:"UNIQUE(s) NOT NULL"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + } + return x.Sync(new(Reaction)) +} diff --git a/models/issues/comment.go b/models/issues/comment.go index b747c51592c..c78bd2d8d25 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -725,10 +725,7 @@ func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository if c.Reactions != nil { return nil } - c.Reactions, _, err = FindReactions(ctx, FindReactionsOptions{ - IssueID: c.IssueID, - CommentID: c.ID, - }) + c.Reactions, _, err = FindReactions(ctx, FindReactionsOptions{CommentID: c.ID}) if err != nil { return err } @@ -1210,7 +1207,7 @@ func DeleteComment(ctx context.Context, comment *Comment) error { return err } - return DeleteReaction(ctx, &ReactionOptions{CommentID: comment.ID}) + return DeleteCommentAllReactions(ctx, comment.ID) } // UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id diff --git a/models/issues/issue.go b/models/issues/issue.go index ca76dabc3ac..0ce15737f75 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -225,13 +225,11 @@ func (issue *Issue) loadCommentsByType(ctx context.Context, tp CommentType) (err return err } -func (issue *Issue) loadReactions(ctx context.Context) (err error) { +func (issue *Issue) loadAllReactions(ctx context.Context) (err error) { if issue.Reactions != nil { return nil } - reactions, _, err := FindReactions(ctx, FindReactionsOptions{ - IssueID: issue.ID, - }) + reactions, _, err := FindReactions(ctx, FindReactionsOptions{IssueIDWithComments: issue.ID}) if err != nil { return err } @@ -340,7 +338,7 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) { } } - return issue.loadReactions(ctx) + return issue.loadAllReactions(ctx) } // IsPinned returns if a Issue is pinned diff --git a/models/issues/reaction.go b/models/issues/reaction.go index 70e0d18ba9c..7035d4d003e 100644 --- a/models/issues/reaction.go +++ b/models/issues/reaction.go @@ -58,17 +58,20 @@ func (err ErrReactionAlreadyExist) Unwrap() error { return util.ErrAlreadyExist } -// Reaction represents a reactions on issues and comments. +// Reaction represents a reaction on an issue, comment or release. type Reaction struct { - ID int64 `xorm:"pk autoincr"` - Type string `xorm:"INDEX UNIQUE(s) NOT NULL"` - IssueID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` - CommentID int64 `xorm:"INDEX UNIQUE(s)"` - UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` - OriginalAuthorID int64 `xorm:"INDEX UNIQUE(s) NOT NULL DEFAULT(0)"` - OriginalAuthor string `xorm:"INDEX UNIQUE(s)"` - User *user_model.User `xorm:"-"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + ID int64 `xorm:"pk autoincr"` + + UserID int64 `xorm:"UNIQUE(s) NOT NULL"` // first unique column implies INDEX + IssueID int64 `xorm:"UNIQUE(s) INDEX(issue_comment) NOT NULL"` + CommentID int64 `xorm:"UNIQUE(s) INDEX(issue_comment) INDEX NOT NULL"` + ReleaseID int64 `xorm:"UNIQUE(s) INDEX NOT NULL DEFAULT(0)"` + OriginalAuthorID int64 `xorm:"UNIQUE(s) NOT NULL DEFAULT(0)"` + OriginalAuthor string `xorm:"UNIQUE(s) NOT NULL DEFAULT('')"` + Type string `xorm:"UNIQUE(s) NOT NULL"` + + User *user_model.User `xorm:"-"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` } // LoadUser load user of reaction @@ -108,53 +111,48 @@ func init() { // FindReactionsOptions describes the conditions to Find reactions type FindReactionsOptions struct { db.ListOptions - IssueID int64 - CommentID int64 - UserID int64 - Reaction string + IssueIDOnly int64 + IssueIDWithComments int64 + CommentID int64 + ReleaseID int64 + UserID int64 } func (opts *FindReactionsOptions) toConds() builder.Cond { // If Issue ID is set add to Query cond := builder.NewCond() - if opts.IssueID > 0 { - cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID}) + if opts.IssueIDOnly > 0 { + cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueIDOnly}).And(builder.Expr("reaction.comment_id = 0")) + } + if opts.IssueIDWithComments > 0 { + cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueIDWithComments}) } - // If CommentID is > 0 add to Query - // If it is 0 Query ignore CommentID to select - // If it is -1 it explicit search of Issue Reactions where CommentID = 0 if opts.CommentID > 0 { cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID}) - } else if opts.CommentID == -1 { - cond = cond.And(builder.Eq{"reaction.comment_id": 0}) + } + if opts.ReleaseID > 0 { + cond = cond.And(builder.Eq{"reaction.release_id": opts.ReleaseID}) } if opts.UserID > 0 { - cond = cond.And(builder.Eq{ - "reaction.user_id": opts.UserID, - "reaction.original_author_id": 0, - }) + cond = cond.And(builder.Eq{"reaction.user_id": opts.UserID, "reaction.original_author_id": 0}) } - if opts.Reaction != "" { - cond = cond.And(builder.Eq{"reaction.type": opts.Reaction}) + if !cond.IsValid() { + setting.PanicInDevOrTesting("FindReactionsOptions.toConds: no conditions specified") + cond = cond.And(builder.Expr("1=0")) } - return cond } -// FindCommentReactions returns a ReactionList of all reactions from an comment -func FindCommentReactions(ctx context.Context, issueID, commentID int64) (ReactionList, int64, error) { - return FindReactions(ctx, FindReactionsOptions{ - IssueID: issueID, - CommentID: commentID, - }) +// FindCommentReactions returns a ReactionList of all reactions from a comment +func FindCommentReactions(ctx context.Context, commentID int64) (ReactionList, int64, error) { + return FindReactions(ctx, FindReactionsOptions{CommentID: commentID}) } // FindIssueReactions returns a ReactionList of all reactions from an issue func FindIssueReactions(ctx context.Context, issueID int64, listOptions db.ListOptions) (ReactionList, int64, error) { return FindReactions(ctx, FindReactionsOptions{ ListOptions: listOptions, - IssueID: issueID, - CommentID: -1, + IssueIDOnly: issueID, }) } @@ -177,37 +175,14 @@ func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList return reactions, count, err } -func createReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) { - reaction := &Reaction{ - Type: opts.Type, - UserID: opts.DoerID, - IssueID: opts.IssueID, - CommentID: opts.CommentID, - } - findOpts := FindReactionsOptions{ - IssueID: opts.IssueID, - CommentID: opts.CommentID, - Reaction: opts.Type, - UserID: opts.DoerID, - } - if findOpts.CommentID == 0 { - // explicit search of Issue Reactions where CommentID = 0 - findOpts.CommentID = -1 - } - - existingR, _, err := FindReactions(ctx, findOpts) - if err != nil { - return nil, err - } - if len(existingR) > 0 { - return existingR[0], ErrReactionAlreadyExist{Reaction: opts.Type} - } - - if err := db.Insert(ctx, reaction); err != nil { - return nil, err - } - - return reaction, nil +func FindReleaseReactions(ctx context.Context, releaseIDs ...int64) (ReactionList, error) { + reactions := make(ReactionList, 0, 10) + err := db.GetEngine(ctx). + In("release_id", releaseIDs). + In("`type`", setting.UI.ReleaseReactions). + Asc("created_unix", "id"). + Find(&reactions) + return reactions, err } // ReactionOptions defines options for creating or deleting reactions @@ -216,56 +191,63 @@ type ReactionOptions struct { DoerID int64 IssueID int64 CommentID int64 + ReleaseID int64 } -// CreateReaction creates reaction for issue or comment. -func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) { - if !setting.UI.ReactionsLookup.Contains(opts.Type) { +// CreateReaction creates reaction for issue, comment or release. +func CreateReaction(ctx context.Context, allowed container.Set[string], opts *ReactionOptions) (*Reaction, error) { + if !allowed.Contains(opts.Type) { return nil, ErrForbiddenIssueReaction{opts.Type} } - return db.WithTx2(ctx, func(ctx context.Context) (*Reaction, error) { - return createReaction(ctx, opts) - }) -} - -// DeleteReaction deletes reaction for issue or comment. -func DeleteReaction(ctx context.Context, opts *ReactionOptions) error { reaction := &Reaction{ Type: opts.Type, UserID: opts.DoerID, IssueID: opts.IssueID, CommentID: opts.CommentID, + ReleaseID: opts.ReleaseID, } - sess := db.GetEngine(ctx).Where("original_author_id = 0") - if opts.CommentID == -1 { - reaction.CommentID = 0 - sess.MustCols("comment_id") + err := db.Insert(ctx, reaction) + if err != nil { + if has, _ := db.GetEngine(ctx).Get(reaction); has { + return reaction, ErrReactionAlreadyExist{Reaction: opts.Type} + } + return nil, err } + return reaction, nil +} - _, err := sess.Delete(reaction) +func DeleteIssueReaction(ctx context.Context, doerID, issueID int64, content string) error { + // here and below: the migration reactions have unrelated user_id (might be the doer?) + // so skip the deletion if the reaction is from an external user (original_author_id != 0) + sess := db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0 AND user_id = ? AND original_author_id = 0 AND `type` = ?", issueID, doerID, content) + _, err := sess.Delete(&Reaction{}) return err } -// DeleteIssueReaction deletes a reaction on issue. -func DeleteIssueReaction(ctx context.Context, doerID, issueID int64, content string) error { - return DeleteReaction(ctx, &ReactionOptions{ - Type: content, - DoerID: doerID, - IssueID: issueID, - CommentID: -1, - }) +func DeleteCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) error { + sess := db.GetEngine(ctx).Where("issue_id = ? AND comment_id = ? AND user_id = ? AND original_author_id = 0 AND `type` = ?", issueID, commentID, doerID, content) + _, err := sess.Delete(&Reaction{}) + return err } -// DeleteCommentReaction deletes a reaction on comment. -func DeleteCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) error { - return DeleteReaction(ctx, &ReactionOptions{ - Type: content, - DoerID: doerID, - IssueID: issueID, - CommentID: commentID, - }) +func DeleteCommentAllReactions(ctx context.Context, commentID int64) error { + sess := db.GetEngine(ctx).Where("comment_id = ?", commentID) + _, err := sess.Delete(&Reaction{}) + return err +} + +func DeleteUserReactions(ctx context.Context, userID int64) error { + sess := db.GetEngine(ctx).Where("user_id = ?", userID) + _, err := sess.Delete(&Reaction{}) + return err +} + +func DeleteReleaseReaction(ctx context.Context, doerID, releaseID int64, content string) error { + sess := db.GetEngine(ctx).Where("release_id = ? AND user_id = ? AND `type` = ?", releaseID, doerID, content) + _, err := sess.Delete(&Reaction{}) + return err } // ReactionList represents list of reactions @@ -302,23 +284,15 @@ func (list ReactionList) getUserIDs() []int64 { }) } -func valuesUser(m map[int64]*user_model.User) []*user_model.User { - values := make([]*user_model.User, 0, len(m)) - for _, v := range m { - values = append(values, v) - } - return values -} - // newMigrationOriginalUser creates and returns a fake user for external user func newMigrationOriginalUser(name string) *user_model.User { return &user_model.User{ID: 0, Name: name, LowerName: strings.ToLower(name)} } // LoadUsers loads reactions' all users -func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) ([]*user_model.User, error) { +func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) (map[int64]*user_model.User, error) { if len(list) == 0 { - return nil, nil + return map[int64]*user_model.User{}, nil } userIDs := list.getUserIDs() @@ -339,7 +313,7 @@ func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Reposit reaction.User = user_model.NewGhostUser() } } - return valuesUser(userMaps), nil + return userMaps, nil } // GetFirstUsers returns first reacted user display names separated by comma diff --git a/modules/setting/ui.go b/modules/setting/ui.go index 23204daf87f..bd2ba1bc9e7 100644 --- a/modules/setting/ui.go +++ b/modules/setting/ui.go @@ -30,7 +30,9 @@ var UI = struct { FileIconTheme string FolderIconTheme string Reactions []string + ReleaseReactions []string ReactionsLookup container.Set[string] `ini:"-"` + ReleaseReactionsLookup container.Set[string] `ini:"-"` CustomEmojis []string CustomEmojisMap map[string]string `ini:"-"` EnabledEmojis []string @@ -98,6 +100,7 @@ var UI = struct { FileIconTheme: `material`, FolderIconTheme: `basic`, Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, + ReleaseReactions: []string{`+1`, `laugh`, `hooray`, `heart`, `rocket`, `eyes`}, CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`}, CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"}, ExploreDefaultSort: "recentupdate", @@ -170,10 +173,9 @@ func loadUIFrom(rootCfg ConfigProvider) { // because many private repositories do not have "description/topic", users just want to search by their names. UI.OnlyShowRelevantRepos = sec.Key("ONLY_SHOW_RELEVANT_REPOS").MustBool(false) - UI.ReactionsLookup = make(container.Set[string]) - for _, reaction := range UI.Reactions { - UI.ReactionsLookup.Add(reaction) - } + UI.ReactionsLookup = container.SetOf(UI.Reactions...) + UI.ReleaseReactionsLookup = container.SetOf(UI.ReleaseReactions...) + UI.CustomEmojisMap = make(map[string]string) for _, emoji := range UI.CustomEmojis { UI.CustomEmojisMap[emoji] = ":" + emoji + ":" diff --git a/modules/templates/helper.go b/modules/templates/helper.go index c4f0f16225a..6fc08b6812e 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -93,9 +93,6 @@ func newFuncMapWebPage() template.FuncMap { "ShowFooterPoweredBy": func() bool { return setting.Other.ShowFooterPoweredBy }, - "AllowedReactions": func() []string { - return setting.UI.Reactions - }, "MetaAuthor": func() string { return setting.UI.Meta.Author }, diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go index d77958cd473..78b04eaab6e 100644 --- a/routers/api/v1/repo/issue_reaction.go +++ b/routers/api/v1/repo/issue_reaction.go @@ -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 diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 8be9d059345..1dcfa468406 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -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) diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index a48cc456291..33f16863166 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -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 diff --git a/routers/web/repo/issue_comment.go b/routers/web/repo/issue_comment.go index efff271b520..0749cd0ee46 100644 --- a/routers/web/repo/issue_comment.go +++ b/routers/web/repo/issue_comment.go @@ -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 diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index b3372860968..19cf71c5448 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -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) diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 6eb14b7f123..788b2baab7f 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -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 { diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 9ab71c121bb..bf67269f3fa 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -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 { diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 00598601058..757ac1fe84b 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -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 diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 3bffbf5b077..84259682a69 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -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()) +} diff --git a/routers/web/web.go b/routers/web/web.go index e786185b9f0..17733003113 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/context/base.go b/services/context/base.go index 8d537b3c2e9..edcf0fc2eba 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -148,16 +148,26 @@ func (b *Base) PlainText(status int, text string) { // Redirect redirects the request func (b *Base) Redirect(location string, status ...int) { - code := util.OptionalArg(status, http.StatusSeeOther) // In case the request is made by "fetch-action" module, make JS redirect to the new location // Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected. if httplib.IsGiteaFetchActionRequest(b.Req) { b.JSON(http.StatusOK, map[string]any{"redirect": location}) return } + code := util.OptionalArg(status, http.StatusSeeOther) http.Redirect(b.Resp, b.Req, location, code) } +func (b *Base) RedirectForLogin(location string) { + if httplib.IsGiteaFetchActionRequest(b.Req) { + // let fetch-action handles the redirect automatically + b.JSON(http.StatusUnauthorized, map[string]any{"redirect": location}) + } else { + // keep the old behaivor, use 303 to redirect to login page + http.Redirect(b.Resp, b.Req, location, http.StatusSeeOther) + } +} + type ServeHeaderOptions = httplib.ServeHeaderOptions func (b *Base) SetServeHeaders(opts ServeHeaderOptions) { diff --git a/services/context/upload/upload.go b/services/context/upload/upload.go index 922501ebb2c..0d69a85b92b 100644 --- a/services/context/upload/upload.go +++ b/services/context/upload/upload.go @@ -104,6 +104,7 @@ type uploadOptions struct { // AddUploadContext renders template values for dropzone func AddUploadContext(ctx *context.Context, uploadType string) { + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled switch uploadType { case "release": ctx.Data["UploadOptions"] = uploadOptions{ diff --git a/services/issue/reaction.go b/services/issue/reaction.go index e06d8116431..b525038df44 100644 --- a/services/issue/reaction.go +++ b/services/issue/reaction.go @@ -8,6 +8,7 @@ import ( issues_model "gitea.dev/models/issues" user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" ) // CreateIssueReaction creates a reaction on an issue. @@ -20,7 +21,7 @@ func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issu return nil, user_model.ErrBlockedUser } - return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + return issues_model.CreateReaction(ctx, setting.UI.ReactionsLookup, &issues_model.ReactionOptions{ Type: content, DoerID: doer.ID, IssueID: issue.ID, @@ -41,7 +42,7 @@ func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment * return nil, user_model.ErrBlockedUser } - return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + return issues_model.CreateReaction(ctx, setting.UI.ReactionsLookup, &issues_model.ReactionOptions{ Type: content, DoerID: doer.ID, IssueID: comment.Issue.ID, diff --git a/services/issue/reaction_test.go b/services/issue/reaction_test.go index 7cf5d155461..3d85e603ebc 100644 --- a/services/issue/reaction_test.go +++ b/services/issue/reaction_test.go @@ -90,9 +90,7 @@ func TestIssueReactionCount(t *testing.T) { addReaction(t, user4, issue, nil, "heart") addReaction(t, ghost, issue, nil, "-1") - reactionsList, _, err := issues_model.FindReactions(t.Context(), issues_model.FindReactionsOptions{ - IssueID: issue.ID, - }) + reactionsList, _, err := issues_model.FindReactions(t.Context(), issues_model.FindReactionsOptions{IssueIDOnly: issue.ID}) assert.NoError(t, err) assert.Len(t, reactionsList, 7) _, err = reactionsList.LoadUsers(t.Context(), repo) @@ -136,10 +134,7 @@ func TestIssueCommentDeleteReaction(t *testing.T) { addReaction(t, org3, nil, comment, "heart") addReaction(t, user4, nil, comment, "+1") - reactionsList, _, err := issues_model.FindReactions(t.Context(), issues_model.FindReactionsOptions{ - IssueID: comment.IssueID, - CommentID: comment.ID, - }) + reactionsList, _, err := issues_model.FindReactions(t.Context(), issues_model.FindReactionsOptions{CommentID: comment.ID}) assert.NoError(t, err) assert.Len(t, reactionsList, 4) diff --git a/services/release/reaction.go b/services/release/reaction.go new file mode 100644 index 00000000000..8d64ffa8241 --- /dev/null +++ b/services/release/reaction.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package release + +import ( + "context" + + issues_model "gitea.dev/models/issues" + repo_model "gitea.dev/models/repo" + user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" +) + +func AllowedReleaseReactions() []string { + return setting.UI.ReleaseReactions +} + +func CreateReleaseReaction(ctx context.Context, doer *user_model.User, rel *repo_model.Release, content string) (*issues_model.Reaction, error) { + if err := rel.LoadRepo(ctx); err != nil { + return nil, err + } + + if user_model.IsUserBlockedBy(ctx, doer, rel.PublisherID, rel.Repo.OwnerID) { + return nil, user_model.ErrBlockedUser + } + + return issues_model.CreateReaction(ctx, setting.UI.ReleaseReactionsLookup, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + ReleaseID: rel.ID, + }) +} diff --git a/services/release/release.go b/services/release/release.go index f5a31f2acea..f23788369e7 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -11,6 +11,7 @@ import ( "gitea.dev/models/db" git_model "gitea.dev/models/git" + issues_model "gitea.dev/models/issues" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/container" @@ -421,6 +422,10 @@ func DeleteReleaseByID(ctx context.Context, repo *repo_model.Repository, rel *re return fmt.Errorf("DeleteAttachments: %w", err) } + if _, err := db.DeleteByBean(ctx, &issues_model.Reaction{ReleaseID: rel.ID}); err != nil { + return fmt.Errorf("DeleteReleaseByID: %w", err) + } + for i := range rel.Attachments { attachment := rel.Attachments[i] if err := storage.Attachments.Delete(attachment.RelativePath()); err != nil { diff --git a/services/release/release_test.go b/services/release/release_test.go index 35a14665552..9603c125eb0 100644 --- a/services/release/release_test.go +++ b/services/release/release_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "gitea.dev/models/db" + issues_model "gitea.dev/models/issues" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" @@ -407,6 +409,25 @@ func TestCreateNewTag(t *testing.T) { "v2.0 is released \n\n BUGFIX: .... \n\n 123")) } +func TestCreateReleaseReaction(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1}) + + assert.Equal(t, []string{"+1", "laugh", "hooray", "heart", "rocket", "eyes"}, AllowedReleaseReactions()) + + _, err := CreateReleaseReaction(t.Context(), user, release, "heart") + assert.NoError(t, err) + assert.NoError(t, DeleteReleaseByID(t.Context(), repo, release, user, false)) + unittest.AssertNotExistsBean(t, &issues_model.Reaction{ReleaseID: release.ID}) + + assert.NoError(t, db.Insert(t.Context(), &user_model.Blocking{BlockerID: repo.OwnerID, BlockeeID: user.ID})) + _, err = CreateReleaseReaction(t.Context(), user, release, "heart") + assert.ErrorIs(t, err, user_model.ErrBlockedUser) +} + func TestRelease_DatedByTargetCommit(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) diff --git a/services/repository/delete.go b/services/repository/delete.go index e018ed3fbeb..b1f305e74f5 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -139,6 +139,11 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams return err } + if _, err := db.GetEngine(ctx).In("release_id", builder.Select("id").From("`release`").Where(builder.Eq{"repo_id": repoID})). + Delete(&issues_model.Reaction{}); err != nil { + return err + } + // CleanupEphemeralRunnersByPickedTaskOfRepo deletes ephemeral global/org/user that have started any task of this repo // The cannot pick a second task hardening for ephemeral runners expect that task objects remain available until runner deletion // This method will delete affected ephemeral global/org/user runners diff --git a/services/repository/delete_test.go b/services/repository/delete_test.go index bd069b5f4cc..7d1c1e4b6b0 100644 --- a/services/repository/delete_test.go +++ b/services/repository/delete_test.go @@ -10,6 +10,7 @@ import ( actions_model "gitea.dev/models/actions" "gitea.dev/models/db" git_model "gitea.dev/models/git" + issues_model "gitea.dev/models/issues" "gitea.dev/models/organization" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" @@ -75,6 +76,7 @@ func TestDeleteRepositoryDirectlyPurgesRepoScopedRows(t *testing.T) { &git_model.RenamedBranch{RepoID: 1, From: "old-name", To: "new-name"}, &git_model.CommitStatusSummary{RepoID: 1, SHA: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", State: "success"}, &repo_model.RepoTransfer{RepoID: 1, DoerID: 2, RecipientID: 3}, + &issues_model.Reaction{Type: "heart", UserID: 2, ReleaseID: 1}, )) unittest.AssertExistsAndLoadBean(t, &git_model.CommitStatusIndex{RepoID: 1}) @@ -87,4 +89,5 @@ func TestDeleteRepositoryDirectlyPurgesRepoScopedRows(t *testing.T) { unittest.AssertNotExistsBean(t, &git_model.CommitStatusSummary{RepoID: 1}) unittest.AssertNotExistsBean(t, &git_model.CommitStatusIndex{RepoID: 1}) unittest.AssertNotExistsBean(t, &repo_model.RepoTransfer{RepoID: 1}) + unittest.AssertNotExistsBean(t, &issues_model.Reaction{ReleaseID: 1}) } diff --git a/services/user/delete.go b/services/user/delete.go index e6ff7075c44..9f3ca48b34c 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -129,7 +129,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) } // Delete Reactions - if err = issues_model.DeleteReaction(ctx, &issues_model.ReactionOptions{DoerID: u.ID}); err != nil { + if err = issues_model.DeleteUserReactions(ctx, u.ID); err != nil { return err } } diff --git a/templates/repo/diff/comments.tmpl b/templates/repo/diff/comments.tmpl index 206f6b8f67a..b6c8884d7e7 100644 --- a/templates/repo/diff/comments.tmpl +++ b/templates/repo/diff/comments.tmpl @@ -55,9 +55,6 @@ {{end}} {{end}} - {{if not $repo.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID)}} - {{end}} {{template "repo/issue/view_content/context_menu" dict "ConvContentType" "issue_comment" "ConvContentPage" "pr-files" "ConvContentItem" $comment}} @@ -76,10 +73,11 @@ {{template "repo/issue/view_content/attachments" dict "Attachments" $comment.Attachments "RenderedContent" $comment.RenderedContent}} {{end}} - {{$reactions := $comment.Reactions.GroupByType}} - {{if $reactions}} - {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID) "Reactions" $reactions}} - {{end}} + {{template "shared/reactions" dict + "AllowedReactions" $root.AllowedIssueReactions + "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID) + "Reactions" $comment.Reactions.GroupByType + }} {{end}} diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index a04f1cf5940..d040cc4b3d7 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -38,9 +38,6 @@
{{template "repo/issue/view_content/show_role" dict "ShowRole" .Issue.ShowRole "IgnorePoster" true}} - {{if not $.Repository.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}} - {{end}} {{template "repo/issue/view_content/context_menu" dict "ConvContentType" "issue_main" "ConvContentItem" .Issue}}
@@ -58,10 +55,11 @@ {{template "repo/issue/view_content/attachments" dict "Attachments" .Issue.Attachments "RenderedContent" .Issue.RenderedContent}} {{end}} - {{$reactions := .Issue.Reactions.GroupByType}} - {{if $reactions}} - {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) "Reactions" $reactions}} - {{end}} + {{template "shared/reactions" dict + "AllowedReactions" $.AllowedIssueReactions + "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) + "Reactions" .Issue.Reactions.GroupByType + }} diff --git a/templates/repo/issue/view_content/add_reaction.tmpl b/templates/repo/issue/view_content/add_reaction.tmpl deleted file mode 100644 index 9b8d0344c99..00000000000 --- a/templates/repo/issue/view_content/add_reaction.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -{{if ctx.RootData.IsSigned}} - -{{end}} diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index ff65cfbecc0..4ce549f6116 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -53,9 +53,6 @@
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}} - {{if not $.Repository.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}} - {{end}} {{template "repo/issue/view_content/context_menu" dict "ConvContentType" "issue_comment" "ConvContentItem" $comment}}
@@ -73,10 +70,11 @@ {{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}} {{end}} - {{$reactions := .Reactions.GroupByType}} - {{if $reactions}} - {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions}} - {{end}} + {{template "shared/reactions" dict + "AllowedReactions" $.AllowedIssueReactions + "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) + "Reactions" .Reactions.GroupByType + }} {{else if eq .Type 1}} @@ -423,7 +421,6 @@
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}} {{if not $.Repository.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}} {{template "repo/issue/view_content/context_menu" dict "ConvContentType" "pull_review" "ConvContentItem" $comment}} {{end}}
@@ -442,10 +439,11 @@ {{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}} {{end}} - {{$reactions := .Reactions.GroupByType}} - {{if $reactions}} - {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions}} - {{end}} + {{template "shared/reactions" dict + "AllowedReactions" $.AllowedIssueReactions + "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) + "Reactions" .Reactions.GroupByType + }} {{end}} diff --git a/templates/repo/issue/view_content/conversation.tmpl b/templates/repo/issue/view_content/conversation.tmpl index 4689cd7cbf5..e7768b92b55 100644 --- a/templates/repo/issue/view_content/conversation.tmpl +++ b/templates/repo/issue/view_content/conversation.tmpl @@ -92,7 +92,6 @@ The variables in "ctx.Data" are different in each case, making this template fra
{{template "repo/issue/view_content/show_role" dict "ShowRole" $comment.ShowRole}} {{if not $repo.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID)}} {{template "repo/issue/view_content/context_menu" dict "ConvContentType" "issue_comment" "ConvContentItem" $comment}} {{end}}
@@ -112,10 +111,11 @@ The variables in "ctx.Data" are different in each case, making this template fra {{template "repo/issue/view_content/attachments" dict "Attachments" $comment.Attachments "RenderedContent" $comment.RenderedContent}} {{end}} - {{$reactions := $comment.Reactions.GroupByType}} - {{if $reactions}} - {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID) "Reactions" $reactions}} - {{end}} + {{template "shared/reactions" dict + "AllowedReactions" $root.AllowedIssueReactions + "ActionURL" (printf "%s/comments/%d/reactions" $repoLink $comment.ID) + "Reactions" $comment.Reactions.GroupByType + }} {{end}} diff --git a/templates/repo/issue/view_content/reactions.tmpl b/templates/repo/issue/view_content/reactions.tmpl deleted file mode 100644 index 0ed6faf7025..00000000000 --- a/templates/repo/issue/view_content/reactions.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -
-{{range $key, $value := .Reactions}} - {{$hasReacted := $value.HasUser ctx.RootData.SignedUserID}} - - {{ReactionToEmoji $key}} - {{len $value}} - -{{end}} -{{if AllowedReactions}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" .ActionURL}} -{{end}} -
diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index be2ae5df8ff..3c0d2bd676d 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -107,6 +107,13 @@ {{end}} + {{if not (or $release.IsTag $release.IsDraft)}} + {{template "shared/reactions" dict + "ActionURL" (printf "%s/releases/%d/reactions" $.RepoLink $release.ID) + "Reactions" $info.Reactions.GroupByType + "AllowedReactions" $.AllowedReleaseReactions + }} + {{end}} {{end}} diff --git a/templates/shared/reactions.tmpl b/templates/shared/reactions.tmpl new file mode 100644 index 00000000000..b716be20ad9 --- /dev/null +++ b/templates/shared/reactions.tmpl @@ -0,0 +1,28 @@ +{{$allowedReactions := $.AllowedReactions}} +{{$canReact := and $allowedReactions ctx.RootData.IsSigned (not ctx.RootData.Repository.IsArchived)}} +{{if or .Reactions $canReact}} +
+{{if $canReact}} + +
+
+ {{range $value := $allowedReactions}} + {{ReactionToEmoji $value}} + {{end}} +
+
+{{end}} +{{range $key, $value := .Reactions}} + {{$hasReacted := $value.HasUser ctx.RootData.SignedUserID}} + + {{ReactionToEmoji $key}} + {{len $value}} + +{{end}} +
+{{end}} diff --git a/tests/e2e/reactions.test.ts b/tests/e2e/reactions.test.ts index a6b61837416..670a91ed03f 100644 --- a/tests/e2e/reactions.test.ts +++ b/tests/e2e/reactions.test.ts @@ -12,15 +12,20 @@ test('toggle issue reactions', async ({page, request}) => { ]); await page.goto(`/${owner}/${repoName}/issues/1`); - const issueComment = page.locator('.timeline-item.comment.issue-content-comment'); + const reactions = page.locator('.timeline-item.comment.issue-content-comment').getByRole('group', {name: 'Reactions'}); + const reactionPicker = reactions.getByRole('button', {name: 'Reactions', exact: true}); - const reactionPicker = issueComment.locator('.select-reaction'); await reactionPicker.click(); - await reactionPicker.getByLabel('+1').click(); - - const reactions = issueComment.getByRole('group', {name: 'Reactions'}); + await page.getByRole('menuitem', {name: '+1', exact: true}).click(); await expect(reactions.getByRole('button', {name: /^\+1:/})).toContainText('1'); await reactions.getByRole('button', {name: /^\+1:/}).click(); await expect(reactions.getByRole('button', {name: /^\+1:/})).toHaveCount(0); + + await reactionPicker.press('Enter'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + await expect(reactions.getByRole('button', {name: /^-1:/})).toContainText('1'); + await expect(reactionPicker).toBeFocused(); }); diff --git a/tests/integration/release_test.go b/tests/integration/release_test.go index 4526e37cb03..a64136a32f8 100644 --- a/tests/integration/release_test.go +++ b/tests/integration/release_test.go @@ -8,6 +8,7 @@ import ( "net/http" "testing" + issues_model "gitea.dev/models/issues" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" @@ -280,3 +281,29 @@ func TestEditReleaseAttachmentRejectsForbiddenRename(t *testing.T) { attachment = unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: attachment.ID}) assert.NotEqual(t, "evil.exe", attachment.Name) } + +func TestReleaseReaction(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + reactReq := func(releaseID int64, action, content string) *RequestWrapper { + return NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/releases/%d/reactions/%s", releaseID, action), map[string]string{"content": content}) + } + + session.MakeRequest(t, reactReq(1, "react", "-1"), http.StatusForbidden) + + session.MakeRequest(t, reactReq(1, "react", "heart"), http.StatusOK) + session.MakeRequest(t, reactReq(1, "react", "heart"), http.StatusOK) + unittest.AssertCount(t, &issues_model.Reaction{ReleaseID: 1}, 1) + + resp := session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/releases"), http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + AssertHTMLElement(t, htmlDoc, `.bottom-reactions a.ui.label[data-reaction-content="heart"]`, 1) + AssertHTMLElement(t, htmlDoc, `.reaction-picker [data-reaction-content="-1"]`, 0) + + session.MakeRequest(t, reactReq(1, "unreact", "heart"), http.StatusOK) + unittest.AssertCount(t, &issues_model.Reaction{ReleaseID: 1}, 0) + + for _, releaseID := range []int64{2, 3, 4} { + session.MakeRequest(t, reactReq(releaseID, "react", "heart"), http.StatusNotFound) + } +} diff --git a/web_src/css/repo/reactions.css b/web_src/css/repo/reactions.css index c51587c238b..ffbf36f35a7 100644 --- a/web_src/css/repo/reactions.css +++ b/web_src/css/repo/reactions.css @@ -1,30 +1,61 @@ .bottom-reactions { display: flex; - gap: 6px; - margin: 0 1em 1em; + flex-wrap: wrap; + gap: 4px; + margin: 0 14px 14px; } .timeline-item .conversation-holder .bottom-reactions { - margin: 1em 0 0 24px; + margin: 14px 0 0 24px; padding-bottom: 8px; } +.releases .bottom-reactions { + margin: 14px 0 0; +} + +.bottom-reactions .select-reaction, .bottom-reactions .ui.label { - padding: 2px 6px; - font-weight: var(--font-weight-normal); + height: 28px; } -.bottom-reactions .ui.label.primary { - background-color: var(--color-reaction-active-bg) !important; +.bottom-reactions .select-reaction { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + padding: 0; + border-radius: var(--border-radius-full); + border: 1px solid var(--color-light-border); + background: var(--color-reaction-bg); + color: var(--color-text-light-1); } -.bottom-reactions .ui.label:hover { - background-color: var(--color-reaction-hover-bg) !important; +.bottom-reactions .select-reaction:hover, +.bottom-reactions .select-reaction[aria-expanded="true"] { + background: var(--color-hover); + color: var(--color-text); +} + +.bottom-reactions .ui.label { + padding: 0 8px; + background: transparent; +} + +.bottom-reactions .ui.basic.label[data-has-reacted="true"] { + border-color: var(--color-primary); + background: var(--color-reaction-active-bg); + color: var(--color-text); +} + +.bottom-reactions .ui.label:not(.disabled):hover { + background: var(--color-reaction-hover-bg); } .bottom-reactions .ui.label.disabled { cursor: default; opacity: 1; + pointer-events: auto; } .bottom-reactions .ui.label .reaction { @@ -37,35 +68,22 @@ aspect-ratio: 1; } -.bottom-reactions .reaction-count { - margin-left: 4px; -} - -.ui.dropdown.select-reaction .menu.visible { - display: grid !important; - grid-template-columns: repeat(4, 1fr); - padding: 4px; -} - -.ui.dropdown.select-reaction .menu > .item { - width: 34px; - height: 34px; - margin: 0; - font-size: 16px; - border-radius: var(--border-radius); +.reaction-picker { display: flex; - align-items: center; + flex-wrap: wrap; + gap: 4px; + padding: 0 4px; +} + +.tippy-box .reaction-picker .item { justify-content: center; + width: 32px; + height: 32px; + margin: 0; + padding: 0; + font-size: 17.5px; } -.bottom-reactions .select-reaction { - padding: 0 10px; -} - -.bottom-reactions .select-reaction:not(.active) { - visibility: hidden; -} - -.bottom-reactions:hover .select-reaction { - visibility: visible; +.tippy-box .reaction-picker .item[data-has-reacted="true"] { + background: var(--color-reaction-active-bg); } diff --git a/web_src/js/features/comp/ReactionSelector.ts b/web_src/js/features/comp/ReactionSelector.ts index b7e944f9e7f..d1e9537c5d3 100644 --- a/web_src/js/features/comp/ReactionSelector.ts +++ b/web_src/js/features/comp/ReactionSelector.ts @@ -1,31 +1,44 @@ -import {POST} from '../../modules/fetch.ts'; +import {performFetchActionRequest} from '../../modules/fetch-action.ts'; import {registerGlobalEventFunc} from '../../modules/observer.ts'; +import {createTippy} from '../../modules/tippy.ts'; +import {createElementFromHTML} from '../../utils/dom.ts'; export function initCompReactionSelector() { + registerGlobalEventFunc('click', 'onReactionPickerClick', (btn: HTMLElement) => { + if (btn._tippy) return; + createTippy(btn, { + content: btn.nextElementSibling!, + theme: 'menu', + arrow: false, + maxWidth: 'none', + placement: 'bottom-start', + trigger: 'click', + interactive: true, + hideOnClick: true, + }).show(); + }); + registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: Event) => { - // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment e.preventDefault(); if (target.classList.contains('disabled')) return; - const actionUrl = target.closest('[data-action-url]')!.getAttribute('data-action-url'); - const reactionContent = target.getAttribute('data-reaction-content')!; - - const commentContainer = target.closest('.comment-container')!; - - const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction - const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`); - const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true'; - - const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, { - data: new URLSearchParams({content: reactionContent}), + const picker = target.closest('[data-tippy-root]')?._tippy; + picker?.hide(); + const bottomReactions = (picker?.reference ?? target).closest('.bottom-reactions')!; + const hasReacted = target.getAttribute('data-has-reacted') === 'true'; + const res = await performFetchActionRequest(bottomReactions, { + method: 'POST', + url: `${bottomReactions.getAttribute('data-action-url')}/${hasReacted ? 'unreact' : 'react'}`, + data: new URLSearchParams({content: target.getAttribute('data-reaction-content')!}), }); - if (!res.ok) return; + if (!res) return; const data = await res.json(); - bottomReactions?.remove(); - if (data.html) { - commentContainer.insertAdjacentHTML('beforeend', data.html); - } + const hadFocus = bottomReactions.contains(document.activeElement); + bottomReactions.querySelector('.select-reaction')?._tippy?.destroy(); + const newBottomReactions = createElementFromHTML(data.html); + bottomReactions.replaceWith(newBottomReactions); + if (hadFocus) newBottomReactions.querySelector('.select-reaction')?.focus(); }); } diff --git a/web_src/js/modules/fetch-action.ts b/web_src/js/modules/fetch-action.ts index 58b2e14a21f..76b07c1a658 100644 --- a/web_src/js/modules/fetch-action.ts +++ b/web_src/js/modules/fetch-action.ts @@ -123,7 +123,9 @@ async function handleFetchActionError(el: HTMLElement, resp: Response) { const isRespJson = resp.headers.get('content-type')?.includes('application/json'); const respText = await resp.text(); const respJson = isRespJson ? JSON.parse(respText) : null; - if (respJson?.errorMessage) { + if (respJson?.redirect) { + fetchActionDoRedirect(respJson.redirect); + } else if (respJson?.errorMessage) { // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'}); diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index 68865bbfbd5..9e71dce7edc 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -81,8 +81,8 @@ document.addEventListener('keydown', (e: KeyboardEvent) => { if (!inMenu && focused !== menuInstance.reference && focused !== document.body) return; // body: macOS Safari and Firefox do not focus clicked buttons if (e.key === 'Escape') { menuInstance.hide(); - } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - focusMenuItem(menuInstance, e.key === 'ArrowDown' ? 1 : -1); + } else if (['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft'].includes(e.key)) { + focusMenuItem(menuInstance, e.key === 'ArrowDown' || e.key === 'ArrowRight' ? 1 : -1); } else if ((e.key === 'Enter' || e.key === ' ') && inMenu) { focused.click(); } else {