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