[Notifications Step 6] Per issue/PR watch/unwatchtags/v1.21.12.1
| @@ -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) | |||
| }) | |||
| @@ -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(ProtectedBranch), | |||
| new(UserOpenID), | |||
| new(IssueWatch), | |||
| ) | |||
| gonicNames := []string{"SSL", "UID"} | |||
| @@ -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 { | |||
| 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 | |||
| } | |||
| } | |||
| for _, watch := range watches { | |||
| if err := notifyUser(watch.UserID); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| return nil | |||
| } | |||
| @@ -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 | |||
| @@ -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)) | |||
| @@ -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}} | |||
| </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> | |||