@@ -182,6 +182,7 @@ The goal of this project is to make the easiest, fastest, and most painless way | |||
- Labels | |||
- Assign issues | |||
- Track time | |||
- Reactions | |||
- Filter | |||
- Open | |||
- Closed |
@@ -0,0 +1 @@ | |||
[] # empty |
@@ -19,3 +19,11 @@ func valuesRepository(m map[int64]*Repository) []*Repository { | |||
} | |||
return values | |||
} | |||
func valuesUser(m map[int64]*User) []*User { | |||
var values = make([]*User, 0, len(m)) | |||
for _, v := range m { | |||
values = append(values, v) | |||
} | |||
return values | |||
} |
@@ -54,6 +54,7 @@ type Issue struct { | |||
Attachments []*Attachment `xorm:"-"` | |||
Comments []*Comment `xorm:"-"` | |||
Reactions ReactionList `xorm:"-"` | |||
} | |||
// BeforeUpdate is invoked from XORM before updating this object. | |||
@@ -155,6 +156,37 @@ func (issue *Issue) loadComments(e Engine) (err error) { | |||
return err | |||
} | |||
func (issue *Issue) loadReactions(e Engine) (err error) { | |||
if issue.Reactions != nil { | |||
return nil | |||
} | |||
reactions, err := findReactions(e, FindReactionsOptions{ | |||
IssueID: issue.ID, | |||
}) | |||
if err != nil { | |||
return err | |||
} | |||
// Load reaction user data | |||
if _, err := ReactionList(reactions).LoadUsers(); err != nil { | |||
return err | |||
} | |||
// Cache comments to map | |||
comments := make(map[int64]*Comment) | |||
for _, comment := range issue.Comments { | |||
comments[comment.ID] = comment | |||
} | |||
// Add reactions either to issue or comment | |||
for _, react := range reactions { | |||
if react.CommentID == 0 { | |||
issue.Reactions = append(issue.Reactions, react) | |||
} else if comment, ok := comments[react.CommentID]; ok { | |||
comment.Reactions = append(comment.Reactions, react) | |||
} | |||
} | |||
return nil | |||
} | |||
func (issue *Issue) loadAttributes(e Engine) (err error) { | |||
if err = issue.loadRepo(e); err != nil { | |||
return | |||
@@ -192,10 +224,10 @@ func (issue *Issue) loadAttributes(e Engine) (err error) { | |||
} | |||
if err = issue.loadComments(e); err != nil { | |||
return | |||
return err | |||
} | |||
return nil | |||
return issue.loadReactions(e) | |||
} | |||
// LoadAttributes loads the attribute of this issue. |
@@ -107,6 +107,7 @@ type Comment struct { | |||
CommitSHA string `xorm:"VARCHAR(40)"` | |||
Attachments []*Attachment `xorm:"-"` | |||
Reactions ReactionList `xorm:"-"` | |||
// For view issue page. | |||
ShowTag CommentTag `xorm:"-"` | |||
@@ -287,6 +288,29 @@ func (c *Comment) MailParticipants(e Engine, opType ActionType, issue *Issue) (e | |||
return nil | |||
} | |||
func (c *Comment) loadReactions(e Engine) (err error) { | |||
if c.Reactions != nil { | |||
return nil | |||
} | |||
c.Reactions, err = findReactions(e, FindReactionsOptions{ | |||
IssueID: c.IssueID, | |||
CommentID: c.ID, | |||
}) | |||
if err != nil { | |||
return err | |||
} | |||
// Load reaction user data | |||
if _, err := c.Reactions.LoadUsers(); err != nil { | |||
return err | |||
} | |||
return nil | |||
} | |||
// LoadReactions loads comment reactions | |||
func (c *Comment) LoadReactions() error { | |||
return c.loadReactions(x) | |||
} | |||
func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err error) { | |||
var LabelID int64 | |||
if opts.Label != nil { |
@@ -0,0 +1,255 @@ | |||
// Copyright 2017 The Gitea Authors. All rights reserved. | |||
// Use of this source code is governed by a MIT-style | |||
// license that can be found in the LICENSE file. | |||
package models | |||
import ( | |||
"bytes" | |||
"fmt" | |||
"time" | |||
"github.com/go-xorm/builder" | |||
"github.com/go-xorm/xorm" | |||
"code.gitea.io/gitea/modules/setting" | |||
) | |||
// Reaction represents a reactions on issues and comments. | |||
type Reaction struct { | |||
ID int64 `xorm:"pk autoincr"` | |||
Type string `xorm:"INDEX UNIQUE(s) NOT NULL"` | |||
IssueID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` | |||
CommentID int64 `xorm:"INDEX UNIQUE(s)"` | |||
UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` | |||
User *User `xorm:"-"` | |||
Created time.Time `xorm:"-"` | |||
CreatedUnix int64 `xorm:"INDEX created"` | |||
} | |||
// AfterLoad is invoked from XORM after setting the values of all fields of this object. | |||
func (s *Reaction) AfterLoad() { | |||
s.Created = time.Unix(s.CreatedUnix, 0).Local() | |||
} | |||
// FindReactionsOptions describes the conditions to Find reactions | |||
type FindReactionsOptions struct { | |||
IssueID int64 | |||
CommentID int64 | |||
} | |||
func (opts *FindReactionsOptions) toConds() builder.Cond { | |||
var cond = builder.NewCond() | |||
if opts.IssueID > 0 { | |||
cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID}) | |||
} | |||
if opts.CommentID > 0 { | |||
cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID}) | |||
} | |||
return cond | |||
} | |||
func findReactions(e Engine, opts FindReactionsOptions) ([]*Reaction, error) { | |||
reactions := make([]*Reaction, 0, 10) | |||
sess := e.Where(opts.toConds()) | |||
return reactions, sess. | |||
Asc("reaction.issue_id", "reaction.comment_id", "reaction.created_unix", "reaction.id"). | |||
Find(&reactions) | |||
} | |||
func createReaction(e *xorm.Session, opts *ReactionOptions) (*Reaction, error) { | |||
reaction := &Reaction{ | |||
Type: opts.Type, | |||
UserID: opts.Doer.ID, | |||
IssueID: opts.Issue.ID, | |||
} | |||
if opts.Comment != nil { | |||
reaction.CommentID = opts.Comment.ID | |||
} | |||
if _, err := e.Insert(reaction); err != nil { | |||
return nil, err | |||
} | |||
return reaction, nil | |||
} | |||
// ReactionOptions defines options for creating or deleting reactions | |||
type ReactionOptions struct { | |||
Type string | |||
Doer *User | |||
Issue *Issue | |||
Comment *Comment | |||
} | |||
// CreateReaction creates reaction for issue or comment. | |||
func CreateReaction(opts *ReactionOptions) (reaction *Reaction, err error) { | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
if err = sess.Begin(); err != nil { | |||
return nil, err | |||
} | |||
reaction, err = createReaction(sess, opts) | |||
if err != nil { | |||
return nil, err | |||
} | |||
if err = sess.Commit(); err != nil { | |||
return nil, err | |||
} | |||
return reaction, nil | |||
} | |||
// CreateIssueReaction creates a reaction on issue. | |||
func CreateIssueReaction(doer *User, issue *Issue, content string) (*Reaction, error) { | |||
return CreateReaction(&ReactionOptions{ | |||
Type: content, | |||
Doer: doer, | |||
Issue: issue, | |||
}) | |||
} | |||
// CreateCommentReaction creates a reaction on comment. | |||
func CreateCommentReaction(doer *User, issue *Issue, comment *Comment, content string) (*Reaction, error) { | |||
return CreateReaction(&ReactionOptions{ | |||
Type: content, | |||
Doer: doer, | |||
Issue: issue, | |||
Comment: comment, | |||
}) | |||
} | |||
func deleteReaction(e *xorm.Session, opts *ReactionOptions) error { | |||
reaction := &Reaction{ | |||
Type: opts.Type, | |||
UserID: opts.Doer.ID, | |||
IssueID: opts.Issue.ID, | |||
} | |||
if opts.Comment != nil { | |||
reaction.CommentID = opts.Comment.ID | |||
} | |||
_, err := e.Delete(reaction) | |||
return err | |||
} | |||
// DeleteReaction deletes reaction for issue or comment. | |||
func DeleteReaction(opts *ReactionOptions) error { | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
if err := sess.Begin(); err != nil { | |||
return err | |||
} | |||
if err := deleteReaction(sess, opts); err != nil { | |||
return err | |||
} | |||
return sess.Commit() | |||
} | |||
// DeleteIssueReaction deletes a reaction on issue. | |||
func DeleteIssueReaction(doer *User, issue *Issue, content string) error { | |||
return DeleteReaction(&ReactionOptions{ | |||
Type: content, | |||
Doer: doer, | |||
Issue: issue, | |||
}) | |||
} | |||
// DeleteCommentReaction deletes a reaction on comment. | |||
func DeleteCommentReaction(doer *User, issue *Issue, comment *Comment, content string) error { | |||
return DeleteReaction(&ReactionOptions{ | |||
Type: content, | |||
Doer: doer, | |||
Issue: issue, | |||
Comment: comment, | |||
}) | |||
} | |||
// ReactionList represents list of reactions | |||
type ReactionList []*Reaction | |||
// HasUser check if user has reacted | |||
func (list ReactionList) HasUser(userID int64) bool { | |||
if userID == 0 { | |||
return false | |||
} | |||
for _, reaction := range list { | |||
if reaction.UserID == userID { | |||
return true | |||
} | |||
} | |||
return false | |||
} | |||
// GroupByType returns reactions grouped by type | |||
func (list ReactionList) GroupByType() map[string]ReactionList { | |||
var reactions = make(map[string]ReactionList) | |||
for _, reaction := range list { | |||
reactions[reaction.Type] = append(reactions[reaction.Type], reaction) | |||
} | |||
return reactions | |||
} | |||
func (list ReactionList) getUserIDs() []int64 { | |||
userIDs := make(map[int64]struct{}, len(list)) | |||
for _, reaction := range list { | |||
if _, ok := userIDs[reaction.UserID]; !ok { | |||
userIDs[reaction.UserID] = struct{}{} | |||
} | |||
} | |||
return keysInt64(userIDs) | |||
} | |||
func (list ReactionList) loadUsers(e Engine) ([]*User, error) { | |||
if len(list) == 0 { | |||
return nil, nil | |||
} | |||
userIDs := list.getUserIDs() | |||
userMaps := make(map[int64]*User, len(userIDs)) | |||
err := e. | |||
In("id", userIDs). | |||
Find(&userMaps) | |||
if err != nil { | |||
return nil, fmt.Errorf("find user: %v", err) | |||
} | |||
for _, reaction := range list { | |||
if user, ok := userMaps[reaction.UserID]; ok { | |||
reaction.User = user | |||
} else { | |||
reaction.User = NewGhostUser() | |||
} | |||
} | |||
return valuesUser(userMaps), nil | |||
} | |||
// LoadUsers loads reactions' all users | |||
func (list ReactionList) LoadUsers() ([]*User, error) { | |||
return list.loadUsers(x) | |||
} | |||
// GetFirstUsers returns first reacted user display names separated by comma | |||
func (list ReactionList) GetFirstUsers() string { | |||
var buffer bytes.Buffer | |||
var rem = setting.UI.ReactionMaxUserNum | |||
for _, reaction := range list { | |||
if buffer.Len() > 0 { | |||
buffer.WriteString(", ") | |||
} | |||
buffer.WriteString(reaction.User.DisplayName()) | |||
if rem--; rem == 0 { | |||
break | |||
} | |||
} | |||
return buffer.String() | |||
} | |||
// GetMoreUserCount returns count of not shown users in reaction tooltip | |||
func (list ReactionList) GetMoreUserCount() int { | |||
if len(list) <= setting.UI.ReactionMaxUserNum { | |||
return 0 | |||
} | |||
return len(list) - setting.UI.ReactionMaxUserNum | |||
} |
@@ -148,6 +148,8 @@ var migrations = []Migration{ | |||
NewMigration("add repo indexer status", addRepoIndexerStatus), | |||
// v49 -> v50 | |||
NewMigration("add lfs lock table", addLFSLock), | |||
// v50 -> v51 | |||
NewMigration("add reactions", addReactions), | |||
} | |||
// Migrate database to current version |
@@ -0,0 +1,28 @@ | |||
// Copyright 2017 The Gitea Authors. All rights reserved. | |||
// Use of this source code is governed by a MIT-style | |||
// license that can be found in the LICENSE file. | |||
package migrations | |||
import ( | |||
"fmt" | |||
"github.com/go-xorm/xorm" | |||
) | |||
func addReactions(x *xorm.Engine) error { | |||
// Reaction see models/issue_reaction.go | |||
type Reaction struct { | |||
ID int64 `xorm:"pk autoincr"` | |||
Type string `xorm:"INDEX UNIQUE(s) NOT NULL"` | |||
IssueID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` | |||
CommentID int64 `xorm:"INDEX UNIQUE(s)"` | |||
UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` | |||
CreatedUnix int64 `xorm:"INDEX created"` | |||
} | |||
if err := x.Sync2(new(Reaction)); err != nil { | |||
return fmt.Errorf("Sync2: %v", err) | |||
} | |||
return nil | |||
} |
@@ -118,6 +118,7 @@ func init() { | |||
new(DeletedBranch), | |||
new(RepoIndexerStatus), | |||
new(LFSLock), | |||
new(Reaction), | |||
) | |||
gonicNames := []string{"SSL", "UID"} |
@@ -980,6 +980,7 @@ func deleteUser(e *xorm.Session, u *User) error { | |||
&IssueUser{UID: u.ID}, | |||
&EmailAddress{UID: u.ID}, | |||
&UserOpenID{UID: u.ID}, | |||
&Reaction{UserID: u.ID}, | |||
); err != nil { | |||
return fmt.Errorf("deleteBeans: %v", err) | |||
} |
@@ -268,6 +268,16 @@ func (f *CreateCommentForm) Validate(ctx *macaron.Context, errs binding.Errors) | |||
return validate(errs, ctx.Data, f, ctx.Locale) | |||
} | |||
// ReactionForm form for adding and removing reaction | |||
type ReactionForm struct { | |||
Content string `binding:"Required;In(+1,-1,laugh,confused,heart,hooray)"` | |||
} | |||
// Validate validates the fields | |||
func (f *ReactionForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { | |||
return validate(errs, ctx.Data, f, ctx.Locale) | |||
} | |||
// _____ .__.__ __ | |||
// / \ |__| | ____ _______/ |_ ____ ____ ____ | |||
// / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \ |
@@ -211,7 +211,7 @@ func Contexter() macaron.Handler { | |||
ctx.Data["SignedUserName"] = ctx.User.Name | |||
ctx.Data["IsAdmin"] = ctx.User.IsAdmin | |||
} else { | |||
ctx.Data["SignedUserID"] = 0 | |||
ctx.Data["SignedUserID"] = int64(0) | |||
ctx.Data["SignedUserName"] = "" | |||
} | |||
@@ -256,6 +256,7 @@ var ( | |||
IssuePagingNum int | |||
RepoSearchPagingNum int | |||
FeedMaxCommitNum int | |||
ReactionMaxUserNum int | |||
ThemeColorMetaTag string | |||
MaxDisplayFileSize int64 | |||
ShowUserEmail bool | |||
@@ -279,6 +280,7 @@ var ( | |||
IssuePagingNum: 10, | |||
RepoSearchPagingNum: 10, | |||
FeedMaxCommitNum: 5, | |||
ReactionMaxUserNum: 10, | |||
ThemeColorMetaTag: `#6cc644`, | |||
MaxDisplayFileSize: 8388608, | |||
Admin: struct { |
@@ -8,6 +8,7 @@ import ( | |||
"bytes" | |||
"container/list" | |||
"encoding/json" | |||
"errors" | |||
"fmt" | |||
"html/template" | |||
"mime" | |||
@@ -162,6 +163,21 @@ func NewFuncMap() []template.FuncMap { | |||
return setting.DisableGitHooks | |||
}, | |||
"TrN": TrN, | |||
"Dict": func(values ...interface{}) (map[string]interface{}, error) { | |||
if len(values)%2 != 0 { | |||
return nil, errors.New("invalid dict call") | |||
} | |||
dict := make(map[string]interface{}, len(values)/2) | |||
for i := 0; i < len(values); i += 2 { | |||
key, ok := values[i].(string) | |||
if !ok { | |||
return nil, errors.New("dict keys must be strings") | |||
} | |||
dict[key] = values[i+1] | |||
} | |||
return dict, nil | |||
}, | |||
"Printf": fmt.Sprintf, | |||
}} | |||
} | |||
@@ -489,6 +489,8 @@ mirror_last_synced = Last Synced | |||
watchers = Watchers | |||
stargazers = Stargazers | |||
forks = Forks | |||
pick_reaction = Pick your reaction | |||
reactions_more = and %d more | |||
form.reach_limit_of_creation = You have already reached your limit of %d repositories. | |||
form.name_reserved = The repository name '%s' is reserved. |
@@ -117,6 +117,54 @@ function updateIssuesMeta(url, action, issueIds, elementId, afterSuccess) { | |||
}) | |||
} | |||
function initReactionSelector(parent) { | |||
var reactions = ''; | |||
if (!parent) { | |||
parent = $(document); | |||
reactions = '.reactions > '; | |||
} | |||
parent.find(reactions + 'a.label').popup({'position': 'bottom left', 'metadata': {'content': 'title', 'title': 'none'}}); | |||
parent.find('.select-reaction > .menu > .item, ' + reactions + 'a.label').on('click', function(e){ | |||
var vm = this; | |||
e.preventDefault(); | |||
if ($(this).hasClass('disabled')) return; | |||
var actionURL = $(this).hasClass('item') ? | |||
$(this).closest('.select-reaction').data('action-url') : | |||
$(this).data('action-url'); | |||
var url = actionURL + '/' + ($(this).hasClass('blue') ? 'unreact' : 'react'); | |||
$.ajax({ | |||
type: 'POST', | |||
url: url, | |||
data: { | |||
'_csrf': csrf, | |||
'content': $(this).data('content') | |||
} | |||
}).done(function(resp) { | |||
if (resp && (resp.html || resp.empty)) { | |||
var content = $(vm).closest('.content'); | |||
var react = content.find('.segment.reactions'); | |||
if (react.length > 0) { | |||
react.remove(); | |||
} | |||
if (!resp.empty) { | |||
react = $('<div class="ui attached segment reactions"></div>').appendTo(content); | |||
react.html(resp.html); | |||
var hasEmoji = react.find('.has-emoji'); | |||
for (var i = 0; i < hasEmoji.length; i++) { | |||
emojify.run(hasEmoji.get(i)); | |||
} | |||
react.find('.dropdown').dropdown(); | |||
initReactionSelector(react); | |||
} | |||
} | |||
}); | |||
}); | |||
} | |||
function initCommentForm() { | |||
if ($('.comment.form').length == 0) { | |||
return | |||
@@ -594,6 +642,7 @@ function initRepository() { | |||
$('#status').val($statusButton.data('status-val')); | |||
$('#comment-form').submit(); | |||
}); | |||
initReactionSelector(); | |||
} | |||
// Diff |
@@ -548,7 +548,7 @@ | |||
} | |||
.content { | |||
margin-left: 4em; | |||
.header { | |||
> .header { | |||
#avatar-arrow; | |||
font-weight: normal; | |||
padding: auto 15px; | |||
@@ -1350,6 +1350,43 @@ | |||
} | |||
} | |||
} | |||
.segment.reactions, .select-reaction { | |||
&.dropdown .menu { | |||
right: 0!important; | |||
left: auto!important; | |||
> .header { | |||
margin: 0.75rem 0 .5rem; | |||
} | |||
> .item { | |||
float: left; | |||
padding: .5rem .5rem !important; | |||
img.emoji { | |||
margin-right: 0; | |||
} | |||
} | |||
} | |||
} | |||
.segment.reactions { | |||
padding: .3em 1em; | |||
.ui.label { | |||
padding: .4em; | |||
&.disabled { | |||
cursor: default; | |||
} | |||
> img { | |||
height: 1.5em !important; | |||
} | |||
} | |||
.select-reaction { | |||
float: none; | |||
&:not(.active) a { | |||
display: none; | |||
} | |||
} | |||
&:hover .select-reaction a { | |||
display: block; | |||
} | |||
} | |||
} | |||
// End of .repository | |||
@@ -39,6 +39,8 @@ const ( | |||
tplMilestoneNew base.TplName = "repo/issue/milestone_new" | |||
tplMilestoneEdit base.TplName = "repo/issue/milestone_edit" | |||
tplReactions base.TplName = "repo/issue/view_content/reactions" | |||
issueTemplateKey = "IssueTemplate" | |||
) | |||
@@ -726,9 +728,8 @@ func GetActionIssue(ctx *context.Context) *models.Issue { | |||
ctx.NotFoundOrServerError("GetIssueByIndex", models.IsErrIssueNotExist, err) | |||
return nil | |||
} | |||
if issue.IsPull && !ctx.Repo.Repository.UnitEnabled(models.UnitTypePullRequests) || | |||
!issue.IsPull && !ctx.Repo.Repository.UnitEnabled(models.UnitTypeIssues) { | |||
ctx.Handle(404, "IssueOrPullRequestUnitNotAllowed", nil) | |||
checkIssueRights(ctx, issue) | |||
if ctx.Written() { | |||
return nil | |||
} | |||
if err = issue.LoadAttributes(); err != nil { | |||
@@ -738,6 +739,13 @@ func GetActionIssue(ctx *context.Context) *models.Issue { | |||
return issue | |||
} | |||
func checkIssueRights(ctx *context.Context, issue *models.Issue) { | |||
if issue.IsPull && !ctx.Repo.Repository.UnitEnabled(models.UnitTypePullRequests) || | |||
!issue.IsPull && !ctx.Repo.Repository.UnitEnabled(models.UnitTypeIssues) { | |||
ctx.Handle(404, "IssueOrPullRequestUnitNotAllowed", nil) | |||
} | |||
} | |||
func getActionIssues(ctx *context.Context) []*models.Issue { | |||
commaSeparatedIssueIDs := ctx.Query("issue_ids") | |||
if len(commaSeparatedIssueIDs) == 0 { | |||
@@ -1259,3 +1267,146 @@ func DeleteMilestone(ctx *context.Context) { | |||
"redirect": ctx.Repo.RepoLink + "/milestones", | |||
}) | |||
} | |||
// ChangeIssueReaction create a reaction for issue | |||
func ChangeIssueReaction(ctx *context.Context, form auth.ReactionForm) { | |||
issue := GetActionIssue(ctx) | |||
if ctx.Written() { | |||
return | |||
} | |||
if ctx.HasError() { | |||
ctx.Handle(500, "ChangeIssueReaction", errors.New(ctx.GetErrMsg())) | |||
return | |||
} | |||
switch ctx.Params(":action") { | |||
case "react": | |||
reaction, err := models.CreateIssueReaction(ctx.User, issue, form.Content) | |||
if err != nil { | |||
log.Info("CreateIssueReaction: %s", err) | |||
break | |||
} | |||
// Reload new reactions | |||
issue.Reactions = nil | |||
if err = issue.LoadAttributes(); err != nil { | |||
log.Info("issue.LoadAttributes: %s", err) | |||
break | |||
} | |||
log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID) | |||
case "unreact": | |||
if err := models.DeleteIssueReaction(ctx.User, issue, form.Content); err != nil { | |||
ctx.Handle(500, "DeleteIssueReaction", err) | |||
return | |||
} | |||
// Reload new reactions | |||
issue.Reactions = nil | |||
if err := issue.LoadAttributes(); err != nil { | |||
log.Info("issue.LoadAttributes: %s", err) | |||
break | |||
} | |||
log.Trace("Reaction for issue removed: %d/%d", ctx.Repo.Repository.ID, issue.ID) | |||
default: | |||
ctx.Handle(404, fmt.Sprintf("Unknown action %s", ctx.Params(":action")), nil) | |||
return | |||
} | |||
if len(issue.Reactions) == 0 { | |||
ctx.JSON(200, map[string]interface{}{ | |||
"empty": true, | |||
"html": "", | |||
}) | |||
return | |||
} | |||
html, err := ctx.HTMLString(string(tplReactions), map[string]interface{}{ | |||
"ctx": ctx.Data, | |||
"ActionURL": fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index), | |||
"Reactions": issue.Reactions.GroupByType(), | |||
}) | |||
if err != nil { | |||
ctx.Handle(500, "ChangeIssueReaction.HTMLString", err) | |||
return | |||
} | |||
ctx.JSON(200, map[string]interface{}{ | |||
"html": html, | |||
}) | |||
} | |||
// ChangeCommentReaction create a reaction for comment | |||
func ChangeCommentReaction(ctx *context.Context, form auth.ReactionForm) { | |||
comment, err := models.GetCommentByID(ctx.ParamsInt64(":id")) | |||
if err != nil { | |||
ctx.NotFoundOrServerError("GetCommentByID", models.IsErrCommentNotExist, err) | |||
return | |||
} | |||
issue, err := models.GetIssueByID(comment.IssueID) | |||
checkIssueRights(ctx, issue) | |||
if ctx.Written() { | |||
return | |||
} | |||
if ctx.HasError() { | |||
ctx.Handle(500, "ChangeCommentReaction", errors.New(ctx.GetErrMsg())) | |||
return | |||
} | |||
switch ctx.Params(":action") { | |||
case "react": | |||
reaction, err := models.CreateCommentReaction(ctx.User, issue, comment, form.Content) | |||
if err != nil { | |||
log.Info("CreateCommentReaction: %s", err) | |||
break | |||
} | |||
// Reload new reactions | |||
comment.Reactions = nil | |||
if err = comment.LoadReactions(); err != nil { | |||
log.Info("comment.LoadReactions: %s", err) | |||
break | |||
} | |||
log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID, reaction.ID) | |||
case "unreact": | |||
if err := models.DeleteCommentReaction(ctx.User, issue, comment, form.Content); err != nil { | |||
ctx.Handle(500, "DeleteCommentReaction", err) | |||
return | |||
} | |||
// Reload new reactions | |||
comment.Reactions = nil | |||
if err = comment.LoadReactions(); err != nil { | |||
log.Info("comment.LoadReactions: %s", err) | |||
break | |||
} | |||
log.Trace("Reaction for comment removed: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) | |||
default: | |||
ctx.Handle(404, fmt.Sprintf("Unknown action %s", ctx.Params(":action")), nil) | |||
return | |||
} | |||
if len(comment.Reactions) == 0 { | |||
ctx.JSON(200, map[string]interface{}{ | |||
"empty": true, | |||
"html": "", | |||
}) | |||
return | |||
} | |||
html, err := ctx.HTMLString(string(tplReactions), map[string]interface{}{ | |||
"ctx": ctx.Data, | |||
"ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), | |||
"Reactions": comment.Reactions.GroupByType(), | |||
}) | |||
if err != nil { | |||
ctx.Handle(500, "ChangeCommentReaction.HTMLString", err) | |||
return | |||
} | |||
ctx.JSON(200, map[string]interface{}{ | |||
"html": html, | |||
}) | |||
} |
@@ -495,6 +495,7 @@ func RegisterRoutes(m *macaron.Macaron) { | |||
m.Post("/cancel", repo.CancelStopwatch) | |||
}) | |||
}) | |||
m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeIssueReaction) | |||
}) | |||
m.Post("/labels", reqRepoWriter, repo.UpdateIssueLabel) | |||
@@ -505,6 +506,7 @@ func RegisterRoutes(m *macaron.Macaron) { | |||
m.Group("/comments/:id", func() { | |||
m.Post("", repo.UpdateCommentContent) | |||
m.Post("/delete", repo.DeleteComment) | |||
m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeCommentReaction) | |||
}, context.CheckAnyUnit(models.UnitTypeIssues, models.UnitTypePullRequests)) | |||
m.Group("/labels", func() { | |||
m.Post("/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel) |
@@ -19,6 +19,7 @@ | |||
<div class="ui top attached header"> | |||
<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> | |||
<div class="ui right actions"> | |||
{{template "repo/issue/view_content/add_reaction" Dict "ctx" $ "ActionURL" (Printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) }} | |||
{{if .IsIssueOwner}} | |||
<div class="item action"> | |||
<a class="edit-content" href="#"><i class="octicon octicon-pencil"></i></a> | |||
@@ -37,6 +38,12 @@ | |||
<div class="raw-content hide">{{.Issue.Content}}</div> | |||
<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> | |||
</div> | |||
{{$reactions := .Issue.Reactions.GroupByType}} | |||
{{if $reactions}} | |||
<div class="ui attached segment reactions"> | |||
{{template "repo/issue/view_content/reactions" Dict "ctx" $ "ActionURL" (Printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) "Reactions" $reactions }} | |||
</div> | |||
{{end}} | |||
{{if .Issue.Attachments}} | |||
<div class="ui bottom attached segment"> | |||
<div class="ui small images"> |
@@ -0,0 +1,18 @@ | |||
{{if .ctx.IsSigned}} | |||
<div class="item action ui pointing top right select-reaction dropdown" data-action-url="{{ .ActionURL }}"> | |||
<a class="add-reaction"> | |||
<i class="octicon octicon-plus-small" style="width: 10px"></i> | |||
<i class="octicon octicon-smiley"></i> | |||
</a> | |||
<div class="menu has-emoji"> | |||
<div class="header">{{ .ctx.i18n.Tr "repo.pick_reaction"}}</div> | |||
<div class="divider"></div> | |||
<div class="item" data-content="+1">:+1:</div> | |||
<div class="item" data-content="-1">:-1:</div> | |||
<div class="item" data-content="laugh">:laughing:</div> | |||
<div class="item" data-content="confused">:confused:</div> | |||
<div class="item" data-content="heart">:heart:</div> | |||
<div class="item" data-content="hooray">:tada:</div> | |||
</div> | |||
</div> | |||
{{end}} |
@@ -22,6 +22,7 @@ | |||
{{end}} | |||
</div> | |||
{{end}} | |||
{{template "repo/issue/view_content/add_reaction" Dict "ctx" $ "ActionURL" (Printf "%s/comments/%d/reactions" $.RepoLink .ID) }} | |||
{{if or $.IsRepositoryAdmin (eq .Poster.ID $.SignedUserID)}} | |||
<div class="item action"> | |||
<a class="edit-content" href="#"><i class="octicon octicon-pencil"></i></a> | |||
@@ -41,6 +42,12 @@ | |||
<div class="raw-content hide">{{.Content}}</div> | |||
<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> | |||
</div> | |||
{{$reactions := .Reactions.GroupByType}} | |||
{{if $reactions}} | |||
<div class="ui attached segment reactions"> | |||
{{template "repo/issue/view_content/reactions" Dict "ctx" $ "ActionURL" (Printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions }} | |||
</div> | |||
{{end}} | |||
{{if .Attachments}} | |||
<div class="ui bottom attached segment"> | |||
<div class="ui small images"> |
@@ -0,0 +1,15 @@ | |||
{{range $key, $value := .Reactions}} | |||
<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 }}"> | |||
{{if eq $key "hooray"}} | |||
:tada: | |||
{{else}} | |||
{{if eq $key "laugh"}} | |||
:laughing: | |||
{{else}} | |||
:{{$key}}: | |||
{{end}} | |||
{{end}} | |||
{{len $value}} | |||
</a> | |||
{{end}} | |||
{{template "repo/issue/view_content/add_reaction" Dict "ctx" $.ctx "ActionURL" .ActionURL }} |