@@ -182,6 +182,7 @@ The goal of this project is to make the easiest, fastest, and most painless way | |||||
- Labels | - Labels | ||||
- Assign issues | - Assign issues | ||||
- Track time | - Track time | ||||
- Reactions | |||||
- Filter | - Filter | ||||
- Open | - Open | ||||
- Closed | - Closed | ||||
@@ -0,0 +1 @@ | |||||
[] # empty |
@@ -19,3 +19,11 @@ func valuesRepository(m map[int64]*Repository) []*Repository { | |||||
} | } | ||||
return values | 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:"-"` | Attachments []*Attachment `xorm:"-"` | ||||
Comments []*Comment `xorm:"-"` | Comments []*Comment `xorm:"-"` | ||||
Reactions ReactionList `xorm:"-"` | |||||
} | } | ||||
// BeforeUpdate is invoked from XORM before updating this object. | // BeforeUpdate is invoked from XORM before updating this object. | ||||
@@ -155,6 +156,37 @@ func (issue *Issue) loadComments(e Engine) (err error) { | |||||
return err | 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) { | func (issue *Issue) loadAttributes(e Engine) (err error) { | ||||
if err = issue.loadRepo(e); err != nil { | if err = issue.loadRepo(e); err != nil { | ||||
return | return | ||||
@@ -192,10 +224,10 @@ func (issue *Issue) loadAttributes(e Engine) (err error) { | |||||
} | } | ||||
if err = issue.loadComments(e); err != nil { | if err = issue.loadComments(e); err != nil { | ||||
return | |||||
return err | |||||
} | } | ||||
return nil | |||||
return issue.loadReactions(e) | |||||
} | } | ||||
// LoadAttributes loads the attribute of this issue. | // LoadAttributes loads the attribute of this issue. | ||||
@@ -107,6 +107,7 @@ type Comment struct { | |||||
CommitSHA string `xorm:"VARCHAR(40)"` | CommitSHA string `xorm:"VARCHAR(40)"` | ||||
Attachments []*Attachment `xorm:"-"` | Attachments []*Attachment `xorm:"-"` | ||||
Reactions ReactionList `xorm:"-"` | |||||
// For view issue page. | // For view issue page. | ||||
ShowTag CommentTag `xorm:"-"` | ShowTag CommentTag `xorm:"-"` | ||||
@@ -287,6 +288,29 @@ func (c *Comment) MailParticipants(e Engine, opType ActionType, issue *Issue) (e | |||||
return nil | 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) { | func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err error) { | ||||
var LabelID int64 | var LabelID int64 | ||||
if opts.Label != nil { | 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), | NewMigration("add repo indexer status", addRepoIndexerStatus), | ||||
// v49 -> v50 | // v49 -> v50 | ||||
NewMigration("add lfs lock table", addLFSLock), | NewMigration("add lfs lock table", addLFSLock), | ||||
// v50 -> v51 | |||||
NewMigration("add reactions", addReactions), | |||||
} | } | ||||
// Migrate database to current version | // 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(DeletedBranch), | ||||
new(RepoIndexerStatus), | new(RepoIndexerStatus), | ||||
new(LFSLock), | new(LFSLock), | ||||
new(Reaction), | |||||
) | ) | ||||
gonicNames := []string{"SSL", "UID"} | gonicNames := []string{"SSL", "UID"} | ||||
@@ -980,6 +980,7 @@ func deleteUser(e *xorm.Session, u *User) error { | |||||
&IssueUser{UID: u.ID}, | &IssueUser{UID: u.ID}, | ||||
&EmailAddress{UID: u.ID}, | &EmailAddress{UID: u.ID}, | ||||
&UserOpenID{UID: u.ID}, | &UserOpenID{UID: u.ID}, | ||||
&Reaction{UserID: u.ID}, | |||||
); err != nil { | ); err != nil { | ||||
return fmt.Errorf("deleteBeans: %v", err) | 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) | 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["SignedUserName"] = ctx.User.Name | ||||
ctx.Data["IsAdmin"] = ctx.User.IsAdmin | ctx.Data["IsAdmin"] = ctx.User.IsAdmin | ||||
} else { | } else { | ||||
ctx.Data["SignedUserID"] = 0 | |||||
ctx.Data["SignedUserID"] = int64(0) | |||||
ctx.Data["SignedUserName"] = "" | ctx.Data["SignedUserName"] = "" | ||||
} | } | ||||
@@ -256,6 +256,7 @@ var ( | |||||
IssuePagingNum int | IssuePagingNum int | ||||
RepoSearchPagingNum int | RepoSearchPagingNum int | ||||
FeedMaxCommitNum int | FeedMaxCommitNum int | ||||
ReactionMaxUserNum int | |||||
ThemeColorMetaTag string | ThemeColorMetaTag string | ||||
MaxDisplayFileSize int64 | MaxDisplayFileSize int64 | ||||
ShowUserEmail bool | ShowUserEmail bool | ||||
@@ -279,6 +280,7 @@ var ( | |||||
IssuePagingNum: 10, | IssuePagingNum: 10, | ||||
RepoSearchPagingNum: 10, | RepoSearchPagingNum: 10, | ||||
FeedMaxCommitNum: 5, | FeedMaxCommitNum: 5, | ||||
ReactionMaxUserNum: 10, | |||||
ThemeColorMetaTag: `#6cc644`, | ThemeColorMetaTag: `#6cc644`, | ||||
MaxDisplayFileSize: 8388608, | MaxDisplayFileSize: 8388608, | ||||
Admin: struct { | Admin: struct { | ||||
@@ -8,6 +8,7 @@ import ( | |||||
"bytes" | "bytes" | ||||
"container/list" | "container/list" | ||||
"encoding/json" | "encoding/json" | ||||
"errors" | |||||
"fmt" | "fmt" | ||||
"html/template" | "html/template" | ||||
"mime" | "mime" | ||||
@@ -162,6 +163,21 @@ func NewFuncMap() []template.FuncMap { | |||||
return setting.DisableGitHooks | return setting.DisableGitHooks | ||||
}, | }, | ||||
"TrN": TrN, | "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 | watchers = Watchers | ||||
stargazers = Stargazers | stargazers = Stargazers | ||||
forks = Forks | 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.reach_limit_of_creation = You have already reached your limit of %d repositories. | ||||
form.name_reserved = The repository name '%s' is reserved. | 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() { | function initCommentForm() { | ||||
if ($('.comment.form').length == 0) { | if ($('.comment.form').length == 0) { | ||||
return | return | ||||
@@ -594,6 +642,7 @@ function initRepository() { | |||||
$('#status').val($statusButton.data('status-val')); | $('#status').val($statusButton.data('status-val')); | ||||
$('#comment-form').submit(); | $('#comment-form').submit(); | ||||
}); | }); | ||||
initReactionSelector(); | |||||
} | } | ||||
// Diff | // Diff | ||||
@@ -548,7 +548,7 @@ | |||||
} | } | ||||
.content { | .content { | ||||
margin-left: 4em; | margin-left: 4em; | ||||
.header { | |||||
> .header { | |||||
#avatar-arrow; | #avatar-arrow; | ||||
font-weight: normal; | font-weight: normal; | ||||
padding: auto 15px; | 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 | // End of .repository | ||||
@@ -39,6 +39,8 @@ const ( | |||||
tplMilestoneNew base.TplName = "repo/issue/milestone_new" | tplMilestoneNew base.TplName = "repo/issue/milestone_new" | ||||
tplMilestoneEdit base.TplName = "repo/issue/milestone_edit" | tplMilestoneEdit base.TplName = "repo/issue/milestone_edit" | ||||
tplReactions base.TplName = "repo/issue/view_content/reactions" | |||||
issueTemplateKey = "IssueTemplate" | issueTemplateKey = "IssueTemplate" | ||||
) | ) | ||||
@@ -726,9 +728,8 @@ func GetActionIssue(ctx *context.Context) *models.Issue { | |||||
ctx.NotFoundOrServerError("GetIssueByIndex", models.IsErrIssueNotExist, err) | ctx.NotFoundOrServerError("GetIssueByIndex", models.IsErrIssueNotExist, err) | ||||
return nil | 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 | return nil | ||||
} | } | ||||
if err = issue.LoadAttributes(); err != nil { | if err = issue.LoadAttributes(); err != nil { | ||||
@@ -738,6 +739,13 @@ func GetActionIssue(ctx *context.Context) *models.Issue { | |||||
return 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 { | func getActionIssues(ctx *context.Context) []*models.Issue { | ||||
commaSeparatedIssueIDs := ctx.Query("issue_ids") | commaSeparatedIssueIDs := ctx.Query("issue_ids") | ||||
if len(commaSeparatedIssueIDs) == 0 { | if len(commaSeparatedIssueIDs) == 0 { | ||||
@@ -1259,3 +1267,146 @@ func DeleteMilestone(ctx *context.Context) { | |||||
"redirect": ctx.Repo.RepoLink + "/milestones", | "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("/cancel", repo.CancelStopwatch) | ||||
}) | }) | ||||
}) | }) | ||||
m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeIssueReaction) | |||||
}) | }) | ||||
m.Post("/labels", reqRepoWriter, repo.UpdateIssueLabel) | m.Post("/labels", reqRepoWriter, repo.UpdateIssueLabel) | ||||
@@ -505,6 +506,7 @@ func RegisterRoutes(m *macaron.Macaron) { | |||||
m.Group("/comments/:id", func() { | m.Group("/comments/:id", func() { | ||||
m.Post("", repo.UpdateCommentContent) | m.Post("", repo.UpdateCommentContent) | ||||
m.Post("/delete", repo.DeleteComment) | m.Post("/delete", repo.DeleteComment) | ||||
m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeCommentReaction) | |||||
}, context.CheckAnyUnit(models.UnitTypeIssues, models.UnitTypePullRequests)) | }, context.CheckAnyUnit(models.UnitTypeIssues, models.UnitTypePullRequests)) | ||||
m.Group("/labels", func() { | m.Group("/labels", func() { | ||||
m.Post("/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel) | m.Post("/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel) | ||||
@@ -19,6 +19,7 @@ | |||||
<div class="ui top attached header"> | <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> | <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"> | <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}} | {{if .IsIssueOwner}} | ||||
<div class="item action"> | <div class="item action"> | ||||
<a class="edit-content" href="#"><i class="octicon octicon-pencil"></i></a> | <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="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 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> | </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}} | {{if .Issue.Attachments}} | ||||
<div class="ui bottom attached segment"> | <div class="ui bottom attached segment"> | ||||
<div class="ui small images"> | <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}} | {{end}} | ||||
</div> | </div> | ||||
{{end}} | {{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)}} | {{if or $.IsRepositoryAdmin (eq .Poster.ID $.SignedUserID)}} | ||||
<div class="item action"> | <div class="item action"> | ||||
<a class="edit-content" href="#"><i class="octicon octicon-pencil"></i></a> | <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="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 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> | </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}} | {{if .Attachments}} | ||||
<div class="ui bottom attached segment"> | <div class="ui bottom attached segment"> | ||||
<div class="ui small images"> | <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 }} |