Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add reactions to issues/PR and comments #2856

Merged
merged 4 commits into from
Dec 3, 2017
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions models/fixtures/reaction.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[] # empty
8 changes: 8 additions & 0 deletions models/helper.go
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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)"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Foreign key please? 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bkcsoft IIRC, xorm does not support foreign keys 😢

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And what also I don't quite like it inserts 0 values for IDs where it would normally should be null

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would it ever insert 0/null ? It would throw foreign key constrain and that would be that 😛

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
Original file line number Diff line number Diff line change
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
Loading