Browse Source

Add reactions to issues/PR and comments (#2856)

Lauris BH 1 year ago
parent
commit
5dc37b187c

+ 1 - 0
docs/content/page/index.en-us.md

@@ -182,6 +182,7 @@ The goal of this project is to make the easiest, fastest, and most painless way
182 182
         - Labels
183 183
         - Assign issues
184 184
         - Track time
185
+        - Reactions
185 186
         - Filter
186 187
             - Open
187 188
             - Closed

+ 1 - 0
models/fixtures/reaction.yml

@@ -0,0 +1 @@
1
+[] # empty

+ 8 - 0
models/helper.go

@@ -19,3 +19,11 @@ func valuesRepository(m map[int64]*Repository) []*Repository {
19 19
 	}
20 20
 	return values
21 21
 }
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
+}

+ 34 - 2
models/issue.go

@@ -54,6 +54,7 @@ type Issue struct {
54 54
 
55 55
 	Attachments []*Attachment `xorm:"-"`
56 56
 	Comments    []*Comment    `xorm:"-"`
57
+	Reactions   ReactionList  `xorm:"-"`
57 58
 }
58 59
 
59 60
 // BeforeUpdate is invoked from XORM before updating this object.
@@ -155,6 +156,37 @@ func (issue *Issue) loadComments(e Engine) (err error) {
155 156
 	return err
156 157
 }
157 158
 
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
+
158 190
 func (issue *Issue) loadAttributes(e Engine) (err error) {
159 191
 	if err = issue.loadRepo(e); err != nil {
160 192
 		return
@@ -192,10 +224,10 @@ func (issue *Issue) loadAttributes(e Engine) (err error) {
192 224
 	}
193 225
 
194 226
 	if err = issue.loadComments(e); err != nil {
195
-		return
227
+		return err
196 228
 	}
197 229
 
198
-	return nil
230
+	return issue.loadReactions(e)
199 231
 }
200 232
 
201 233
 // LoadAttributes loads the attribute of this issue.

+ 24 - 0
models/issue_comment.go

@@ -107,6 +107,7 @@ type Comment struct {
107 107
 	CommitSHA string `xorm:"VARCHAR(40)"`
108 108
 
109 109
 	Attachments []*Attachment `xorm:"-"`
110
+	Reactions   ReactionList  `xorm:"-"`
110 111
 
111 112
 	// For view issue page.
112 113
 	ShowTag CommentTag `xorm:"-"`
@@ -287,6 +288,29 @@ func (c *Comment) MailParticipants(e Engine, opType ActionType, issue *Issue) (e
287 288
 	return nil
288 289
 }
289 290
 
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
+
290 314
 func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err error) {
291 315
 	var LabelID int64
292 316
 	if opts.Label != nil {

+ 255 - 0
models/issue_reaction.go

@@ -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
+}

+ 2 - 0
models/migrations/migrations.go

@@ -148,6 +148,8 @@ var migrations = []Migration{
148 148
 	NewMigration("add repo indexer status", addRepoIndexerStatus),
149 149
 	// v49 -> v50
150 150
 	NewMigration("add lfs lock table", addLFSLock),
151
+	// v50 -> v51
152
+	NewMigration("add reactions", addReactions),
151 153
 }
152 154
 
153 155
 // Migrate database to current version

+ 28 - 0
models/migrations/v50.go

@@ -0,0 +1,28 @@
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 migrations
6
+
7
+import (
8
+	"fmt"
9
+
10
+	"github.com/go-xorm/xorm"
11
+)
12
+
13
+func addReactions(x *xorm.Engine) error {
14
+	// Reaction see models/issue_reaction.go
15
+	type Reaction struct {
16
+		ID          int64  `xorm:"pk autoincr"`
17
+		Type        string `xorm:"INDEX UNIQUE(s) NOT NULL"`
18
+		IssueID     int64  `xorm:"INDEX UNIQUE(s) NOT NULL"`
19
+		CommentID   int64  `xorm:"INDEX UNIQUE(s)"`
20
+		UserID      int64  `xorm:"INDEX UNIQUE(s) NOT NULL"`
21
+		CreatedUnix int64  `xorm:"INDEX created"`
22
+	}
23
+
24
+	if err := x.Sync2(new(Reaction)); err != nil {
25
+		return fmt.Errorf("Sync2: %v", err)
26
+	}
27
+	return nil
28
+}

+ 1 - 0
models/models.go

@@ -118,6 +118,7 @@ func init() {
118 118
 		new(DeletedBranch),
119 119
 		new(RepoIndexerStatus),
120 120
 		new(LFSLock),
121
+		new(Reaction),
121 122
 	)
122 123
 
123 124
 	gonicNames := []string{"SSL", "UID"}

+ 1 - 0
models/user.go

@@ -980,6 +980,7 @@ func deleteUser(e *xorm.Session, u *User) error {
980 980
 		&IssueUser{UID: u.ID},
981 981
 		&EmailAddress{UID: u.ID},
982 982
 		&UserOpenID{UID: u.ID},
983
+		&Reaction{UserID: u.ID},
983 984
 	); err != nil {
984 985
 		return fmt.Errorf("deleteBeans: %v", err)
985 986
 	}

+ 10 - 0
modules/auth/repo_form.go

@@ -268,6 +268,16 @@ func (f *CreateCommentForm) Validate(ctx *macaron.Context, errs binding.Errors)
268 268
 	return validate(errs, ctx.Data, f, ctx.Locale)
269 269
 }
270 270
 
271
+// ReactionForm form for adding and removing reaction
272
+type ReactionForm struct {
273
+	Content string `binding:"Required;In(+1,-1,laugh,confused,heart,hooray)"`
274
+}
275
+
276
+// Validate validates the fields
277
+func (f *ReactionForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
278
+	return validate(errs, ctx.Data, f, ctx.Locale)
279
+}
280
+
271 281
 //    _____  .__.__                   __
272 282
 //   /     \ |__|  |   ____   _______/  |_  ____   ____   ____
273 283
 //  /  \ /  \|  |  | _/ __ \ /  ___/\   __\/  _ \ /    \_/ __ \

+ 1 - 1
modules/context/context.go

@@ -211,7 +211,7 @@ func Contexter() macaron.Handler {
211 211
 			ctx.Data["SignedUserName"] = ctx.User.Name
212 212
 			ctx.Data["IsAdmin"] = ctx.User.IsAdmin
213 213
 		} else {
214
-			ctx.Data["SignedUserID"] = 0
214
+			ctx.Data["SignedUserID"] = int64(0)
215 215
 			ctx.Data["SignedUserName"] = ""
216 216
 		}
217 217
 

+ 2 - 0
modules/setting/setting.go

@@ -256,6 +256,7 @@ var (
256 256
 		IssuePagingNum      int
257 257
 		RepoSearchPagingNum int
258 258
 		FeedMaxCommitNum    int
259
+		ReactionMaxUserNum  int
259 260
 		ThemeColorMetaTag   string
260 261
 		MaxDisplayFileSize  int64
261 262
 		ShowUserEmail       bool
@@ -279,6 +280,7 @@ var (
279 280
 		IssuePagingNum:      10,
280 281
 		RepoSearchPagingNum: 10,
281 282
 		FeedMaxCommitNum:    5,
283
+		ReactionMaxUserNum:  10,
282 284
 		ThemeColorMetaTag:   `#6cc644`,
283 285
 		MaxDisplayFileSize:  8388608,
284 286
 		Admin: struct {

+ 16 - 0
modules/templates/helper.go

@@ -8,6 +8,7 @@ import (
8 8
 	"bytes"
9 9
 	"container/list"
10 10
 	"encoding/json"
11
+	"errors"
11 12
 	"fmt"
12 13
 	"html/template"
13 14
 	"mime"
@@ -162,6 +163,21 @@ func NewFuncMap() []template.FuncMap {
162 163
 			return setting.DisableGitHooks
163 164
 		},
164 165
 		"TrN": TrN,
166
+		"Dict": func(values ...interface{}) (map[string]interface{}, error) {
167
+			if len(values)%2 != 0 {
168
+				return nil, errors.New("invalid dict call")
169
+			}
170
+			dict := make(map[string]interface{}, len(values)/2)
171
+			for i := 0; i < len(values); i += 2 {
172
+				key, ok := values[i].(string)
173
+				if !ok {
174
+					return nil, errors.New("dict keys must be strings")
175
+				}
176
+				dict[key] = values[i+1]
177
+			}
178
+			return dict, nil
179
+		},
180
+		"Printf": fmt.Sprintf,
165 181
 	}}
166 182
 }
167 183
 

+ 2 - 0
options/locale/locale_en-US.ini

@@ -489,6 +489,8 @@ mirror_last_synced = Last Synced
489 489
 watchers = Watchers
490 490
 stargazers = Stargazers
491 491
 forks = Forks
492
+pick_reaction = Pick your reaction
493
+reactions_more = and %d more
492 494
 
493 495
 form.reach_limit_of_creation = You have already reached your limit of %d repositories.
494 496
 form.name_reserved = The repository name '%s' is reserved.

File diff suppressed because it is too large
+ 1 - 1
public/css/index.css


+ 49 - 0
public/js/index.js

@@ -117,6 +117,54 @@ function updateIssuesMeta(url, action, issueIds, elementId, afterSuccess) {
117 117
     })
118 118
 }
119 119
 
120
+function initReactionSelector(parent) {
121
+    var reactions = '';
122
+    if (!parent) {
123
+        parent = $(document);
124
+        reactions = '.reactions > ';
125
+    }
126
+
127
+    parent.find(reactions + 'a.label').popup({'position': 'bottom left', 'metadata': {'content': 'title', 'title': 'none'}});
128
+
129
+    parent.find('.select-reaction > .menu > .item, ' + reactions + 'a.label').on('click', function(e){
130
+        var vm = this;
131
+        e.preventDefault();
132
+
133
+        if ($(this).hasClass('disabled')) return;
134
+
135
+        var actionURL = $(this).hasClass('item') ?
136
+                $(this).closest('.select-reaction').data('action-url') :
137
+                $(this).data('action-url');
138
+        var url = actionURL + '/' + ($(this).hasClass('blue') ? 'unreact' : 'react');
139
+        $.ajax({
140
+            type: 'POST',
141
+            url: url,
142
+            data: {
143
+                '_csrf': csrf,
144
+                'content': $(this).data('content')
145
+            }
146
+        }).done(function(resp) {
147
+            if (resp && (resp.html || resp.empty)) {
148
+                var content = $(vm).closest('.content');
149
+                var react = content.find('.segment.reactions');
150
+                if (react.length > 0) {
151
+                    react.remove();
152
+                }
153
+                if (!resp.empty) {
154
+                    react = $('<div class="ui attached segment reactions"></div>').appendTo(content);
155
+                    react.html(resp.html);
156
+                    var hasEmoji = react.find('.has-emoji');
157
+                    for (var i = 0; i < hasEmoji.length; i++) {
158
+                        emojify.run(hasEmoji.get(i));
159
+                    }
160
+                    react.find('.dropdown').dropdown();
161
+                    initReactionSelector(react);
162
+                }
163
+            }
164
+        });
165
+    });
166
+}
167
+
120 168
 function initCommentForm() {
121 169
     if ($('.comment.form').length == 0) {
122 170
         return
@@ -594,6 +642,7 @@ function initRepository() {
594 642
             $('#status').val($statusButton.data('status-val'));
595 643
             $('#comment-form').submit();
596 644
         });
645
+        initReactionSelector();
597 646
     }
598 647
 
599 648
     // Diff

+ 38 - 1
public/less/_repository.less

@@ -548,7 +548,7 @@
548 548
                 }
549 549
                 .content {
550 550
                     margin-left: 4em;
551
-                    .header {
551
+                    > .header {
552 552
                         #avatar-arrow;
553 553
                         font-weight: normal;
554 554
                         padding: auto 15px;
@@ -1350,6 +1350,43 @@
1350 1350
             }
1351 1351
         }
1352 1352
     }
1353
+    .segment.reactions, .select-reaction {
1354
+        &.dropdown .menu {
1355
+            right: 0!important;
1356
+            left: auto!important;
1357
+            > .header {
1358
+                margin: 0.75rem 0 .5rem;
1359
+            }
1360
+            > .item {
1361
+                float: left;
1362
+                padding: .5rem .5rem !important;
1363
+                img.emoji {
1364
+                    margin-right: 0;
1365
+                }
1366
+            }
1367
+        }
1368
+    }
1369
+    .segment.reactions {
1370
+        padding: .3em 1em;
1371
+        .ui.label {
1372
+            padding: .4em;
1373
+            &.disabled {
1374
+                cursor: default;
1375
+            }
1376
+            > img {
1377
+                height: 1.5em !important;
1378
+            }
1379
+        }
1380
+        .select-reaction {
1381
+            float: none;
1382
+            &:not(.active) a {
1383
+                display: none;
1384
+            }
1385
+        }
1386
+        &:hover .select-reaction a {
1387
+            display: block;
1388
+        }
1389
+    }
1353 1390
 }
1354 1391
 // End of .repository
1355 1392
 

+ 154 - 3
routers/repo/issue.go

@@ -39,6 +39,8 @@ const (
39 39
 	tplMilestoneNew  base.TplName = "repo/issue/milestone_new"
40 40
 	tplMilestoneEdit base.TplName = "repo/issue/milestone_edit"
41 41
 
42
+	tplReactions base.TplName = "repo/issue/view_content/reactions"
43
+
42 44
 	issueTemplateKey = "IssueTemplate"
43 45
 )
44 46
 
@@ -726,9 +728,8 @@ func GetActionIssue(ctx *context.Context) *models.Issue {
726 728
 		ctx.NotFoundOrServerError("GetIssueByIndex", models.IsErrIssueNotExist, err)
727 729
 		return nil
728 730
 	}
729
-	if issue.IsPull && !ctx.Repo.Repository.UnitEnabled(models.UnitTypePullRequests) ||
730
-		!issue.IsPull && !ctx.Repo.Repository.UnitEnabled(models.UnitTypeIssues) {
731
-		ctx.Handle(404, "IssueOrPullRequestUnitNotAllowed", nil)
731
+	checkIssueRights(ctx, issue)
732
+	if ctx.Written() {
732 733
 		return nil
733 734
 	}
734 735
 	if err = issue.LoadAttributes(); err != nil {
@@ -738,6 +739,13 @@ func GetActionIssue(ctx *context.Context) *models.Issue {
738 739
 	return issue
739 740
 }
740 741
 
742
+func checkIssueRights(ctx *context.Context, issue *models.Issue) {
743
+	if issue.IsPull && !ctx.Repo.Repository.UnitEnabled(models.UnitTypePullRequests) ||
744
+		!issue.IsPull && !ctx.Repo.Repository.UnitEnabled(models.UnitTypeIssues) {
745
+		ctx.Handle(404, "IssueOrPullRequestUnitNotAllowed", nil)
746
+	}
747
+}
748
+
741 749
 func getActionIssues(ctx *context.Context) []*models.Issue {
742 750
 	commaSeparatedIssueIDs := ctx.Query("issue_ids")
743 751
 	if len(commaSeparatedIssueIDs) == 0 {
@@ -1259,3 +1267,146 @@ func DeleteMilestone(ctx *context.Context) {
1259 1267
 		"redirect": ctx.Repo.RepoLink + "/milestones",
1260 1268
 	})
1261 1269
 }
1270
+
1271
+// ChangeIssueReaction create a reaction for issue
1272
+func ChangeIssueReaction(ctx *context.Context, form auth.ReactionForm) {
1273
+	issue := GetActionIssue(ctx)
1274
+	if ctx.Written() {
1275
+		return
1276
+	}
1277
+
1278
+	if ctx.HasError() {
1279
+		ctx.Handle(500, "ChangeIssueReaction", errors.New(ctx.GetErrMsg()))
1280
+		return
1281
+	}
1282
+
1283
+	switch ctx.Params(":action") {
1284
+	case "react":
1285
+		reaction, err := models.CreateIssueReaction(ctx.User, issue, form.Content)
1286
+		if err != nil {
1287
+			log.Info("CreateIssueReaction: %s", err)
1288
+			break
1289
+		}
1290
+		// Reload new reactions
1291
+		issue.Reactions = nil
1292
+		if err = issue.LoadAttributes(); err != nil {
1293
+			log.Info("issue.LoadAttributes: %s", err)
1294
+			break
1295
+		}
1296
+
1297
+		log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID)
1298
+	case "unreact":
1299
+		if err := models.DeleteIssueReaction(ctx.User, issue, form.Content); err != nil {
1300
+			ctx.Handle(500, "DeleteIssueReaction", err)
1301
+			return
1302
+		}
1303
+
1304
+		// Reload new reactions
1305
+		issue.Reactions = nil
1306
+		if err := issue.LoadAttributes(); err != nil {
1307
+			log.Info("issue.LoadAttributes: %s", err)
1308
+			break
1309
+		}
1310
+
1311
+		log.Trace("Reaction for issue removed: %d/%d", ctx.Repo.Repository.ID, issue.ID)
1312
+	default:
1313
+		ctx.Handle(404, fmt.Sprintf("Unknown action %s", ctx.Params(":action")), nil)
1314
+		return
1315
+	}
1316
+
1317
+	if len(issue.Reactions) == 0 {
1318
+		ctx.JSON(200, map[string]interface{}{
1319
+			"empty": true,
1320
+			"html":  "",
1321
+		})
1322
+		return
1323
+	}
1324
+
1325
+	html, err := ctx.HTMLString(string(tplReactions), map[string]interface{}{
1326
+		"ctx":       ctx.Data,
1327
+		"ActionURL": fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index),
1328
+		"Reactions": issue.Reactions.GroupByType(),
1329
+	})
1330
+	if err != nil {
1331
+		ctx.Handle(500, "ChangeIssueReaction.HTMLString", err)
1332
+		return
1333
+	}
1334
+	ctx.JSON(200, map[string]interface{}{
1335
+		"html": html,
1336
+	})
1337
+}
1338
+
1339
+// ChangeCommentReaction create a reaction for comment
1340
+func ChangeCommentReaction(ctx *context.Context, form auth.ReactionForm) {
1341
+	comment, err := models.GetCommentByID(ctx.ParamsInt64(":id"))
1342
+	if err != nil {
1343
+		ctx.NotFoundOrServerError("GetCommentByID", models.IsErrCommentNotExist, err)
1344
+		return
1345
+	}
1346
+
1347
+	issue, err := models.GetIssueByID(comment.IssueID)
1348
+	checkIssueRights(ctx, issue)
1349
+	if ctx.Written() {
1350
+		return
1351
+	}
1352
+
1353
+	if ctx.HasError() {
1354
+		ctx.Handle(500, "ChangeCommentReaction", errors.New(ctx.GetErrMsg()))
1355
+		return
1356
+	}
1357
+
1358
+	switch ctx.Params(":action") {
1359
+	case "react":
1360
+		reaction, err := models.CreateCommentReaction(ctx.User, issue, comment, form.Content)
1361
+		if err != nil {
1362
+			log.Info("CreateCommentReaction: %s", err)
1363
+			break
1364
+		}
1365
+		// Reload new reactions
1366
+		comment.Reactions = nil
1367
+		if err = comment.LoadReactions(); err != nil {
1368
+			log.Info("comment.LoadReactions: %s", err)
1369
+			break
1370
+		}
1371
+
1372
+		log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID, reaction.ID)
1373
+	case "unreact":
1374
+		if err := models.DeleteCommentReaction(ctx.User, issue, comment, form.Content); err != nil {
1375
+			ctx.Handle(500, "DeleteCommentReaction", err)
1376
+			return
1377
+		}
1378
+
1379
+		// Reload new reactions
1380
+		comment.Reactions = nil
1381
+		if err = comment.LoadReactions(); err != nil {
1382
+			log.Info("comment.LoadReactions: %s", err)
1383
+			break
1384
+		}
1385
+
1386
+		log.Trace("Reaction for comment removed: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID)
1387
+	default:
1388
+		ctx.Handle(404, fmt.Sprintf("Unknown action %s", ctx.Params(":action")), nil)
1389
+		return
1390
+	}
1391
+
1392
+	if len(comment.Reactions) == 0 {
1393
+		ctx.JSON(200, map[string]interface{}{
1394
+			"empty": true,
1395
+			"html":  "",
1396
+		})
1397
+		return
1398
+	}
1399
+
1400
+	html, err := ctx.HTMLString(string(tplReactions), map[string]interface{}{
1401
+		"ctx":       ctx.Data,
1402
+		"ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID),
1403
+		"Reactions": comment.Reactions.GroupByType(),
1404
+	})
1405
+	if err != nil {
1406
+		ctx.Handle(500, "ChangeCommentReaction.HTMLString", err)
1407
+		return
1408
+	}
1409
+	ctx.JSON(200, map[string]interface{}{
1410
+		"html": html,
1411
+	})
1412
+}

+ 2 - 0
routers/routes/routes.go

@@ -495,6 +495,7 @@ func RegisterRoutes(m *macaron.Macaron) {
495 495
 						m.Post("/cancel", repo.CancelStopwatch)
496 496
 					})
497 497
 				})
498
+				m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeIssueReaction)
498 499
 			})
499 500
 
500 501
 			m.Post("/labels", reqRepoWriter, repo.UpdateIssueLabel)
@@ -505,6 +506,7 @@ func RegisterRoutes(m *macaron.Macaron) {
505 506
 		m.Group("/comments/:id", func() {
506 507
 			m.Post("", repo.UpdateCommentContent)
507 508
 			m.Post("/delete", repo.DeleteComment)
509
+			m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeCommentReaction)
508 510
 		}, context.CheckAnyUnit(models.UnitTypeIssues, models.UnitTypePullRequests))
509 511
 		m.Group("/labels", func() {
510 512
 			m.Post("/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel)

+ 7 - 0
templates/repo/issue/view_content.tmpl

@@ -19,6 +19,7 @@
19 19
 					<div class="ui top attached header">
20 20
 						<span class="text grey"><a {{if gt .Issue.Poster.ID 0}}href="{{.Issue.Poster.HomeLink}}"{{end}}>{{.Issue.Poster.Name}}</a> {{.i18n.Tr "repo.issues.commented_at" .Issue.HashTag $createdStr | Safe}}</span>
21 21
 						<div class="ui right actions">
22
+							{{template "repo/issue/view_content/add_reaction" Dict "ctx" $ "ActionURL" (Printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) }}
22 23
 							{{if .IsIssueOwner}}
23 24
 								<div class="item action">
24 25
 									<a class="edit-content" href="#"><i class="octicon octicon-pencil"></i></a>
@@ -37,6 +38,12 @@
37 38
 						<div class="raw-content hide">{{.Issue.Content}}</div>
38 39
 						<div class="edit-content-zone hide" data-write="issue-{{.Issue.ID}}-write" data-preview="issue-{{.Issue.ID}}-preview" data-update-url="{{$.RepoLink}}/issues/{{.Issue.Index}}/content" data-context="{{.RepoLink}}"></div>
39 40
 					</div>
41
+					{{$reactions := .Issue.Reactions.GroupByType}}
42
+					{{if $reactions}}
43
+						<div class="ui attached segment reactions">
44
+							{{template "repo/issue/view_content/reactions" Dict "ctx" $ "ActionURL" (Printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) "Reactions" $reactions }}
45
+						</div>
46
+					{{end}}
40 47
 					{{if .Issue.Attachments}}
41 48
 						<div class="ui bottom attached segment">
42 49
 							<div class="ui small images">

+ 18 - 0
templates/repo/issue/view_content/add_reaction.tmpl

@@ -0,0 +1,18 @@
1
+{{if .ctx.IsSigned}}
2
+<div class="item action ui pointing top right select-reaction dropdown" data-action-url="{{ .ActionURL }}">
3
+	<a class="add-reaction">
4
+		<i class="octicon octicon-plus-small" style="width: 10px"></i>
5
+		<i class="octicon octicon-smiley"></i>
6
+	</a>
7
+	<div class="menu has-emoji">
8
+		<div class="header">{{ .ctx.i18n.Tr "repo.pick_reaction"}}</div>
9
+		<div class="divider"></div>
10
+		<div class="item" data-content="+1">:+1:</div>
11
+		<div class="item" data-content="-1">:-1:</div>
12
+		<div class="item" data-content="laugh">:laughing:</div>
13
+		<div class="item" data-content="confused">:confused:</div>
14
+		<div class="item" data-content="heart">:heart:</div>
15
+		<div class="item" data-content="hooray">:tada:</div>
16
+	</div>
17
+</div>
18
+{{end}}

+ 7 - 0
templates/repo/issue/view_content/comments.tmpl

@@ -22,6 +22,7 @@
22 22
 								{{end}}
23 23
 							</div>
24 24
 						{{end}}
25
+						{{template "repo/issue/view_content/add_reaction" Dict "ctx" $ "ActionURL" (Printf "%s/comments/%d/reactions" $.RepoLink .ID) }}
25 26
 						{{if or $.IsRepositoryAdmin (eq .Poster.ID $.SignedUserID)}}
26 27
 							<div class="item action">
27 28
 								<a class="edit-content" href="#"><i class="octicon octicon-pencil"></i></a>
@@ -41,6 +42,12 @@
41 42
 					<div class="raw-content hide">{{.Content}}</div>
42 43
 					<div class="edit-content-zone hide" data-write="issuecomment-{{.ID}}-write" data-preview="issuecomment-{{.ID}}-preview" data-update-url="{{$.RepoLink}}/comments/{{.ID}}" data-context="{{$.RepoLink}}"></div>
43 44
 				</div>
45
+				{{$reactions := .Reactions.GroupByType}}
46
+				{{if $reactions}}
47
+					<div class="ui attached segment reactions">
48
+						{{template "repo/issue/view_content/reactions" Dict "ctx" $ "ActionURL" (Printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions }}
49
+					</div>
50
+				{{end}}
44 51
 				{{if .Attachments}}
45 52
 					<div class="ui bottom attached segment">
46 53
 						<div class="ui small images">

+ 15 - 0
templates/repo/issue/view_content/reactions.tmpl

@@ -0,0 +1,15 @@
1
+{{range $key, $value := .Reactions}}
2
+	<a class="ui label basic{{if $value.HasUser $.ctx.SignedUserID}} blue{{end}}{{if not $.ctx.IsSigned}} disabled{{end}} has-emoji" data-title="{{$value.GetFirstUsers}}{{if gt ($value.GetMoreUserCount) 0}} {{ $.ctx.i18n.Tr "repo.reactions_more" $value.GetMoreUserCount}}{{end}}" data-content="{{ $key }}" data-action-url="{{ $.ActionURL }}">
3
+		{{if eq $key "hooray"}}
4
+			:tada:
5
+		{{else}}
6
+			{{if eq $key "laugh"}}
7
+				:laughing:
8
+			{{else}}
9
+				:{{$key}}:
10
+			{{end}}
11
+		{{end}}
12
+		{{len $value}}
13
+	</a>
14
+{{end}}
15
+{{template "repo/issue/view_content/add_reaction" Dict "ctx" $.ctx "ActionURL" .ActionURL }}