Adds GitHub-like pages to view watched repos and subscribed issues/PRs This is my second try to fix this, but it is better than the first since it doesn't uses a filter option which could be slow when accessing `/issues` or `/pulls` and it shows both pulls and issues (the first try is #17053). Closes #16111 Replaces and closes #17053 ![Screenshot](https://user-images.githubusercontent.com/80460567/134782937-3112f7da-425a-45b6-9511-5c9695aee896.png) Co-authored-by: Lauris BH <lauris@nix.lv> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>tags/v1.18.0-rc0
@@ -1186,6 +1186,7 @@ type IssuesOptions struct { //nolint | |||
PosterID int64 | |||
MentionedID int64 | |||
ReviewRequestedID int64 | |||
SubscriberID int64 | |||
MilestoneIDs []int64 | |||
ProjectID int64 | |||
ProjectBoardID int64 | |||
@@ -1299,6 +1300,10 @@ func (opts *IssuesOptions) setupSessionNoLimit(sess *xorm.Session) { | |||
applyReviewRequestedCondition(sess, opts.ReviewRequestedID) | |||
} | |||
if opts.SubscriberID > 0 { | |||
applySubscribedCondition(sess, opts.SubscriberID) | |||
} | |||
if len(opts.MilestoneIDs) > 0 { | |||
sess.In("issue.milestone_id", opts.MilestoneIDs) | |||
} | |||
@@ -1463,6 +1468,36 @@ func applyReviewRequestedCondition(sess *xorm.Session, reviewRequestedID int64) | |||
reviewRequestedID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, reviewRequestedID) | |||
} | |||
func applySubscribedCondition(sess *xorm.Session, subscriberID int64) *xorm.Session { | |||
return sess.And( | |||
builder. | |||
NotIn("issue.id", | |||
builder.Select("issue_id"). | |||
From("issue_watch"). | |||
Where(builder.Eq{"is_watching": false, "user_id": subscriberID}), | |||
), | |||
).And( | |||
builder.Or( | |||
builder.In("issue.id", builder. | |||
Select("issue_id"). | |||
From("issue_watch"). | |||
Where(builder.Eq{"is_watching": true, "user_id": subscriberID}), | |||
), | |||
builder.In("issue.id", builder. | |||
Select("issue_id"). | |||
From("comment"). | |||
Where(builder.Eq{"poster_id": subscriberID}), | |||
), | |||
builder.Eq{"issue.poster_id": subscriberID}, | |||
builder.In("issue.repo_id", builder. | |||
Select("id"). | |||
From("watch"). | |||
Where(builder.Eq{"user_id": subscriberID, "mode": true}), | |||
), | |||
), | |||
) | |||
} | |||
// CountIssuesByRepo map from repoID to number of issues matching the options | |||
func CountIssuesByRepo(opts *IssuesOptions) (map[int64]int64, error) { | |||
e := db.GetEngine(db.DefaultContext) |
@@ -3034,6 +3034,9 @@ pin = Pin notification | |||
mark_as_read = Mark as read | |||
mark_as_unread = Mark as unread | |||
mark_all_as_read = Mark all as read | |||
subscriptions = Subscriptions | |||
watching = Watching | |||
no_subscriptions = No subscriptions | |||
[gpg] | |||
default_key=Signed with default key |
@@ -13,16 +13,23 @@ import ( | |||
"strings" | |||
activities_model "code.gitea.io/gitea/models/activities" | |||
"code.gitea.io/gitea/models/db" | |||
issues_model "code.gitea.io/gitea/models/issues" | |||
repo_model "code.gitea.io/gitea/models/repo" | |||
"code.gitea.io/gitea/modules/base" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/structs" | |||
"code.gitea.io/gitea/modules/util" | |||
issue_service "code.gitea.io/gitea/services/issue" | |||
pull_service "code.gitea.io/gitea/services/pull" | |||
) | |||
const ( | |||
tplNotification base.TplName = "user/notification/notification" | |||
tplNotificationDiv base.TplName = "user/notification/notification_div" | |||
tplNotification base.TplName = "user/notification/notification" | |||
tplNotificationDiv base.TplName = "user/notification/notification_div" | |||
tplNotificationSubscriptions base.TplName = "user/notification/notification_subscriptions" | |||
) | |||
// GetNotificationCount is the middleware that sets the notification count in the context | |||
@@ -197,6 +204,208 @@ func NotificationPurgePost(c *context.Context) { | |||
c.Redirect(setting.AppSubURL+"/notifications", http.StatusSeeOther) | |||
} | |||
// NotificationSubscriptions returns the list of subscribed issues | |||
func NotificationSubscriptions(c *context.Context) { | |||
page := c.FormInt("page") | |||
if page < 1 { | |||
page = 1 | |||
} | |||
sortType := c.FormString("sort") | |||
c.Data["SortType"] = sortType | |||
state := c.FormString("state") | |||
if !util.IsStringInSlice(state, []string{"all", "open", "closed"}, true) { | |||
state = "all" | |||
} | |||
c.Data["State"] = state | |||
var showClosed util.OptionalBool | |||
switch state { | |||
case "all": | |||
showClosed = util.OptionalBoolNone | |||
case "closed": | |||
showClosed = util.OptionalBoolTrue | |||
case "open": | |||
showClosed = util.OptionalBoolFalse | |||
} | |||
var issueTypeBool util.OptionalBool | |||
issueType := c.FormString("issueType") | |||
switch issueType { | |||
case "issues": | |||
issueTypeBool = util.OptionalBoolFalse | |||
case "pulls": | |||
issueTypeBool = util.OptionalBoolTrue | |||
default: | |||
issueTypeBool = util.OptionalBoolNone | |||
} | |||
c.Data["IssueType"] = issueType | |||
var labelIDs []int64 | |||
selectedLabels := c.FormString("labels") | |||
c.Data["Labels"] = selectedLabels | |||
if len(selectedLabels) > 0 && selectedLabels != "0" { | |||
var err error | |||
labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) | |||
if err != nil { | |||
c.ServerError("StringsToInt64s", err) | |||
return | |||
} | |||
} | |||
count, err := issues_model.CountIssues(&issues_model.IssuesOptions{ | |||
SubscriberID: c.Doer.ID, | |||
IsClosed: showClosed, | |||
IsPull: issueTypeBool, | |||
LabelIDs: labelIDs, | |||
}) | |||
if err != nil { | |||
c.ServerError("CountIssues", err) | |||
return | |||
} | |||
issues, err := issues_model.Issues(&issues_model.IssuesOptions{ | |||
ListOptions: db.ListOptions{ | |||
PageSize: setting.UI.IssuePagingNum, | |||
Page: page, | |||
}, | |||
SubscriberID: c.Doer.ID, | |||
SortType: sortType, | |||
IsClosed: showClosed, | |||
IsPull: issueTypeBool, | |||
LabelIDs: labelIDs, | |||
}) | |||
if err != nil { | |||
c.ServerError("Issues", err) | |||
return | |||
} | |||
commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(c, issues) | |||
if err != nil { | |||
c.ServerError("GetIssuesAllCommitStatus", err) | |||
return | |||
} | |||
c.Data["CommitLastStatus"] = lastStatus | |||
c.Data["CommitStatuses"] = commitStatuses | |||
c.Data["Issues"] = issues | |||
c.Data["IssueRefEndNames"], c.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, "") | |||
commitStatus, err := pull_service.GetIssuesLastCommitStatus(c, issues) | |||
if err != nil { | |||
c.ServerError("GetIssuesLastCommitStatus", err) | |||
return | |||
} | |||
c.Data["CommitStatus"] = commitStatus | |||
issueList := issues_model.IssueList(issues) | |||
approvalCounts, err := issueList.GetApprovalCounts(c) | |||
if err != nil { | |||
c.ServerError("ApprovalCounts", err) | |||
return | |||
} | |||
c.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 { | |||
counts, ok := approvalCounts[issueID] | |||
if !ok || len(counts) == 0 { | |||
return 0 | |||
} | |||
reviewTyp := issues_model.ReviewTypeApprove | |||
if typ == "reject" { | |||
reviewTyp = issues_model.ReviewTypeReject | |||
} else if typ == "waiting" { | |||
reviewTyp = issues_model.ReviewTypeRequest | |||
} | |||
for _, count := range counts { | |||
if count.Type == reviewTyp { | |||
return count.Count | |||
} | |||
} | |||
return 0 | |||
} | |||
c.Data["Status"] = 1 | |||
c.Data["Title"] = c.Tr("notification.subscriptions") | |||
// redirect to last page if request page is more than total pages | |||
pager := context.NewPagination(int(count), setting.UI.IssuePagingNum, page, 5) | |||
if pager.Paginater.Current() < page { | |||
c.Redirect(fmt.Sprintf("/notifications/subscriptions?page=%d", pager.Paginater.Current())) | |||
return | |||
} | |||
pager.AddParam(c, "sort", "SortType") | |||
pager.AddParam(c, "state", "State") | |||
c.Data["Page"] = pager | |||
c.HTML(http.StatusOK, tplNotificationSubscriptions) | |||
} | |||
// NotificationWatching returns the list of watching repos | |||
func NotificationWatching(c *context.Context) { | |||
page := c.FormInt("page") | |||
if page < 1 { | |||
page = 1 | |||
} | |||
var orderBy db.SearchOrderBy | |||
c.Data["SortType"] = c.FormString("sort") | |||
switch c.FormString("sort") { | |||
case "newest": | |||
orderBy = db.SearchOrderByNewest | |||
case "oldest": | |||
orderBy = db.SearchOrderByOldest | |||
case "recentupdate": | |||
orderBy = db.SearchOrderByRecentUpdated | |||
case "leastupdate": | |||
orderBy = db.SearchOrderByLeastUpdated | |||
case "reversealphabetically": | |||
orderBy = db.SearchOrderByAlphabeticallyReverse | |||
case "alphabetically": | |||
orderBy = db.SearchOrderByAlphabetically | |||
case "moststars": | |||
orderBy = db.SearchOrderByStarsReverse | |||
case "feweststars": | |||
orderBy = db.SearchOrderByStars | |||
case "mostforks": | |||
orderBy = db.SearchOrderByForksReverse | |||
case "fewestforks": | |||
orderBy = db.SearchOrderByForks | |||
default: | |||
c.Data["SortType"] = "recentupdate" | |||
orderBy = db.SearchOrderByRecentUpdated | |||
} | |||
repos, count, err := repo_model.SearchRepository(&repo_model.SearchRepoOptions{ | |||
ListOptions: db.ListOptions{ | |||
PageSize: setting.UI.User.RepoPagingNum, | |||
Page: page, | |||
}, | |||
Actor: c.Doer, | |||
Keyword: c.FormTrim("q"), | |||
OrderBy: orderBy, | |||
Private: c.IsSigned, | |||
WatchedByID: c.Doer.ID, | |||
Collaborate: util.OptionalBoolFalse, | |||
TopicOnly: c.FormBool("topic"), | |||
IncludeDescription: setting.UI.SearchRepoDescription, | |||
}) | |||
if err != nil { | |||
c.ServerError("ErrSearchRepository", err) | |||
return | |||
} | |||
total := int(count) | |||
c.Data["Total"] = total | |||
c.Data["Repos"] = repos | |||
// redirect to last page if request page is more than total pages | |||
pager := context.NewPagination(total, setting.UI.User.RepoPagingNum, page, 5) | |||
pager.SetDefaultParams(c) | |||
c.Data["Page"] = pager | |||
c.Data["Status"] = 2 | |||
c.Data["Title"] = c.Tr("notification.watching") | |||
c.HTML(http.StatusOK, tplNotificationSubscriptions) | |||
} | |||
// NewAvailable returns the notification counts | |||
func NewAvailable(ctx *context.Context) { | |||
ctx.JSON(http.StatusOK, structs.NotificationCount{New: activities_model.CountUnread(ctx, ctx.Doer.ID)}) |
@@ -1269,6 +1269,8 @@ func RegisterRoutes(m *web.Route) { | |||
m.Group("/notifications", func() { | |||
m.Get("", user.Notifications) | |||
m.Get("/subscriptions", user.NotificationSubscriptions) | |||
m.Get("/watching", user.NotificationWatching) | |||
m.Post("/status", user.NotificationStatusPost) | |||
m.Post("/purge", user.NotificationPurgePost) | |||
m.Get("/new", user.NewAvailable) |
@@ -171,6 +171,10 @@ | |||
{{.locale.Tr "your_starred"}} | |||
</a> | |||
{{end}} | |||
<a class="item" href="{{AppSubUrl}}/notifications/subscriptions"> | |||
{{svg "octicon-bell"}} | |||
{{.locale.Tr "notification.subscriptions"}}<!-- Subscriptions --> | |||
</a> | |||
<a class="{{if .PageIsUserSettings}}active{{end}} item" href="{{AppSubUrl}}/user/settings"> | |||
{{svg "octicon-tools"}} | |||
{{.locale.Tr "your_settings"}}<!-- Your settings --> |
@@ -0,0 +1,79 @@ | |||
{{template "base/head" .}} | |||
<div class="page-content user notification" id="notification_subscriptions" data-params="{{.Page.GetParams}}" data-sequence-number="{{.SequenceNumber}}"> | |||
<div class="ui container"> | |||
<div class="ui top attached tabular menu"> | |||
<a href="{{AppSubUrl}}/notifications/subscriptions" class="{{if eq .Status 1}}active {{end}}item"> | |||
{{.locale.Tr "notification.subscriptions"}} | |||
</a> | |||
<a href="{{AppSubUrl}}/notifications/watching" class="{{if eq .Status 2}}active {{end}}item"> | |||
{{.locale.Tr "notification.watching"}} | |||
</a> | |||
</div> | |||
<div class="ui bottom attached active tab segment"> | |||
{{if eq .Status 1}} | |||
<div id="issue-filters" class="ui stackable grid"> | |||
<div class="six wide column"> | |||
<div class="ui compact tiny menu"> | |||
<a class="{{if eq .State "all"}}active {{end}}item" href="{{$.Link}}?sort={{$.SortType}}&state=all&issueType={{$.IssueType}}&labels={{$.Labels}}"> | |||
{{.locale.Tr "all"}} | |||
</a> | |||
<a class="{{if eq .State "open"}}active {{end}}item" href="{{$.Link}}?sort={{$.SortType}}&state=open&issueType={{$.IssueType}}&labels={{$.Labels}}"> | |||
{{svg "octicon-issue-opened" 16 "mr-3"}} | |||
{{.locale.Tr "repo.issues.open_title"}} | |||
</a> | |||
<a class="{{if eq .State "closed"}}active {{end}}item" href="{{$.Link}}?sort={{$.SortType}}&state=closed&issueType={{$.IssueType}}&labels={{$.Labels}}"> | |||
{{svg "octicon-issue-closed" 16 "mr-3"}} | |||
{{.locale.Tr "repo.issues.closed_title"}} | |||
</a> | |||
</div> | |||
</div> | |||
<div class="seven wide right aligned right floated column"> | |||
<div class="ui right aligned secondary filter stackable menu labels"> | |||
<!-- Type --> | |||
<div class="ui dropdown type jump item"> | |||
<span class="text"> | |||
{{.locale.Tr "repo.issues.filter_type"}} | |||
{{svg "octicon-triangle-down" 14 "dropdown icon"}} | |||
</span> | |||
<div class="menu"> | |||
<a class="{{if or (eq .IssueType "all") (not .IssueType)}}active {{end}}item" href="{{$.Link}}?sort={{$.SortType}}&state={{$.State}}&issueType=all&labels={{$.Labels}}">{{.locale.Tr "all"}}</a> | |||
<a class="{{if eq .IssueType "issues"}}active {{end}}item" href="{{$.Link}}?sort={{$.SortType}}&state={{$.State}}&issueType=issues&labels={{$.Labels}}">{{.locale.Tr "issues"}}</a> | |||
<a class="{{if eq .IssueType "pulls"}}active {{end}}item" href="{{$.Link}}?sort={{$.SortType}}&state={{$.State}}&issueType=pulls&labels={{$.Labels}}">{{.locale.Tr "pull_requests"}}</a> | |||
</div> | |||
</div> | |||
<!-- Sort --> | |||
<div class="ui dropdown type jump item"> | |||
<span class="text"> | |||
{{.locale.Tr "repo.issues.filter_sort"}} | |||
{{svg "octicon-triangle-down" 14 "dropdown icon"}} | |||
</span> | |||
<div class="menu"> | |||
<a class="{{if or (eq .SortType "latest") (not .SortType)}}active {{end}}item" href="{{$.Link}}?sort=latest&state={{$.State}}&issueType={{$.IssueType}}&labels={{$.Labels}}">{{.locale.Tr "repo.issues.filter_sort.latest"}}</a> | |||
<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="{{$.Link}}?sort=oldest&state={{$.State}}&issueType={{$.IssueType}}&labels={{$.Labels}}">{{.locale.Tr "repo.issues.filter_sort.oldest"}}</a> | |||
<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="{{$.Link}}?sort=recentupdate&state={{$.State}}&issueType={{$.IssueType}}&labels={{$.Labels}}">{{.locale.Tr "repo.issues.filter_sort.recentupdate"}}</a> | |||
<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="{{$.Link}}?sort=leastupdate&state={{$.State}}&issueType={{$.IssueType}}&labels={{$.Labels}}">{{.locale.Tr "repo.issues.filter_sort.leastupdate"}}</a> | |||
<a class="{{if eq .SortType "mostcomment"}}active {{end}}item" href="{{$.Link}}?sort=mostcomment&state={{$.State}}&issueType={{$.IssueType}}&labels={{$.Labels}}">{{.locale.Tr "repo.issues.filter_sort.mostcomment"}}</a> | |||
<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="{{$.Link}}?sort=leastcomment&state={{$.State}}&issueType={{$.IssueType}}&labels={{$.Labels}}">{{.locale.Tr "repo.issues.filter_sort.leastcomment"}}</a> | |||
<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="{{$.Link}}?sort=nearduedate&state={{$.State}}&issueType={{$.IssueType}}&labels={{$.Labels}}">{{.locale.Tr "repo.issues.filter_sort.nearduedate"}}</a> | |||
<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="{{$.Link}}?sort=farduedate&state={{$.State}}&issueType={{$.IssueType}}&labels={{$.Labels}}">{{.locale.Tr "repo.issues.filter_sort.farduedate"}}</a> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
{{if eq (len .Issues) 0}} | |||
<div class="ui divider"></div> | |||
{{.locale.Tr "notification.no_subscriptions"}} | |||
{{else}} | |||
{{template "shared/issuelist" mergeinto . "listType" "dashboard"}} | |||
{{end}} | |||
{{else}} | |||
{{template "explore/repo_search" .}} | |||
{{template "explore/repo_list" .}} | |||
{{template "base/paginate" .}} | |||
{{end}} | |||
</div> | |||
</div> | |||
</div> | |||
{{template "base/footer" .}} |