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

+4 -1
View File
@@ -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
;;
+1
View File
@@ -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
}
+33
View File
@@ -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))
}
+2 -5
View File
@@ -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
+3 -5
View File
@@ -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
+83 -109
View File
@@ -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
+6 -4
View File
@@ -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 + ":"
-3
View File
@@ -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
},
+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)
+11 -1
View File
@@ -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) {
+1
View File
@@ -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{
+3 -2
View File
@@ -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,
+2 -7
View File
@@ -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)
+33
View File
@@ -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,
})
}
+5
View File
@@ -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 {
+21
View File
@@ -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})
+5
View File
@@ -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
+3
View File
@@ -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})
}
+1 -1
View File
@@ -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
}
}
+5 -7
View File
@@ -55,9 +55,6 @@
</div>
{{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}}
</div>
</div>
@@ -76,10 +73,11 @@
{{template "repo/issue/view_content/attachments" dict "Attachments" $comment.Attachments "RenderedContent" $comment.RenderedContent}}
{{end}}
</div>
{{$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
}}
</div>
</div>
{{end}}
+5 -7
View File
@@ -38,9 +38,6 @@
</div>
<div class="comment-header-right">
{{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}}
</div>
</div>
@@ -58,10 +55,11 @@
{{template "repo/issue/view_content/attachments" dict "Attachments" .Issue.Attachments "RenderedContent" .Issue.RenderedContent}}
{{end}}
</div>
{{$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
}}
</div>
</div>
@@ -1,10 +0,0 @@
{{if ctx.RootData.IsSigned}}
<div class="item action ui dropdown jump pointing top right select-reaction" data-action-url="{{.ActionURL}}" aria-label="{{ctx.Locale.Tr "repo.reactions"}}">
<a class="muted">{{svg "octicon-smiley"}}</a>
<div class="menu">
{{range $value := AllowedReactions}}
<a class="item emoji" data-tooltip-content="{{$value}}" aria-label="{{$value}}" data-reaction-content="{{$value}}" data-global-click="onCommentReactionButtonClick">{{ReactionToEmoji $value}}</a>
{{end}}
</div>
</div>
{{end}}
+10 -12
View File
@@ -53,9 +53,6 @@
</div>
<div class="comment-header-right">
{{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}}
</div>
</div>
@@ -73,10 +70,11 @@
{{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}}
{{end}}
</div>
{{$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
}}
</div>
</div>
{{else if eq .Type 1}}
@@ -423,7 +421,6 @@
<div class="comment-header-right">
{{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}}
</div>
@@ -442,10 +439,11 @@
{{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}}
{{end}}
</div>
{{$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
}}
</div>
</div>
{{end}}
@@ -92,7 +92,6 @@ The variables in "ctx.Data" are different in each case, making this template fra
<div class="comment-header-right">
{{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}}
</div>
@@ -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}}
</div>
{{$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
}}
</div>
</div>
{{end}}
@@ -1,17 +0,0 @@
<div class="bottom-reactions" data-action-url="{{$.ActionURL}}" role="group" aria-label="{{ctx.Locale.Tr "repo.reactions"}}">
{{range $key, $value := .Reactions}}
{{$hasReacted := $value.HasUser ctx.RootData.SignedUserID}}
<a role="button" class="ui label basic{{if $hasReacted}} primary{{end}}{{if not ctx.RootData.IsSigned}} disabled{{end}}"
data-global-click="onCommentReactionButtonClick"
data-tooltip-content title="{{$value.GetFirstUsers}}{{if gt ($value.GetMoreUserCount) 0}} {{ctx.Locale.Tr "repo.reactions_more" $value.GetMoreUserCount}}{{end}}"
aria-label="{{$key}}: {{$value.GetFirstUsers}}{{if gt ($value.GetMoreUserCount) 0}} {{ctx.Locale.Tr "repo.reactions_more" $value.GetMoreUserCount}}{{end}}"
data-tooltip-placement="bottom-start"
data-reaction-content="{{$key}}" data-has-reacted="{{$hasReacted}}">
<span class="reaction">{{ReactionToEmoji $key}}</span>
<span class="reaction-count">{{len $value}}</span>
</a>
{{end}}
{{if AllowedReactions}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" .ActionURL}}
{{end}}
</div>
+7
View File
@@ -107,6 +107,13 @@
{{end}}
</ul>
</details>
{{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}}
</div>
</li>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{$allowedReactions := $.AllowedReactions}}
{{$canReact := and $allowedReactions ctx.RootData.IsSigned (not ctx.RootData.Repository.IsArchived)}}
{{if or .Reactions $canReact}}
<div class="bottom-reactions" data-action-url="{{$.ActionURL}}" role="group" aria-label="{{ctx.Locale.Tr "repo.reactions"}}">
{{if $canReact}}
<button type="button" class="select-reaction" data-global-click="onReactionPickerClick" aria-label="{{ctx.Locale.Tr "repo.reactions"}}">{{svg "octicon-smiley"}}</button>
<div class="tippy-target">
<div class="reaction-picker">
{{range $value := $allowedReactions}}
<a class="item emoji" role="menuitem" data-tooltip-content="{{$value}}" aria-label="{{$value}}" data-reaction-content="{{$value}}" data-has-reacted="{{(index $.Reactions $value).HasUser ctx.RootData.SignedUserID}}" data-global-click="onCommentReactionButtonClick">{{ReactionToEmoji $value}}</a>
{{end}}
</div>
</div>
{{end}}
{{range $key, $value := .Reactions}}
{{$hasReacted := $value.HasUser ctx.RootData.SignedUserID}}
<a role="button" class="ui label basic{{if not $canReact}} disabled{{end}}"
data-global-click="onCommentReactionButtonClick"
data-tooltip-content="{{$value.GetFirstUsers}}{{if gt ($value.GetMoreUserCount) 0}} {{ctx.Locale.Tr "repo.reactions_more" $value.GetMoreUserCount}}{{end}}"
aria-label="{{$key}}: {{$value.GetFirstUsers}}{{if gt ($value.GetMoreUserCount) 0}} {{ctx.Locale.Tr "repo.reactions_more" $value.GetMoreUserCount}}{{end}}"
data-tooltip-placement="bottom-start"
data-reaction-content="{{$key}}" data-has-reacted="{{$hasReacted}}">
<span class="reaction">{{ReactionToEmoji $key}}</span>
<span>{{len $value}}</span>
</a>
{{end}}
</div>
{{end}}
+10 -5
View File
@@ -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();
});
+27
View File
@@ -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)
}
}
+54 -36
View File
@@ -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);
}
+31 -18
View File
@@ -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<HTMLElement>('.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<HTMLElement>('.select-reaction')?.focus();
});
}
+3 -1
View File
@@ -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'});
+2 -2
View File
@@ -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 {