[Notifications Step 6] Per issue/PR watch/unwatchmaster
@@ -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 { | |||||
continue | |||||
if userID == notificationAuthorID { | |||||
return nil | |||||
} | } | ||||
if notificationExists(notifications, issue.ID, watch.UserID) { | |||||
err = updateIssueNotification(e, watch.UserID, issue.ID, notificationAuthorID) | |||||
} else { | |||||
err = createIssueNotification(e, watch.UserID, issue, notificationAuthorID) | |||||
if _, ok := alreadyNotified[userID]; ok { | |||||
return nil | |||||
} | } | ||||
alreadyNotified[userID] = struct{}{} | |||||
if err != nil { | |||||
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 | |||||
} | |||||
if err := notifyUser(issueWatch.UserID); 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> |