Skip to content

Commit 5dc37b1

Browse files
authored
Add reactions to issues/PR and comments (#2856)
1 parent e59adcd commit 5dc37b1

File tree

24 files changed

+677
-8
lines changed

24 files changed

+677
-8
lines changed

docs/content/page/index.en-us.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ The goal of this project is to make the easiest, fastest, and most painless way
182182
- Labels
183183
- Assign issues
184184
- Track time
185+
- Reactions
185186
- Filter
186187
- Open
187188
- Closed

models/fixtures/reaction.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[] # empty

models/helper.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,11 @@ func valuesRepository(m map[int64]*Repository) []*Repository {
1919
}
2020
return values
2121
}
22+
23+
func valuesUser(m map[int64]*User) []*User {
24+
var values = make([]*User, 0, len(m))
25+
for _, v := range m {
26+
values = append(values, v)
27+
}
28+
return values
29+
}

models/issue.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type Issue struct {
5454

5555
Attachments []*Attachment `xorm:"-"`
5656
Comments []*Comment `xorm:"-"`
57+
Reactions ReactionList `xorm:"-"`
5758
}
5859

5960
// BeforeUpdate is invoked from XORM before updating this object.
@@ -155,6 +156,37 @@ func (issue *Issue) loadComments(e Engine) (err error) {
155156
return err
156157
}
157158

159+
func (issue *Issue) loadReactions(e Engine) (err error) {
160+
if issue.Reactions != nil {
161+
return nil
162+
}
163+
reactions, err := findReactions(e, FindReactionsOptions{
164+
IssueID: issue.ID,
165+
})
166+
if err != nil {
167+
return err
168+
}
169+
// Load reaction user data
170+
if _, err := ReactionList(reactions).LoadUsers(); err != nil {
171+
return err
172+
}
173+
174+
// Cache comments to map
175+
comments := make(map[int64]*Comment)
176+
for _, comment := range issue.Comments {
177+
comments[comment.ID] = comment
178+
}
179+
// Add reactions either to issue or comment
180+
for _, react := range reactions {
181+
if react.CommentID == 0 {
182+
issue.Reactions = append(issue.Reactions, react)
183+
} else if comment, ok := comments[react.CommentID]; ok {
184+
comment.Reactions = append(comment.Reactions, react)
185+
}
186+
}
187+
return nil
188+
}
189+
158190
func (issue *Issue) loadAttributes(e Engine) (err error) {
159191
if err = issue.loadRepo(e); err != nil {
160192
return
@@ -192,10 +224,10 @@ func (issue *Issue) loadAttributes(e Engine) (err error) {
192224
}
193225

194226
if err = issue.loadComments(e); err != nil {
195-
return
227+
return err
196228
}
197229

198-
return nil
230+
return issue.loadReactions(e)
199231
}
200232

201233
// LoadAttributes loads the attribute of this issue.

models/issue_comment.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ type Comment struct {
107107
CommitSHA string `xorm:"VARCHAR(40)"`
108108

109109
Attachments []*Attachment `xorm:"-"`
110+
Reactions ReactionList `xorm:"-"`
110111

111112
// For view issue page.
112113
ShowTag CommentTag `xorm:"-"`
@@ -287,6 +288,29 @@ func (c *Comment) MailParticipants(e Engine, opType ActionType, issue *Issue) (e
287288
return nil
288289
}
289290

291+
func (c *Comment) loadReactions(e Engine) (err error) {
292+
if c.Reactions != nil {
293+
return nil
294+
}
295+
c.Reactions, err = findReactions(e, FindReactionsOptions{
296+
IssueID: c.IssueID,
297+
CommentID: c.ID,
298+
})
299+
if err != nil {
300+
return err
301+
}
302+
// Load reaction user data
303+
if _, err := c.Reactions.LoadUsers(); err != nil {
304+
return err
305+
}
306+
return nil
307+
}
308+
309+
// LoadReactions loads comment reactions
310+
func (c *Comment) LoadReactions() error {
311+
return c.loadReactions(x)
312+
}
313+
290314
func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err error) {
291315
var LabelID int64
292316
if opts.Label != nil {

models/issue_reaction.go

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright 2017 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package models
6+
7+
import (
8+
"bytes"
9+
"fmt"
10+
"time"
11+
12+
"github.com/go-xorm/builder"
13+
"github.com/go-xorm/xorm"
14+
15+
"code.gitea.io/gitea/modules/setting"
16+
)
17+
18+
// Reaction represents a reactions on issues and comments.
19+
type Reaction struct {
20+
ID int64 `xorm:"pk autoincr"`
21+
Type string `xorm:"INDEX UNIQUE(s) NOT NULL"`
22+
IssueID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
23+
CommentID int64 `xorm:"INDEX UNIQUE(s)"`
24+
UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
25+
User *User `xorm:"-"`
26+
Created time.Time `xorm:"-"`
27+
CreatedUnix int64 `xorm:"INDEX created"`
28+
}
29+
30+
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
31+
func (s *Reaction) AfterLoad() {
32+
s.Created = time.Unix(s.CreatedUnix, 0).Local()
33+
}
34+
35+
// FindReactionsOptions describes the conditions to Find reactions
36+
type FindReactionsOptions struct {
37+
IssueID int64
38+
CommentID int64
39+
}
40+
41+
func (opts *FindReactionsOptions) toConds() builder.Cond {
42+
var cond = builder.NewCond()
43+
if opts.IssueID > 0 {
44+
cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID})
45+
}
46+
if opts.CommentID > 0 {
47+
cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID})
48+
}
49+
return cond
50+
}
51+
52+
func findReactions(e Engine, opts FindReactionsOptions) ([]*Reaction, error) {
53+
reactions := make([]*Reaction, 0, 10)
54+
sess := e.Where(opts.toConds())
55+
return reactions, sess.
56+
Asc("reaction.issue_id", "reaction.comment_id", "reaction.created_unix", "reaction.id").
57+
Find(&reactions)
58+
}
59+
60+
func createReaction(e *xorm.Session, opts *ReactionOptions) (*Reaction, error) {
61+
reaction := &Reaction{
62+
Type: opts.Type,
63+
UserID: opts.Doer.ID,
64+
IssueID: opts.Issue.ID,
65+
}
66+
if opts.Comment != nil {
67+
reaction.CommentID = opts.Comment.ID
68+
}
69+
if _, err := e.Insert(reaction); err != nil {
70+
return nil, err
71+
}
72+
73+
return reaction, nil
74+
}
75+
76+
// ReactionOptions defines options for creating or deleting reactions
77+
type ReactionOptions struct {
78+
Type string
79+
Doer *User
80+
Issue *Issue
81+
Comment *Comment
82+
}
83+
84+
// CreateReaction creates reaction for issue or comment.
85+
func CreateReaction(opts *ReactionOptions) (reaction *Reaction, err error) {
86+
sess := x.NewSession()
87+
defer sess.Close()
88+
if err = sess.Begin(); err != nil {
89+
return nil, err
90+
}
91+
92+
reaction, err = createReaction(sess, opts)
93+
if err != nil {
94+
return nil, err
95+
}
96+
97+
if err = sess.Commit(); err != nil {
98+
return nil, err
99+
}
100+
return reaction, nil
101+
}
102+
103+
// CreateIssueReaction creates a reaction on issue.
104+
func CreateIssueReaction(doer *User, issue *Issue, content string) (*Reaction, error) {
105+
return CreateReaction(&ReactionOptions{
106+
Type: content,
107+
Doer: doer,
108+
Issue: issue,
109+
})
110+
}
111+
112+
// CreateCommentReaction creates a reaction on comment.
113+
func CreateCommentReaction(doer *User, issue *Issue, comment *Comment, content string) (*Reaction, error) {
114+
return CreateReaction(&ReactionOptions{
115+
Type: content,
116+
Doer: doer,
117+
Issue: issue,
118+
Comment: comment,
119+
})
120+
}
121+
122+
func deleteReaction(e *xorm.Session, opts *ReactionOptions) error {
123+
reaction := &Reaction{
124+
Type: opts.Type,
125+
UserID: opts.Doer.ID,
126+
IssueID: opts.Issue.ID,
127+
}
128+
if opts.Comment != nil {
129+
reaction.CommentID = opts.Comment.ID
130+
}
131+
_, err := e.Delete(reaction)
132+
return err
133+
}
134+
135+
// DeleteReaction deletes reaction for issue or comment.
136+
func DeleteReaction(opts *ReactionOptions) error {
137+
sess := x.NewSession()
138+
defer sess.Close()
139+
if err := sess.Begin(); err != nil {
140+
return err
141+
}
142+
143+
if err := deleteReaction(sess, opts); err != nil {
144+
return err
145+
}
146+
147+
return sess.Commit()
148+
}
149+
150+
// DeleteIssueReaction deletes a reaction on issue.
151+
func DeleteIssueReaction(doer *User, issue *Issue, content string) error {
152+
return DeleteReaction(&ReactionOptions{
153+
Type: content,
154+
Doer: doer,
155+
Issue: issue,
156+
})
157+
}
158+
159+
// DeleteCommentReaction deletes a reaction on comment.
160+
func DeleteCommentReaction(doer *User, issue *Issue, comment *Comment, content string) error {
161+
return DeleteReaction(&ReactionOptions{
162+
Type: content,
163+
Doer: doer,
164+
Issue: issue,
165+
Comment: comment,
166+
})
167+
}
168+
169+
// ReactionList represents list of reactions
170+
type ReactionList []*Reaction
171+
172+
// HasUser check if user has reacted
173+
func (list ReactionList) HasUser(userID int64) bool {
174+
if userID == 0 {
175+
return false
176+
}
177+
for _, reaction := range list {
178+
if reaction.UserID == userID {
179+
return true
180+
}
181+
}
182+
return false
183+
}
184+
185+
// GroupByType returns reactions grouped by type
186+
func (list ReactionList) GroupByType() map[string]ReactionList {
187+
var reactions = make(map[string]ReactionList)
188+
for _, reaction := range list {
189+
reactions[reaction.Type] = append(reactions[reaction.Type], reaction)
190+
}
191+
return reactions
192+
}
193+
194+
func (list ReactionList) getUserIDs() []int64 {
195+
userIDs := make(map[int64]struct{}, len(list))
196+
for _, reaction := range list {
197+
if _, ok := userIDs[reaction.UserID]; !ok {
198+
userIDs[reaction.UserID] = struct{}{}
199+
}
200+
}
201+
return keysInt64(userIDs)
202+
}
203+
204+
func (list ReactionList) loadUsers(e Engine) ([]*User, error) {
205+
if len(list) == 0 {
206+
return nil, nil
207+
}
208+
209+
userIDs := list.getUserIDs()
210+
userMaps := make(map[int64]*User, len(userIDs))
211+
err := e.
212+
In("id", userIDs).
213+
Find(&userMaps)
214+
if err != nil {
215+
return nil, fmt.Errorf("find user: %v", err)
216+
}
217+
218+
for _, reaction := range list {
219+
if user, ok := userMaps[reaction.UserID]; ok {
220+
reaction.User = user
221+
} else {
222+
reaction.User = NewGhostUser()
223+
}
224+
}
225+
return valuesUser(userMaps), nil
226+
}
227+
228+
// LoadUsers loads reactions' all users
229+
func (list ReactionList) LoadUsers() ([]*User, error) {
230+
return list.loadUsers(x)
231+
}
232+
233+
// GetFirstUsers returns first reacted user display names separated by comma
234+
func (list ReactionList) GetFirstUsers() string {
235+
var buffer bytes.Buffer
236+
var rem = setting.UI.ReactionMaxUserNum
237+
for _, reaction := range list {
238+
if buffer.Len() > 0 {
239+
buffer.WriteString(", ")
240+
}
241+
buffer.WriteString(reaction.User.DisplayName())
242+
if rem--; rem == 0 {
243+
break
244+
}
245+
}
246+
return buffer.String()
247+
}
248+
249+
// GetMoreUserCount returns count of not shown users in reaction tooltip
250+
func (list ReactionList) GetMoreUserCount() int {
251+
if len(list) <= setting.UI.ReactionMaxUserNum {
252+
return 0
253+
}
254+
return len(list) - setting.UI.ReactionMaxUserNum
255+
}

models/migrations/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ var migrations = []Migration{
148148
NewMigration("add repo indexer status", addRepoIndexerStatus),
149149
// v49 -> v50
150150
NewMigration("add lfs lock table", addLFSLock),
151+
// v50 -> v51
152+
NewMigration("add reactions", addReactions),
151153
}
152154

153155
// Migrate database to current version

0 commit comments

Comments
 (0)