code.gitea.io/gitea@v1.22.3/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 // DeleteReaction deletes reaction for issue or comment. 244 func DeleteReaction(ctx context.Context, opts *ReactionOptions) error { 245 reaction := &Reaction{ 246 Type: opts.Type, 247 UserID: opts.DoerID, 248 IssueID: opts.IssueID, 249 CommentID: opts.CommentID, 250 } 251 252 sess := db.GetEngine(ctx).Where("original_author_id = 0") 253 if opts.CommentID == -1 { 254 reaction.CommentID = 0 255 sess.MustCols("comment_id") 256 } 257 258 _, err := sess.Delete(reaction) 259 return err 260 } 261 262 // DeleteIssueReaction deletes a reaction on issue. 263 func DeleteIssueReaction(ctx context.Context, doerID, issueID int64, content string) error { 264 return DeleteReaction(ctx, &ReactionOptions{ 265 Type: content, 266 DoerID: doerID, 267 IssueID: issueID, 268 CommentID: -1, 269 }) 270 } 271 272 // DeleteCommentReaction deletes a reaction on comment. 273 func DeleteCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) error { 274 return DeleteReaction(ctx, &ReactionOptions{ 275 Type: content, 276 DoerID: doerID, 277 IssueID: issueID, 278 CommentID: commentID, 279 }) 280 } 281 282 // ReactionList represents list of reactions 283 type ReactionList []*Reaction 284 285 // HasUser check if user has reacted 286 func (list ReactionList) HasUser(userID int64) bool { 287 if userID == 0 { 288 return false 289 } 290 for _, reaction := range list { 291 if reaction.OriginalAuthor == "" && reaction.UserID == userID { 292 return true 293 } 294 } 295 return false 296 } 297 298 // GroupByType returns reactions grouped by type 299 func (list ReactionList) GroupByType() map[string]ReactionList { 300 reactions := make(map[string]ReactionList) 301 for _, reaction := range list { 302 reactions[reaction.Type] = append(reactions[reaction.Type], reaction) 303 } 304 return reactions 305 } 306 307 func (list ReactionList) getUserIDs() []int64 { 308 return container.FilterSlice(list, func(reaction *Reaction) (int64, bool) { 309 if reaction.OriginalAuthor != "" { 310 return 0, false 311 } 312 return reaction.UserID, true 313 }) 314 } 315 316 func valuesUser(m map[int64]*user_model.User) []*user_model.User { 317 values := make([]*user_model.User, 0, len(m)) 318 for _, v := range m { 319 values = append(values, v) 320 } 321 return values 322 } 323 324 // LoadUsers loads reactions' all users 325 func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) ([]*user_model.User, error) { 326 if len(list) == 0 { 327 return nil, nil 328 } 329 330 userIDs := list.getUserIDs() 331 userMaps := make(map[int64]*user_model.User, len(userIDs)) 332 err := db.GetEngine(ctx). 333 In("id", userIDs). 334 Find(&userMaps) 335 if err != nil { 336 return nil, fmt.Errorf("find user: %w", err) 337 } 338 339 for _, reaction := range list { 340 if reaction.OriginalAuthor != "" { 341 reaction.User = user_model.NewReplaceUser(fmt.Sprintf("%s(%s)", reaction.OriginalAuthor, repo.OriginalServiceType.Name())) 342 } else if user, ok := userMaps[reaction.UserID]; ok { 343 reaction.User = user 344 } else { 345 reaction.User = user_model.NewGhostUser() 346 } 347 } 348 return valuesUser(userMaps), nil 349 } 350 351 // GetFirstUsers returns first reacted user display names separated by comma 352 func (list ReactionList) GetFirstUsers() string { 353 var buffer bytes.Buffer 354 rem := setting.UI.ReactionMaxUserNum 355 for _, reaction := range list { 356 if buffer.Len() > 0 { 357 buffer.WriteString(", ") 358 } 359 buffer.WriteString(reaction.User.Name) 360 if rem--; rem == 0 { 361 break 362 } 363 } 364 return buffer.String() 365 } 366 367 // GetMoreUserCount returns count of not shown users in reaction tooltip 368 func (list ReactionList) GetMoreUserCount() int { 369 if len(list) <= setting.UI.ReactionMaxUserNum { 370 return 0 371 } 372 return len(list) - setting.UI.ReactionMaxUserNum 373 }