code.gitea.io/gitea@v1.21.7/models/issues/reaction.go (about)

     1  // Copyright 2017 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package issues
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"fmt"
    10  
    11  	"code.gitea.io/gitea/models/db"
    12  	repo_model "code.gitea.io/gitea/models/repo"
    13  	user_model "code.gitea.io/gitea/models/user"
    14  	"code.gitea.io/gitea/modules/container"
    15  	"code.gitea.io/gitea/modules/setting"
    16  	"code.gitea.io/gitea/modules/timeutil"
    17  	"code.gitea.io/gitea/modules/util"
    18  
    19  	"xorm.io/builder"
    20  )
    21  
    22  // ErrForbiddenIssueReaction is used when a forbidden reaction was try to created
    23  type ErrForbiddenIssueReaction struct {
    24  	Reaction string
    25  }
    26  
    27  // IsErrForbiddenIssueReaction checks if an error is a ErrForbiddenIssueReaction.
    28  func IsErrForbiddenIssueReaction(err error) bool {
    29  	_, ok := err.(ErrForbiddenIssueReaction)
    30  	return ok
    31  }
    32  
    33  func (err ErrForbiddenIssueReaction) Error() string {
    34  	return fmt.Sprintf("'%s' is not an allowed reaction", err.Reaction)
    35  }
    36  
    37  func (err ErrForbiddenIssueReaction) Unwrap() error {
    38  	return util.ErrPermissionDenied
    39  }
    40  
    41  // ErrReactionAlreadyExist is used when a existing reaction was try to created
    42  type ErrReactionAlreadyExist struct {
    43  	Reaction string
    44  }
    45  
    46  // IsErrReactionAlreadyExist checks if an error is a ErrReactionAlreadyExist.
    47  func IsErrReactionAlreadyExist(err error) bool {
    48  	_, ok := err.(ErrReactionAlreadyExist)
    49  	return ok
    50  }
    51  
    52  func (err ErrReactionAlreadyExist) Error() string {
    53  	return fmt.Sprintf("reaction '%s' already exists", err.Reaction)
    54  }
    55  
    56  func (err ErrReactionAlreadyExist) Unwrap() error {
    57  	return util.ErrAlreadyExist
    58  }
    59  
    60  // Reaction represents a reactions on issues and comments.
    61  type Reaction struct {
    62  	ID               int64              `xorm:"pk autoincr"`
    63  	Type             string             `xorm:"INDEX UNIQUE(s) NOT NULL"`
    64  	IssueID          int64              `xorm:"INDEX UNIQUE(s) NOT NULL"`
    65  	CommentID        int64              `xorm:"INDEX UNIQUE(s)"`
    66  	UserID           int64              `xorm:"INDEX UNIQUE(s) NOT NULL"`
    67  	OriginalAuthorID int64              `xorm:"INDEX UNIQUE(s) NOT NULL DEFAULT(0)"`
    68  	OriginalAuthor   string             `xorm:"INDEX UNIQUE(s)"`
    69  	User             *user_model.User   `xorm:"-"`
    70  	CreatedUnix      timeutil.TimeStamp `xorm:"INDEX created"`
    71  }
    72  
    73  // LoadUser load user of reaction
    74  func (r *Reaction) LoadUser(ctx context.Context) (*user_model.User, error) {
    75  	if r.User != nil {
    76  		return r.User, nil
    77  	}
    78  	user, err := user_model.GetUserByID(ctx, r.UserID)
    79  	if err != nil {
    80  		return nil, err
    81  	}
    82  	r.User = user
    83  	return user, nil
    84  }
    85  
    86  // RemapExternalUser ExternalUserRemappable interface
    87  func (r *Reaction) RemapExternalUser(externalName string, externalID, userID int64) error {
    88  	r.OriginalAuthor = externalName
    89  	r.OriginalAuthorID = externalID
    90  	r.UserID = userID
    91  	return nil
    92  }
    93  
    94  // GetUserID ExternalUserRemappable interface
    95  func (r *Reaction) GetUserID() int64 { return r.UserID }
    96  
    97  // GetExternalName ExternalUserRemappable interface
    98  func (r *Reaction) GetExternalName() string { return r.OriginalAuthor }
    99  
   100  // GetExternalID ExternalUserRemappable interface
   101  func (r *Reaction) GetExternalID() int64 { return r.OriginalAuthorID }
   102  
   103  func init() {
   104  	db.RegisterModel(new(Reaction))
   105  }
   106  
   107  // FindReactionsOptions describes the conditions to Find reactions
   108  type FindReactionsOptions struct {
   109  	db.ListOptions
   110  	IssueID   int64
   111  	CommentID int64
   112  	UserID    int64
   113  	Reaction  string
   114  }
   115  
   116  func (opts *FindReactionsOptions) toConds() builder.Cond {
   117  	// If Issue ID is set add to Query
   118  	cond := builder.NewCond()
   119  	if opts.IssueID > 0 {
   120  		cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID})
   121  	}
   122  	// If CommentID is > 0 add to Query
   123  	// If it is 0 Query ignore CommentID to select
   124  	// If it is -1 it explicit search of Issue Reactions where CommentID = 0
   125  	if opts.CommentID > 0 {
   126  		cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID})
   127  	} else if opts.CommentID == -1 {
   128  		cond = cond.And(builder.Eq{"reaction.comment_id": 0})
   129  	}
   130  	if opts.UserID > 0 {
   131  		cond = cond.And(builder.Eq{
   132  			"reaction.user_id":            opts.UserID,
   133  			"reaction.original_author_id": 0,
   134  		})
   135  	}
   136  	if opts.Reaction != "" {
   137  		cond = cond.And(builder.Eq{"reaction.type": opts.Reaction})
   138  	}
   139  
   140  	return cond
   141  }
   142  
   143  // FindCommentReactions returns a ReactionList of all reactions from an comment
   144  func FindCommentReactions(ctx context.Context, issueID, commentID int64) (ReactionList, int64, error) {
   145  	return FindReactions(ctx, FindReactionsOptions{
   146  		IssueID:   issueID,
   147  		CommentID: commentID,
   148  	})
   149  }
   150  
   151  // FindIssueReactions returns a ReactionList of all reactions from an issue
   152  func FindIssueReactions(ctx context.Context, issueID int64, listOptions db.ListOptions) (ReactionList, int64, error) {
   153  	return FindReactions(ctx, FindReactionsOptions{
   154  		ListOptions: listOptions,
   155  		IssueID:     issueID,
   156  		CommentID:   -1,
   157  	})
   158  }
   159  
   160  // FindReactions returns a ReactionList of all reactions from an issue or a comment
   161  func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList, int64, error) {
   162  	sess := db.GetEngine(ctx).
   163  		Where(opts.toConds()).
   164  		In("reaction.`type`", setting.UI.Reactions).
   165  		Asc("reaction.issue_id", "reaction.comment_id", "reaction.created_unix", "reaction.id")
   166  	if opts.Page != 0 {
   167  		sess = db.SetSessionPagination(sess, &opts)
   168  
   169  		reactions := make([]*Reaction, 0, opts.PageSize)
   170  		count, err := sess.FindAndCount(&reactions)
   171  		return reactions, count, err
   172  	}
   173  
   174  	reactions := make([]*Reaction, 0, 10)
   175  	count, err := sess.FindAndCount(&reactions)
   176  	return reactions, count, err
   177  }
   178  
   179  func createReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
   180  	reaction := &Reaction{
   181  		Type:      opts.Type,
   182  		UserID:    opts.DoerID,
   183  		IssueID:   opts.IssueID,
   184  		CommentID: opts.CommentID,
   185  	}
   186  	findOpts := FindReactionsOptions{
   187  		IssueID:   opts.IssueID,
   188  		CommentID: opts.CommentID,
   189  		Reaction:  opts.Type,
   190  		UserID:    opts.DoerID,
   191  	}
   192  	if findOpts.CommentID == 0 {
   193  		// explicit search of Issue Reactions where CommentID = 0
   194  		findOpts.CommentID = -1
   195  	}
   196  
   197  	existingR, _, err := FindReactions(ctx, findOpts)
   198  	if err != nil {
   199  		return nil, err
   200  	}
   201  	if len(existingR) > 0 {
   202  		return existingR[0], ErrReactionAlreadyExist{Reaction: opts.Type}
   203  	}
   204  
   205  	if err := db.Insert(ctx, reaction); err != nil {
   206  		return nil, err
   207  	}
   208  
   209  	return reaction, nil
   210  }
   211  
   212  // ReactionOptions defines options for creating or deleting reactions
   213  type ReactionOptions struct {
   214  	Type      string
   215  	DoerID    int64
   216  	IssueID   int64
   217  	CommentID int64
   218  }
   219  
   220  // CreateReaction creates reaction for issue or comment.
   221  func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
   222  	if !setting.UI.ReactionsLookup.Contains(opts.Type) {
   223  		return nil, ErrForbiddenIssueReaction{opts.Type}
   224  	}
   225  
   226  	ctx, committer, err := db.TxContext(ctx)
   227  	if err != nil {
   228  		return nil, err
   229  	}
   230  	defer committer.Close()
   231  
   232  	reaction, err := createReaction(ctx, opts)
   233  	if err != nil {
   234  		return reaction, err
   235  	}
   236  
   237  	if err := committer.Commit(); err != nil {
   238  		return nil, err
   239  	}
   240  	return reaction, nil
   241  }
   242  
   243  // CreateIssueReaction creates a reaction on issue.
   244  func CreateIssueReaction(ctx context.Context, doerID, issueID int64, content string) (*Reaction, error) {
   245  	return CreateReaction(ctx, &ReactionOptions{
   246  		Type:    content,
   247  		DoerID:  doerID,
   248  		IssueID: issueID,
   249  	})
   250  }
   251  
   252  // CreateCommentReaction creates a reaction on comment.
   253  func CreateCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) (*Reaction, error) {
   254  	return CreateReaction(ctx, &ReactionOptions{
   255  		Type:      content,
   256  		DoerID:    doerID,
   257  		IssueID:   issueID,
   258  		CommentID: commentID,
   259  	})
   260  }
   261  
   262  // DeleteReaction deletes reaction for issue or comment.
   263  func DeleteReaction(ctx context.Context, opts *ReactionOptions) error {
   264  	reaction := &Reaction{
   265  		Type:      opts.Type,
   266  		UserID:    opts.DoerID,
   267  		IssueID:   opts.IssueID,
   268  		CommentID: opts.CommentID,
   269  	}
   270  
   271  	sess := db.GetEngine(ctx).Where("original_author_id = 0")
   272  	if opts.CommentID == -1 {
   273  		reaction.CommentID = 0
   274  		sess.MustCols("comment_id")
   275  	}
   276  
   277  	_, err := sess.Delete(reaction)
   278  	return err
   279  }
   280  
   281  // DeleteIssueReaction deletes a reaction on issue.
   282  func DeleteIssueReaction(ctx context.Context, doerID, issueID int64, content string) error {
   283  	return DeleteReaction(ctx, &ReactionOptions{
   284  		Type:      content,
   285  		DoerID:    doerID,
   286  		IssueID:   issueID,
   287  		CommentID: -1,
   288  	})
   289  }
   290  
   291  // DeleteCommentReaction deletes a reaction on comment.
   292  func DeleteCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) error {
   293  	return DeleteReaction(ctx, &ReactionOptions{
   294  		Type:      content,
   295  		DoerID:    doerID,
   296  		IssueID:   issueID,
   297  		CommentID: commentID,
   298  	})
   299  }
   300  
   301  // ReactionList represents list of reactions
   302  type ReactionList []*Reaction
   303  
   304  // HasUser check if user has reacted
   305  func (list ReactionList) HasUser(userID int64) bool {
   306  	if userID == 0 {
   307  		return false
   308  	}
   309  	for _, reaction := range list {
   310  		if reaction.OriginalAuthor == "" && reaction.UserID == userID {
   311  			return true
   312  		}
   313  	}
   314  	return false
   315  }
   316  
   317  // GroupByType returns reactions grouped by type
   318  func (list ReactionList) GroupByType() map[string]ReactionList {
   319  	reactions := make(map[string]ReactionList)
   320  	for _, reaction := range list {
   321  		reactions[reaction.Type] = append(reactions[reaction.Type], reaction)
   322  	}
   323  	return reactions
   324  }
   325  
   326  func (list ReactionList) getUserIDs() []int64 {
   327  	userIDs := make(container.Set[int64], len(list))
   328  	for _, reaction := range list {
   329  		if reaction.OriginalAuthor != "" {
   330  			continue
   331  		}
   332  		userIDs.Add(reaction.UserID)
   333  	}
   334  	return userIDs.Values()
   335  }
   336  
   337  func valuesUser(m map[int64]*user_model.User) []*user_model.User {
   338  	values := make([]*user_model.User, 0, len(m))
   339  	for _, v := range m {
   340  		values = append(values, v)
   341  	}
   342  	return values
   343  }
   344  
   345  // LoadUsers loads reactions' all users
   346  func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) ([]*user_model.User, error) {
   347  	if len(list) == 0 {
   348  		return nil, nil
   349  	}
   350  
   351  	userIDs := list.getUserIDs()
   352  	userMaps := make(map[int64]*user_model.User, len(userIDs))
   353  	err := db.GetEngine(ctx).
   354  		In("id", userIDs).
   355  		Find(&userMaps)
   356  	if err != nil {
   357  		return nil, fmt.Errorf("find user: %w", err)
   358  	}
   359  
   360  	for _, reaction := range list {
   361  		if reaction.OriginalAuthor != "" {
   362  			reaction.User = user_model.NewReplaceUser(fmt.Sprintf("%s(%s)", reaction.OriginalAuthor, repo.OriginalServiceType.Name()))
   363  		} else if user, ok := userMaps[reaction.UserID]; ok {
   364  			reaction.User = user
   365  		} else {
   366  			reaction.User = user_model.NewGhostUser()
   367  		}
   368  	}
   369  	return valuesUser(userMaps), nil
   370  }
   371  
   372  // GetFirstUsers returns first reacted user display names separated by comma
   373  func (list ReactionList) GetFirstUsers() string {
   374  	var buffer bytes.Buffer
   375  	rem := setting.UI.ReactionMaxUserNum
   376  	for _, reaction := range list {
   377  		if buffer.Len() > 0 {
   378  			buffer.WriteString(", ")
   379  		}
   380  		buffer.WriteString(reaction.User.Name)
   381  		if rem--; rem == 0 {
   382  			break
   383  		}
   384  	}
   385  	return buffer.String()
   386  }
   387  
   388  // GetMoreUserCount returns count of not shown users in reaction tooltip
   389  func (list ReactionList) GetMoreUserCount() int {
   390  	if len(list) <= setting.UI.ReactionMaxUserNum {
   391  		return 0
   392  	}
   393  	return len(list) - setting.UI.ReactionMaxUserNum
   394  }