Add reactions to issues/PR and comments (#2856)
This commit is contained in:
		
					parent
					
						
							
								e59adcde65
							
						
					
				
			
			
				commit
				
					
						5dc37b187c
					
				
			
		
					 24 changed files with 677 additions and 8 deletions
				
			
		| 
						 | 
				
			
			@ -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…
	
	Add table
		Add a link
		
	
		Reference in a new issue