You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

reaction.go 11KB

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