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 }