Add reactions to issues/PR and comments (#2856)
parent
e59adcde65
commit
5dc37b187c
@ -0,0 +1 @@ |
|||||||
|
[] # empty |
@ -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 |
||||||
|
} |
@ -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 |
||||||
|
} |
File diff suppressed because one or more lines are too long
@ -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}} |
@ -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…
Reference in new issue