forked from gitea/gitea
		
	Add reactions to issues/PR and comments (#2856)
This commit is contained in:
		
							parent
							
								
									e59adcde65
								
							
						
					
					
						commit
						5dc37b187c
					
				| @ -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 | ||||
|  | ||||
							
								
								
									
										1
									
								
								models/fixtures/reaction.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								models/fixtures/reaction.yml
									
									
									
									
									
										Normal file
									
								
							| @ -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 { | ||||
|  | ||||
							
								
								
									
										255
									
								
								models/issue_reaction.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								models/issue_reaction.go
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
|  | ||||
							
								
								
									
										28
									
								
								models/migrations/v50.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								models/migrations/v50.go
									
									
									
									
									
										Normal file
									
								
							| @ -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. | ||||
|  | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @ -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"> | ||||
|  | ||||
							
								
								
									
										18
									
								
								templates/repo/issue/view_content/add_reaction.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								templates/repo/issue/view_content/add_reaction.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -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"> | ||||
|  | ||||
							
								
								
									
										15
									
								
								templates/repo/issue/view_content/reactions.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								templates/repo/issue/view_content/reactions.tmpl
									
									
									
									
									
										Normal file
									
								
							| @ -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 }} | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Lauris BH
						Lauris BH