mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-26 06:16:56 +09:00
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:
42 files changed
+508
-313
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user