mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-23 12:23:43 +02: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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user