Merge pull request #1410 from andreynering/notification/issue-watch
[Notifications Step 6] Per issue/PR watch/unwatch
This commit is contained in:
commit
37a34c1a28
|
@ -491,6 +491,7 @@ func runWeb(ctx *cli.Context) error {
|
||||||
m.Group("/:index", func() {
|
m.Group("/:index", func() {
|
||||||
m.Post("/title", repo.UpdateIssueTitle)
|
m.Post("/title", repo.UpdateIssueTitle)
|
||||||
m.Post("/content", repo.UpdateIssueContent)
|
m.Post("/content", repo.UpdateIssueContent)
|
||||||
|
m.Post("/watch", repo.IssueWatch)
|
||||||
m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment)
|
m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
-
|
||||||
|
id: 1
|
||||||
|
user_id: 1
|
||||||
|
issue_id: 1
|
||||||
|
is_watching: true
|
||||||
|
created_unix: 946684800
|
||||||
|
updated_unix: 946684800
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 2
|
||||||
|
user_id: 2
|
||||||
|
issue_id: 2
|
||||||
|
is_watching: false
|
||||||
|
created_unix: 946684800
|
||||||
|
updated_unix: 946684800
|
|
@ -0,0 +1,96 @@
|
||||||
|
// 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 (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssueWatch is connection request for receiving issue notification.
|
||||||
|
type IssueWatch struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
UserID int64 `xorm:"UNIQUE(watch) NOT NULL"`
|
||||||
|
IssueID int64 `xorm:"UNIQUE(watch) NOT NULL"`
|
||||||
|
IsWatching bool `xorm:"NOT NULL"`
|
||||||
|
Created time.Time `xorm:"-"`
|
||||||
|
CreatedUnix int64 `xorm:"NOT NULL"`
|
||||||
|
Updated time.Time `xorm:"-"`
|
||||||
|
UpdatedUnix int64 `xorm:"NOT NULL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeInsert is invoked from XORM before inserting an object of this type.
|
||||||
|
func (iw *IssueWatch) BeforeInsert() {
|
||||||
|
var (
|
||||||
|
t = time.Now()
|
||||||
|
u = t.Unix()
|
||||||
|
)
|
||||||
|
iw.Created = t
|
||||||
|
iw.CreatedUnix = u
|
||||||
|
iw.Updated = t
|
||||||
|
iw.UpdatedUnix = u
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeUpdate is invoked from XORM before updating an object of this type.
|
||||||
|
func (iw *IssueWatch) BeforeUpdate() {
|
||||||
|
var (
|
||||||
|
t = time.Now()
|
||||||
|
u = t.Unix()
|
||||||
|
)
|
||||||
|
iw.Updated = t
|
||||||
|
iw.UpdatedUnix = u
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrUpdateIssueWatch set watching for a user and issue
|
||||||
|
func CreateOrUpdateIssueWatch(userID, issueID int64, isWatching bool) error {
|
||||||
|
iw, exists, err := getIssueWatch(x, userID, issueID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
iw = &IssueWatch{
|
||||||
|
UserID: userID,
|
||||||
|
IssueID: issueID,
|
||||||
|
IsWatching: isWatching,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := x.Insert(iw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
iw.IsWatching = isWatching
|
||||||
|
|
||||||
|
if _, err := x.Id(iw.ID).Cols("is_watching", "updated_unix").Update(iw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssueWatch returns an issue watch by user and issue
|
||||||
|
func GetIssueWatch(userID, issueID int64) (iw *IssueWatch, exists bool, err error) {
|
||||||
|
return getIssueWatch(x, userID, issueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIssueWatch(e Engine, userID, issueID int64) (iw *IssueWatch, exists bool, err error) {
|
||||||
|
iw = new(IssueWatch)
|
||||||
|
exists, err = e.
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
And("issue_id = ?", issueID).
|
||||||
|
Get(iw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssueWatchers returns watchers/unwatchers of a given issue
|
||||||
|
func GetIssueWatchers(issueID int64) ([]*IssueWatch, error) {
|
||||||
|
return getIssueWatchers(x, issueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIssueWatchers(e Engine, issueID int64) (watches []*IssueWatch, err error) {
|
||||||
|
err = e.
|
||||||
|
Where("issue_id = ?", issueID).
|
||||||
|
Find(&watches)
|
||||||
|
return
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
// 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 (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateOrUpdateIssueWatch(t *testing.T) {
|
||||||
|
assert.NoError(t, PrepareTestDatabase())
|
||||||
|
|
||||||
|
assert.NoError(t, CreateOrUpdateIssueWatch(3, 1, true))
|
||||||
|
iw := AssertExistsAndLoadBean(t, &IssueWatch{UserID: 3, IssueID: 1}).(*IssueWatch)
|
||||||
|
assert.Equal(t, true, iw.IsWatching)
|
||||||
|
|
||||||
|
assert.NoError(t, CreateOrUpdateIssueWatch(1, 1, false))
|
||||||
|
iw = AssertExistsAndLoadBean(t, &IssueWatch{UserID: 1, IssueID: 1}).(*IssueWatch)
|
||||||
|
assert.Equal(t, false, iw.IsWatching)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIssueWatch(t *testing.T) {
|
||||||
|
assert.NoError(t, PrepareTestDatabase())
|
||||||
|
|
||||||
|
_, exists, err := GetIssueWatch(1, 1)
|
||||||
|
assert.Equal(t, true, exists)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, exists, err = GetIssueWatch(2, 2)
|
||||||
|
assert.Equal(t, true, exists)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, exists, err = GetIssueWatch(3, 1)
|
||||||
|
assert.Equal(t, false, exists)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIssueWatchers(t *testing.T) {
|
||||||
|
assert.NoError(t, PrepareTestDatabase())
|
||||||
|
|
||||||
|
iws, err := GetIssueWatchers(1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(iws))
|
||||||
|
|
||||||
|
iws, err = GetIssueWatchers(5)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(iws))
|
||||||
|
}
|
|
@ -117,6 +117,7 @@ func init() {
|
||||||
new(ExternalLoginUser),
|
new(ExternalLoginUser),
|
||||||
new(ProtectedBranch),
|
new(ProtectedBranch),
|
||||||
new(UserOpenID),
|
new(UserOpenID),
|
||||||
|
new(IssueWatch),
|
||||||
)
|
)
|
||||||
|
|
||||||
gonicNames := []string{"SSL", "UID"}
|
gonicNames := []string{"SSL", "UID"}
|
||||||
|
|
|
@ -96,6 +96,11 @@ func CreateOrUpdateIssueNotifications(issue *Issue, notificationAuthorID int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthorID int64) error {
|
func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthorID int64) error {
|
||||||
|
issueWatches, err := getIssueWatchers(e, issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
watches, err := getWatchers(e, issue.RepoID)
|
watches, err := getWatchers(e, issue.RepoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -106,23 +111,42 @@ func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthor
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, watch := range watches {
|
alreadyNotified := make(map[int64]struct{}, len(issueWatches)+len(watches))
|
||||||
|
|
||||||
|
notifyUser := func(userID int64) error {
|
||||||
// do not send notification for the own issuer/commenter
|
// do not send notification for the own issuer/commenter
|
||||||
if watch.UserID == notificationAuthorID {
|
if userID == notificationAuthorID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := alreadyNotified[userID]; ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
alreadyNotified[userID] = struct{}{}
|
||||||
|
|
||||||
|
if notificationExists(notifications, issue.ID, userID) {
|
||||||
|
return updateIssueNotification(e, userID, issue.ID, notificationAuthorID)
|
||||||
|
}
|
||||||
|
return createIssueNotification(e, userID, issue, notificationAuthorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, issueWatch := range issueWatches {
|
||||||
|
// ignore if user unwatched the issue
|
||||||
|
if !issueWatch.IsWatching {
|
||||||
|
alreadyNotified[issueWatch.UserID] = struct{}{}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if notificationExists(notifications, issue.ID, watch.UserID) {
|
if err := notifyUser(issueWatch.UserID); err != nil {
|
||||||
err = updateIssueNotification(e, watch.UserID, issue.ID, notificationAuthorID)
|
|
||||||
} else {
|
|
||||||
err = createIssueNotification(e, watch.UserID, issue, notificationAuthorID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, watch := range watches {
|
||||||
|
if err := notifyUser(watch.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -652,6 +652,8 @@ issues.label.filter_sort.reverse_alphabetically = Reverse alphabetically
|
||||||
issues.num_participants = %d Participants
|
issues.num_participants = %d Participants
|
||||||
issues.attachment.open_tab = `Click to see "%s" in a new tab`
|
issues.attachment.open_tab = `Click to see "%s" in a new tab`
|
||||||
issues.attachment.download = `Click to download "%s"`
|
issues.attachment.download = `Click to download "%s"`
|
||||||
|
issues.subscribe = Subscribe
|
||||||
|
issues.unsubscribe = Unsubscribe
|
||||||
|
|
||||||
pulls.new = New Pull Request
|
pulls.new = New Pull Request
|
||||||
pulls.compare_changes = Compare Changes
|
pulls.compare_changes = Compare Changes
|
||||||
|
|
|
@ -465,6 +465,20 @@ func ViewIssue(ctx *context.Context) {
|
||||||
}
|
}
|
||||||
ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, issue.Title)
|
ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, issue.Title)
|
||||||
|
|
||||||
|
iw, exists, err := models.GetIssueWatch(ctx.User.ID, issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Handle(500, "GetIssueWatch", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
iw = &models.IssueWatch{
|
||||||
|
UserID: ctx.User.ID,
|
||||||
|
IssueID: issue.ID,
|
||||||
|
IsWatching: models.IsWatching(ctx.User.ID, ctx.Repo.Repository.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Data["IssueWatch"] = iw
|
||||||
|
|
||||||
// Make sure type and URL matches.
|
// Make sure type and URL matches.
|
||||||
if ctx.Params(":type") == "issues" && issue.IsPull {
|
if ctx.Params(":type") == "issues" && issue.IsPull {
|
||||||
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
|
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
// 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 repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssueWatch sets issue watching
|
||||||
|
func IssueWatch(c *context.Context) {
|
||||||
|
watch, err := strconv.ParseBool(c.Req.PostForm.Get("watch"))
|
||||||
|
if err != nil {
|
||||||
|
c.Handle(http.StatusInternalServerError, "watch is not bool", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issueIndex := c.ParamsInt64("index")
|
||||||
|
issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex)
|
||||||
|
if err != nil {
|
||||||
|
c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.CreateOrUpdateIssueWatch(c.User.ID, issue.ID, watch); err != nil {
|
||||||
|
c.Handle(http.StatusInternalServerError, "CreateOrUpdateIssueWatch", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/issues/%d", c.Repo.RepoLink, issueIndex)
|
||||||
|
c.Redirect(url, http.StatusSeeOther)
|
||||||
|
}
|
|
@ -98,5 +98,26 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
|
||||||
|
<div class="ui watching">
|
||||||
|
<span class="text"><strong>{{.i18n.Tr "notification.notifications"}}</strong></span>
|
||||||
|
<div>
|
||||||
|
<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/watch">
|
||||||
|
<input type="hidden" name="watch" value="{{if $.IssueWatch.IsWatching}}0{{else}}1{{end}}" />
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
<button class="fluid ui button">
|
||||||
|
{{if $.IssueWatch.IsWatching}}
|
||||||
|
<i class="octicon octicon-mute"></i>
|
||||||
|
{{.i18n.Tr "repo.issues.unsubscribe"}}
|
||||||
|
{{else}}
|
||||||
|
<i class="octicon octicon-unmute"></i>
|
||||||
|
{{.i18n.Tr "repo.issues.subscribe"}}
|
||||||
|
{{end}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in New Issue