diff --git a/cmd/web.go b/cmd/web.go index 1f2561ca68..b2cc3959a2 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -491,6 +491,7 @@ func runWeb(ctx *cli.Context) error { m.Group("/:index", func() { m.Post("/title", repo.UpdateIssueTitle) m.Post("/content", repo.UpdateIssueContent) + m.Post("/watch", repo.IssueWatch) m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment) }) diff --git a/models/fixtures/issue_watch.yml b/models/fixtures/issue_watch.yml new file mode 100644 index 0000000000..596662d20c --- /dev/null +++ b/models/fixtures/issue_watch.yml @@ -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 diff --git a/models/issue_watch.go b/models/issue_watch.go new file mode 100644 index 0000000000..37511787e5 --- /dev/null +++ b/models/issue_watch.go @@ -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 +} diff --git a/models/issue_watch_test.go b/models/issue_watch_test.go new file mode 100644 index 0000000000..d8b456c3ae --- /dev/null +++ b/models/issue_watch_test.go @@ -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)) +} diff --git a/models/models.go b/models/models.go index 2ae6e355fc..a1332ac23e 100644 --- a/models/models.go +++ b/models/models.go @@ -117,6 +117,7 @@ func init() { new(ExternalLoginUser), new(ProtectedBranch), new(UserOpenID), + new(IssueWatch), ) gonicNames := []string{"SSL", "UID"} diff --git a/models/notification.go b/models/notification.go index bba662c06c..a59c6f1401 100644 --- a/models/notification.go +++ b/models/notification.go @@ -96,6 +96,11 @@ func CreateOrUpdateIssueNotifications(issue *Issue, notificationAuthorID int64) } 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) if err != nil { return err @@ -106,23 +111,42 @@ func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthor 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 - 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 } - if notificationExists(notifications, issue.ID, watch.UserID) { - err = updateIssueNotification(e, watch.UserID, issue.ID, notificationAuthorID) - } else { - err = createIssueNotification(e, watch.UserID, issue, notificationAuthorID) - } - - if err != nil { + if err := notifyUser(issueWatch.UserID); err != nil { return err } } + for _, watch := range watches { + if err := notifyUser(watch.UserID); err != nil { + return err + } + } return nil } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 80260d4b7d..35a5244940 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -652,6 +652,8 @@ issues.label.filter_sort.reverse_alphabetically = Reverse alphabetically issues.num_participants = %d Participants issues.attachment.open_tab = `Click to see "%s" in a new tab` issues.attachment.download = `Click to download "%s"` +issues.subscribe = Subscribe +issues.unsubscribe = Unsubscribe pulls.new = New Pull Request pulls.compare_changes = Compare Changes diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 0a723d755b..61f79a239c 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -465,6 +465,20 @@ func ViewIssue(ctx *context.Context) { } 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. if ctx.Params(":type") == "issues" && issue.IsPull { ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index)) diff --git a/routers/repo/issue_watch.go b/routers/repo/issue_watch.go new file mode 100644 index 0000000000..382798025e --- /dev/null +++ b/routers/repo/issue_watch.go @@ -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) +} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index ea46e5f94d..28bd755e41 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -98,5 +98,26 @@ {{end}} + +
+ +
+ {{.i18n.Tr "notification.notifications"}} +
+
+ + {{$.CsrfTokenHtml}} + +
+
+