Skip to content

Commit

Permalink
Add reactions to issues/PR and comments (#2856)
Browse files Browse the repository at this point in the history
  • Loading branch information
lafriks committed Dec 3, 2017
1 parent e59adcd commit 5dc37b1
Show file tree
Hide file tree
Showing 24 changed files with 677 additions and 8 deletions.
1 change: 1 addition & 0 deletions docs/content/page/index.en-us.md
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions models/fixtures/reaction.yml
@@ -0,0 +1 @@
[] # empty
8 changes: 8 additions & 0 deletions models/helper.go
Expand Up @@ -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
}
36 changes: 34 additions & 2 deletions models/issue.go
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions models/issue_comment.go
Expand Up @@ -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:"-"`
Expand Down Expand Up @@ -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 {
Expand Down
255 changes: 255 additions & 0 deletions models/issue_reaction.go
@@ -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
}
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Expand Up @@ -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
Expand Down

0 comments on commit 5dc37b1

Please sign in to comment.