diff --git a/cmd/web.go b/cmd/web.go index dfb3ac2385461..b8be2ba2dbef5 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -159,6 +159,8 @@ func runWeb(ctx *cli.Context) error { bindIgnErr := binding.BindIgnErr + m.Use(user.GetNotificationCount) + // FIXME: not all routes need go through same middlewares. // Especially some AJAX requests, we can reduce middleware number to improve performance. // Routers. @@ -562,6 +564,8 @@ func runWeb(ctx *cli.Context) error { }) // ***** END: Repository ***** + m.Get("/notifications", reqSignIn, user.Notifications) + m.Group("/api", func() { apiv1.RegisterRoutes(m) }, ignSignIn) diff --git a/conf/locale/locale_en-US.ini b/conf/locale/locale_en-US.ini index 654498cd788dd..e5bd93e18c6b5 100644 --- a/conf/locale/locale_en-US.ini +++ b/conf/locale/locale_en-US.ini @@ -13,6 +13,7 @@ version = Version page = Page template = Template language = Language +notifications = Notifications create_new = Create... user_profile_and_more = User profile and more signed_in_as = Signed in as @@ -1207,3 +1208,10 @@ default_message = Drop files here or click to upload. invalid_input_type = You can't upload files of this type. file_too_big = File size ({{filesize}} MB) exceeds maximum size ({{maxFilesize}} MB). remove_file = Remove file + +[notification] +notifications = Notifications +unread = Unread +read = Read +no_unread = You have no unread notifications. +no_read = You have no read notifications. diff --git a/conf/locale/locale_pt-BR.ini b/conf/locale/locale_pt-BR.ini index f0349b1119a6e..95a7db12f8e9f 100644 --- a/conf/locale/locale_pt-BR.ini +++ b/conf/locale/locale_pt-BR.ini @@ -13,6 +13,7 @@ version=Versão page=Página template=Template language=Idioma +notifications = Notificações create_new=Criar... user_profile_and_more=Perfil do usuário e configurações signed_in_as=Logado como @@ -1198,3 +1199,9 @@ invalid_input_type=Você não pode enviar arquivos deste tipo. file_too_big=O tamanho do arquivo ({{filesize}} MB) excede o limite máximo ({{maxFilesize}} MB). remove_file=Remover +[notification] +notifications = Notificações +unread = Não lidas +read = Lidas +no_unread = Você não possui notificações não lidas. +no_read = Você não possui notificações lidas. diff --git a/models/issue.go b/models/issue.go index 4937bf1b56d47..114714eeaf083 100644 --- a/models/issue.go +++ b/models/issue.go @@ -416,8 +416,8 @@ func (issue *Issue) GetAssignee() (err error) { } // ReadBy sets issue to be read by given user. -func (issue *Issue) ReadBy(uid int64) error { - return UpdateIssueUserByRead(uid, issue.ID) +func (issue *Issue) ReadBy(userID int64) error { + return setNotificationStatusRead(x, userID, issue.ID) } func updateIssueCols(e Engine, issue *Issue, cols ...string) error { @@ -439,8 +439,6 @@ func (issue *Issue) changeStatus(e *xorm.Session, doer *User, repo *Repository, if err = updateIssueCols(e, issue, "is_closed"); err != nil { return err - } else if err = updateIssueUsersByStatus(e, issue.ID, isClosed); err != nil { - return err } // Update issue count of labels @@ -468,6 +466,10 @@ func (issue *Issue) changeStatus(e *xorm.Session, doer *User, repo *Repository, return err } + if err := createOrUpdateIssueNotifications(e, issue, doer.ID); err != nil { + return err + } + return nil } @@ -579,10 +581,6 @@ func (issue *Issue) ChangeContent(doer *User, content string) (err error) { // ChangeAssignee changes the Asssignee field of this issue. func (issue *Issue) ChangeAssignee(doer *User, assigneeID int64) (err error) { issue.AssigneeID = assigneeID - if err = UpdateIssueUserByAssignee(issue); err != nil { - return fmt.Errorf("UpdateIssueUserByAssignee: %v", err) - } - issue.Assignee, err = GetUserByID(issue.AssigneeID) if err != nil && !IsErrUserNotExist(err) { log.Error(4, "GetUserByID [assignee_id: %v]: %v", issue.AssigneeID, err) @@ -699,10 +697,6 @@ func newIssue(e *xorm.Session, opts NewIssueOptions) (err error) { } } - if err = newIssueUsers(e, opts.Repo, opts.Issue); err != nil { - return err - } - if len(opts.Attachments) > 0 { attachments, err := getAttachmentsByUUIDs(e, opts.Attachments) if err != nil { @@ -717,6 +711,10 @@ func newIssue(e *xorm.Session, opts NewIssueOptions) (err error) { } } + if err := createOrUpdateIssueNotifications(e, opts.Issue, opts.Issue.PosterID); err != nil { + return err + } + return opts.Issue.loadAttributes(e) } @@ -921,181 +919,6 @@ func Issues(opts *IssuesOptions) ([]*Issue, error) { return issues, nil } -// .___ ____ ___ -// | | ______ ________ __ ____ | | \______ ___________ -// | |/ ___// ___/ | \_/ __ \| | / ___// __ \_ __ \ -// | |\___ \ \___ \| | /\ ___/| | /\___ \\ ___/| | \/ -// |___/____ >____ >____/ \___ >______//____ >\___ >__| -// \/ \/ \/ \/ \/ - -// IssueUser represents an issue-user relation. -type IssueUser struct { - ID int64 `xorm:"pk autoincr"` - UID int64 `xorm:"INDEX"` // User ID. - IssueID int64 - RepoID int64 `xorm:"INDEX"` - MilestoneID int64 - IsRead bool - IsAssigned bool - IsMentioned bool - IsPoster bool - IsClosed bool -} - -func newIssueUsers(e *xorm.Session, repo *Repository, issue *Issue) error { - assignees, err := repo.getAssignees(e) - if err != nil { - return fmt.Errorf("getAssignees: %v", err) - } - - // Poster can be anyone, append later if not one of assignees. - isPosterAssignee := false - - // Leave a seat for poster itself to append later, but if poster is one of assignee - // and just waste 1 unit is cheaper than re-allocate memory once. - issueUsers := make([]*IssueUser, 0, len(assignees)+1) - for _, assignee := range assignees { - isPoster := assignee.ID == issue.PosterID - issueUsers = append(issueUsers, &IssueUser{ - IssueID: issue.ID, - RepoID: repo.ID, - UID: assignee.ID, - IsPoster: isPoster, - IsAssigned: assignee.ID == issue.AssigneeID, - }) - if !isPosterAssignee && isPoster { - isPosterAssignee = true - } - } - if !isPosterAssignee { - issueUsers = append(issueUsers, &IssueUser{ - IssueID: issue.ID, - RepoID: repo.ID, - UID: issue.PosterID, - IsPoster: true, - }) - } - - if _, err = e.Insert(issueUsers); err != nil { - return err - } - return nil -} - -// NewIssueUsers adds new issue-user relations for new issue of repository. -func NewIssueUsers(repo *Repository, issue *Issue) (err error) { - sess := x.NewSession() - defer sessionRelease(sess) - if err = sess.Begin(); err != nil { - return err - } - - if err = newIssueUsers(sess, repo, issue); err != nil { - return err - } - - return sess.Commit() -} - -// PairsContains returns true when pairs list contains given issue. -func PairsContains(ius []*IssueUser, issueID, uid int64) int { - for i := range ius { - if ius[i].IssueID == issueID && - ius[i].UID == uid { - return i - } - } - return -1 -} - -// GetIssueUsers returns issue-user pairs by given repository and user. -func GetIssueUsers(rid, uid int64, isClosed bool) ([]*IssueUser, error) { - ius := make([]*IssueUser, 0, 10) - err := x.Where("is_closed=?", isClosed).Find(&ius, &IssueUser{RepoID: rid, UID: uid}) - return ius, err -} - -// GetIssueUserPairsByRepoIds returns issue-user pairs by given repository IDs. -func GetIssueUserPairsByRepoIds(rids []int64, isClosed bool, page int) ([]*IssueUser, error) { - if len(rids) == 0 { - return []*IssueUser{}, nil - } - - ius := make([]*IssueUser, 0, 10) - sess := x. - Limit(20, (page-1)*20). - Where("is_closed=?", isClosed). - In("repo_id", rids) - err := sess.Find(&ius) - return ius, err -} - -// GetIssueUserPairsByMode returns issue-user pairs by given repository and user. -func GetIssueUserPairsByMode(uid, rid int64, isClosed bool, page, filterMode int) ([]*IssueUser, error) { - ius := make([]*IssueUser, 0, 10) - sess := x. - Limit(20, (page-1)*20). - Where("uid=?", uid). - And("is_closed=?", isClosed) - if rid > 0 { - sess.And("repo_id=?", rid) - } - - switch filterMode { - case FilterModeAssign: - sess.And("is_assigned=?", true) - case FilterModeCreate: - sess.And("is_poster=?", true) - default: - return ius, nil - } - err := sess.Find(&ius) - return ius, err -} - -// UpdateIssueMentions extracts mentioned people from content and -// updates issue-user relations for them. -func UpdateIssueMentions(issueID int64, mentions []string) error { - if len(mentions) == 0 { - return nil - } - - for i := range mentions { - mentions[i] = strings.ToLower(mentions[i]) - } - users := make([]*User, 0, len(mentions)) - - if err := x.In("lower_name", mentions).Asc("lower_name").Find(&users); err != nil { - return fmt.Errorf("find mentioned users: %v", err) - } - - ids := make([]int64, 0, len(mentions)) - for _, user := range users { - ids = append(ids, user.ID) - if !user.IsOrganization() || user.NumMembers == 0 { - continue - } - - memberIDs := make([]int64, 0, user.NumMembers) - orgUsers, err := GetOrgUsersByOrgID(user.ID) - if err != nil { - return fmt.Errorf("GetOrgUsersByOrgID [%d]: %v", user.ID, err) - } - - for _, orgUser := range orgUsers { - memberIDs = append(memberIDs, orgUser.ID) - } - - ids = append(ids, memberIDs...) - } - - if err := UpdateIssueUsersByMentions(issueID, ids); err != nil { - return fmt.Errorf("UpdateIssueUsersByMentions: %v", err) - } - - return nil -} - // IssueStats represents issue statistic information. type IssueStats struct { OpenCount, ClosedCount int64 @@ -1283,77 +1106,6 @@ func UpdateIssue(issue *Issue) error { return updateIssue(x, issue) } -func updateIssueUsersByStatus(e Engine, issueID int64, isClosed bool) error { - _, err := e.Exec("UPDATE `issue_user` SET is_closed=? WHERE issue_id=?", isClosed, issueID) - return err -} - -// UpdateIssueUsersByStatus updates issue-user relations by issue status. -func UpdateIssueUsersByStatus(issueID int64, isClosed bool) error { - return updateIssueUsersByStatus(x, issueID, isClosed) -} - -func updateIssueUserByAssignee(e *xorm.Session, issue *Issue) (err error) { - if _, err = e.Exec("UPDATE `issue_user` SET is_assigned = ? WHERE issue_id = ?", false, issue.ID); err != nil { - return err - } - - // Assignee ID equals to 0 means clear assignee. - if issue.AssigneeID > 0 { - if _, err = e.Exec("UPDATE `issue_user` SET is_assigned = ? WHERE uid = ? AND issue_id = ?", true, issue.AssigneeID, issue.ID); err != nil { - return err - } - } - - return updateIssue(e, issue) -} - -// UpdateIssueUserByAssignee updates issue-user relation for assignee. -func UpdateIssueUserByAssignee(issue *Issue) (err error) { - sess := x.NewSession() - defer sessionRelease(sess) - if err = sess.Begin(); err != nil { - return err - } - - if err = updateIssueUserByAssignee(sess, issue); err != nil { - return err - } - - return sess.Commit() -} - -// UpdateIssueUserByRead updates issue-user relation for reading. -func UpdateIssueUserByRead(uid, issueID int64) error { - _, err := x.Exec("UPDATE `issue_user` SET is_read=? WHERE uid=? AND issue_id=?", true, uid, issueID) - return err -} - -// UpdateIssueUsersByMentions updates issue-user pairs by mentioning. -func UpdateIssueUsersByMentions(issueID int64, uids []int64) error { - for _, uid := range uids { - iu := &IssueUser{ - UID: uid, - IssueID: issueID, - } - has, err := x.Get(iu) - if err != nil { - return err - } - - iu.IsMentioned = true - if has { - _, err = x.Id(iu.ID).AllCols().Update(iu) - } else { - _, err = x.Insert(iu) - } - if err != nil { - return err - } - } - return nil -} - // _____ .__.__ __ // / \ |__| | ____ _______/ |_ ____ ____ ____ // / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \ diff --git a/models/issue_comment.go b/models/issue_comment.go index f0fc22af34f49..7b768ed62fbee 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -148,9 +148,6 @@ func (c *Comment) EventTag() string { // and mentioned people. func (c *Comment) MailParticipants(opType ActionType, issue *Issue) (err error) { mentions := markdown.FindAllMentions(c.Content) - if err = UpdateIssueMentions(c.IssueID, mentions); err != nil { - return fmt.Errorf("UpdateIssueMentions [%d]: %v", c.IssueID, err) - } switch opType { case ActionCommentIssue: diff --git a/models/issue_mail.go b/models/issue_mail.go index 56c1e0c278e70..8796379469420 100644 --- a/models/issue_mail.go +++ b/models/issue_mail.go @@ -69,9 +69,6 @@ func mailIssueCommentToParticipants(issue *Issue, doer *User, mentions []string) // and mentioned people. func (issue *Issue) MailParticipants() (err error) { mentions := markdown.FindAllMentions(issue.Content) - if err = UpdateIssueMentions(issue.ID, mentions); err != nil { - return fmt.Errorf("UpdateIssueMentions [%d]: %v", issue.ID, err) - } if err = mailIssueCommentToParticipants(issue, issue.Poster, mentions); err != nil { log.Error(4, "mailIssueCommentToParticipants: %v", err) diff --git a/models/models.go b/models/models.go index 56306d61f5afe..c633456a617a3 100644 --- a/models/models.go +++ b/models/models.go @@ -68,15 +68,39 @@ var ( func init() { tables = append(tables, - new(User), new(PublicKey), new(AccessToken), - new(Repository), new(DeployKey), new(Collaboration), new(Access), new(Upload), - new(Watch), new(Star), new(Follow), new(Action), - new(Issue), new(PullRequest), new(Comment), new(Attachment), new(IssueUser), - new(Label), new(IssueLabel), new(Milestone), - new(Mirror), new(Release), new(LoginSource), new(Webhook), - new(UpdateTask), new(HookTask), - new(Team), new(OrgUser), new(TeamUser), new(TeamRepo), - new(Notice), new(EmailAddress)) + new(User), + new(PublicKey), + new(AccessToken), + new(Repository), + new(DeployKey), + new(Collaboration), + new(Access), + new(Upload), + new(Watch), + new(Star), + new(Follow), + new(Action), + new(Issue), + new(PullRequest), + new(Comment), + new(Attachment), + new(Label), + new(IssueLabel), + new(Milestone), + new(Mirror), + new(Release), + new(LoginSource), + new(Webhook), + new(UpdateTask), + new(HookTask), + new(Team), + new(OrgUser), + new(TeamUser), + new(TeamRepo), + new(Notice), + new(EmailAddress), + new(Notification), + ) gonicNames := []string{"SSL", "UID"} for _, name := range gonicNames { diff --git a/models/notification.go b/models/notification.go new file mode 100644 index 0000000000000..379f904569e61 --- /dev/null +++ b/models/notification.go @@ -0,0 +1,233 @@ +package models + +import ( + "time" +) + +type ( + // NotificationStatus is the status of the notification (read or unread) + NotificationStatus uint8 + // NotificationSource is the source of the notification (issue, PR, commit, etc) + NotificationSource uint8 +) + +const ( + // NotificationStatusUnread represents an unread notification + NotificationStatusUnread NotificationStatus = iota + 1 + // NotificationStatusRead represents a read notification + NotificationStatusRead +) + +const ( + // NotificationSourceIssue is a notification of an issue + NotificationSourceIssue NotificationSource = iota + 1 + // NotificationSourcePullRequest is a notification of a pull request + NotificationSourcePullRequest + // NotificationSourceCommit is a notification of a commit + NotificationSourceCommit +) + +// Notification represents a notification +type Notification struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX NOT NULL"` + RepoID int64 `xorm:"INDEX NOT NULL"` + + Status NotificationStatus `xorm:"SMALLINT INDEX NOT NULL"` + Source NotificationSource `xorm:"SMALLINT INDEX NOT NULL"` + + IssueID int64 `xorm:"INDEX NOT NULL"` + PullID int64 `xorm:"INDEX"` + CommitID string `xorm:"INDEX"` + + Issue *Issue `xorm:"-"` + PullRequest *PullRequest `xorm:"-"` + + Created time.Time `xorm:"-"` + CreatedUnix int64 `xorm:"INDEX NOT NULL"` + Updated time.Time `xorm:"-"` + UpdatedUnix int64 `xorm:"INDEX NOT NULL"` +} + +// BeforeInsert runs while inserting a record +func (n *Notification) BeforeInsert() { + var ( + now = time.Now() + nowUnix = now.Unix() + ) + n.Created = now + n.CreatedUnix = nowUnix + n.Updated = now + n.UpdatedUnix = nowUnix +} + +// BeforeUpdate runs while updateing a record +func (n *Notification) BeforeUpdate() { + var ( + now = time.Now() + nowUnix = now.Unix() + ) + n.Updated = now + n.UpdatedUnix = nowUnix +} + +// CreateOrUpdateIssueNotifications creates an issue notification +// for each watcher, or updates it if already exists +func CreateOrUpdateIssueNotifications(issue *Issue, notificationAuthorID int64) error { + sess := x.NewSession() + if err := sess.Begin(); err != nil { + return err + } + defer sess.Close() + + if err := createOrUpdateIssueNotifications(sess, issue, notificationAuthorID); err != nil { + return err + } + + return sess.Commit() +} + +func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthorID int64) error { + watches, err := getWatchers(e, issue.RepoID) + if err != nil { + return err + } + + for _, watch := range watches { + // do not send notification for the own issuer/commenter + if watch.UserID == notificationAuthorID { + continue + } + + exists, err := issueNotificationExists(e, watch.UserID, issue.ID) + if err != nil { + return err + } + + if exists { + err = updateIssueNotification(e, watch.UserID, issue.ID) + } else { + err = createIssueNotification(e, watch.UserID, issue) + } + + if err != nil { + return err + } + } + + return nil +} + +func issueNotificationExists(e Engine, userID, issueID int64) (bool, error) { + count, err := e. + Where("user_id = ?", userID). + And("issue_id = ?", issueID). + Count(Notification{}) + return count > 0, err +} + +func createIssueNotification(e Engine, userID int64, issue *Issue) error { + notification := &Notification{ + UserID: userID, + RepoID: issue.RepoID, + Status: NotificationStatusUnread, + IssueID: issue.ID, + } + + if issue.IsPull { + notification.Source = NotificationSourcePullRequest + } else { + notification.Source = NotificationSourceIssue + } + + _, err := e.Insert(notification) + return err +} + +func updateIssueNotification(e Engine, userID, issueID int64) error { + notification, err := getIssueNotification(e, userID, issueID) + if err != nil { + return err + } + + notification.Status = NotificationStatusUnread + + _, err = e.Id(notification.ID).Update(notification) + return err +} + +func getIssueNotification(e Engine, userID, issueID int64) (*Notification, error) { + notification := new(Notification) + _, err := e. + Where("user_id = ?", userID). + And("issue_id = ?", issueID). + Get(notification) + return notification, err +} + +// NotificationsForUser returns notifications for a given user and status +func NotificationsForUser(user *User, status NotificationStatus) ([]*Notification, error) { + return notificationsForUser(x, user, status) +} +func notificationsForUser(e Engine, user *User, status NotificationStatus) (notifications []*Notification, err error) { + err = e. + Where("user_id = ?", user.ID). + And("status = ?", status). + OrderBy("updated_unix DESC"). + Find(¬ifications) + return +} + +// GetRepo returns the repo of the notification +func (n *Notification) GetRepo() (repo *Repository, err error) { + repo = new(Repository) + _, err = x. + Where("id = ?", n.RepoID). + Get(repo) + return +} + +// GetIssue returns the issue of the notification +func (n *Notification) GetIssue() (issue *Issue, err error) { + issue = new(Issue) + _, err = x. + Where("id = ?", n.IssueID). + Get(issue) + return +} + +// GetNotificationReadCount returns the notification read count for user +func GetNotificationReadCount(user *User) (int64, error) { + return GetNotificationCount(user, NotificationStatusRead) +} + +// GetNotificationUnreadCount returns the notification unread count for user +func GetNotificationUnreadCount(user *User) (int64, error) { + return GetNotificationCount(user, NotificationStatusUnread) +} + +// GetNotificationCount returns the notification count for user +func GetNotificationCount(user *User, status NotificationStatus) (int64, error) { + return getNotificationCount(x, user, status) +} + +func getNotificationCount(e Engine, user *User, status NotificationStatus) (count int64, err error) { + count, err = e. + Where("user_id = ?", user.ID). + And("status = ?", status). + Count(&Notification{}) + return +} + +func setNotificationStatusRead(e Engine, userID, issueID int64) error { + notification, err := getIssueNotification(e, userID, issueID) + // ignore if not exists + if err != nil { + return nil + } + + notification.Status = NotificationStatusRead + + _, err = e.Id(notification.ID).Update(notification) + return err +} diff --git a/models/repo.go b/models/repo.go index 603902a19f9c1..1df71c9de5678 100644 --- a/models/repo.go +++ b/models/repo.go @@ -1401,7 +1401,7 @@ func DeleteRepository(uid, repoID int64) error { &Watch{RepoID: repoID}, &Star{RepoID: repoID}, &Mirror{RepoID: repoID}, - &IssueUser{RepoID: repoID}, + &Notification{RepoID: repoID}, &Milestone{RepoID: repoID}, &Release{RepoID: repoID}, &Collaboration{RepoID: repoID}, diff --git a/models/user.go b/models/user.go index 40afc484913e7..e32e73f0c0961 100644 --- a/models/user.go +++ b/models/user.go @@ -840,7 +840,7 @@ func deleteUser(e *xorm.Session, u *User) error { &Star{UID: u.ID}, &Follow{FollowID: u.ID}, &Action{UserID: u.ID}, - &IssueUser{UID: u.ID}, + &Notification{UserID: u.ID}, &EmailAddress{UID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %v", err) diff --git a/modules/notification/notification.go b/modules/notification/notification.go new file mode 100644 index 0000000000000..894e637319b54 --- /dev/null +++ b/modules/notification/notification.go @@ -0,0 +1,46 @@ +package notification + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" +) + +type ( + notificationService struct { + issueQueue chan issueNotificationOpts + } + + issueNotificationOpts struct { + issue *models.Issue + notificationAuthorID int64 + } +) + +var ( + // Service is the notification service + Service = ¬ificationService{ + issueQueue: make(chan issueNotificationOpts, 100), + } +) + +func init() { + go Service.Run() +} + +func (ns *notificationService) Run() { + for { + select { + case opts := <-ns.issueQueue: + if err := models.CreateOrUpdateIssueNotifications(opts.issue, opts.notificationAuthorID); err != nil { + log.Error(4, "Was unable to create issue notification: %v", err) + } + } + } +} + +func (ns *notificationService) NotifyIssue(issue *models.Issue, notificationAuthorID int64) { + ns.issueQueue <- issueNotificationOpts{ + issue, + notificationAuthorID, + } +} diff --git a/public/css/index.css b/public/css/index.css index a05802118fcfb..f7be3a52fb3e0 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -2691,6 +2691,24 @@ footer .ui.language .menu { .user.followers .follow .ui.button { padding: 8px 15px; } +.user.notification .octicon { + float: left; + font-size: 2em; +} +.user.notification .content { + float: left; + margin-left: 7px; +} +.user.notification .octicon-issue-opened, +.user.notification .octicon-git-pull-request { + color: green; +} +.user.notification .octicon-issue-closed { + color: red; +} +.user.notification .octicon-git-merge { + color: purple; +} .dashboard { padding-top: 15px; padding-bottom: 80px; diff --git a/public/less/_user.less b/public/less/_user.less index 3e37011cfb8e4..d7acc4639ec9a 100644 --- a/public/less/_user.less +++ b/public/less/_user.less @@ -74,4 +74,25 @@ } } } + + &.notification { + .octicon { + float: left; + font-size: 2em; + } + .content { + float: left; + margin-left: 7px; + } + + .octicon-issue-opened, .octicon-git-pull-request { + color: green; + } + .octicon-issue-closed { + color: red; + } + .octicon-git-merge { + color: purple; + } + } } diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 908b5aeb961f5..a10a8592379bc 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -156,11 +156,6 @@ func EditIssue(ctx *context.APIContext, form api.EditIssueOption) { } issue.AssigneeID = assignee.ID } - - if err = models.UpdateIssueUserByAssignee(issue); err != nil { - ctx.Error(500, "UpdateIssueUserByAssignee", err) - return - } } if ctx.Repo.IsWriter() && form.Milestone != nil && issue.MilestoneID != *form.Milestone { diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index 4c10024bfb95d..a63708f0483d4 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -231,11 +231,6 @@ func EditPullRequest(ctx *context.APIContext, form api.EditPullRequestOption) { } issue.AssigneeID = assignee.ID } - - if err = models.UpdateIssueUserByAssignee(issue); err != nil { - ctx.Error(500, "UpdateIssueUserByAssignee", err) - return - } } if ctx.Repo.IsWriter() && form.Milestone != 0 && issue.MilestoneID != form.Milestone { diff --git a/routers/repo/issue.go b/routers/repo/issue.go index e3c088d94024b..d1c72b104ea95 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markdown" + "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/setting" ) @@ -194,29 +195,6 @@ func Issues(ctx *context.Context) { ctx.Handle(500, "Issues", err) return } - - // Get issue-user relations. - pairs, err := models.GetIssueUsers(repo.ID, posterID, isShowClosed) - if err != nil { - ctx.Handle(500, "GetIssueUsers", err) - return - } - - // Get posters. - for i := range issues { - if !ctx.IsSigned { - issues[i].IsRead = true - continue - } - - // Check read status. - idx := models.PairsContains(pairs, issues[i].ID, ctx.User.ID) - if idx > -1 { - issues[i].IsRead = pairs[idx].IsRead - } else { - issues[i].IsRead = true - } - } ctx.Data["Issues"] = issues // Get milestones. @@ -453,6 +431,8 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) { return } + notification.Service.NotifyIssue(issue, ctx.User.ID) + log.Trace("Issue created: %d/%d", repo.ID, issue.ID) ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + com.ToStr(issue.Index)) } @@ -898,6 +878,8 @@ func NewComment(ctx *context.Context, form auth.CreateCommentForm) { return } + notification.Service.NotifyIssue(issue, ctx.User.ID) + log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) } diff --git a/routers/user/notification.go b/routers/user/notification.go new file mode 100644 index 0000000000000..13d2d70f8e7d0 --- /dev/null +++ b/routers/user/notification.go @@ -0,0 +1,54 @@ +package user + +import ( + "fmt" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" +) + +const ( + tplNotification base.TplName = "user/notification/notification" +) + +// GetNotificationCount is the middleware that sets the notification count in the context +func GetNotificationCount(c *context.Context) { + if !c.IsSigned { + return + } + + count, err := models.GetNotificationUnreadCount(c.User) + if err != nil { + c.Handle(500, "GetNotificationCount", err) + return + } + + c.Data["NotificationUnreadCount"] = count +} + +// Notifications is the notifications page +func Notifications(c *context.Context) { + var status models.NotificationStatus + switch c.Query("status") { + case "read": + status = models.NotificationStatusRead + default: + status = models.NotificationStatusUnread + } + + notifications, err := models.NotificationsForUser(c.User, status) + if err != nil { + c.Handle(500, "ErrNotificationsForUser", err) + return + } + + title := "Notifications" + if count := len(notifications); count > 0 { + title = fmt.Sprintf("(%d) %s", count, title) + } + c.Data["Title"] = title + c.Data["Status"] = status + c.Data["Notifications"] = notifications + c.HTML(200, tplNotification) +} diff --git a/routers/user/setting.go b/routers/user/setting.go index 1d405fba375ea..a766556960e14 100644 --- a/routers/user/setting.go +++ b/routers/user/setting.go @@ -29,7 +29,6 @@ const ( tplSettingsSocial base.TplName = "user/settings/social" tplSettingsApplications base.TplName = "user/settings/applications" tplSettingsDelete base.TplName = "user/settings/delete" - tplNotification base.TplName = "user/notification" tplSecurity base.TplName = "user/security" ) diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index 65a8fd3370c01..5a36328e07842 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -75,6 +75,18 @@ {{if .IsSigned}}