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

+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
}
}