From 7dbfed37eb7964b2e251689dd2c16911bd8e0738 Mon Sep 17 00:00:00 2001 From: Alexandre Bontems Date: Sat, 8 Aug 2026 09:35:13 +0200 Subject: [PATCH] feat: add watch options (#37571) Adds per-event watch options, so a user can choose which repository notifications they receive. The watch button is now a menu with four modes: participating and mentions (the unwatched state, as on GitHub), all activity, ignore, and custom, which opens a dialog to pick issues, pull requests and releases. The same dialog is reachable from the gear icon on the watched repository list. Options apply to UI notifications and to mail. The watcher, star and fork counts moved inside their buttons, so all three share one shape, and the links to those lists moved to the repository sidebar. Assignees and requested reviewers now also receive UI notifications without having commented first. Buttons and dropdown: image Modal: Screenshot 2026-08-07 at 21 10 59 New entries in sidebar: Screenshot 2026-08-07 at 21 13 28 Fixes: https://github.com/go-gitea/gitea/issues/17238 Ref: https://github.com/go-gitea/gitea/issues/35492 --------- Co-authored-by: bircni Co-authored-by: silverwind --- modelmigration/migrations.go | 1 + modelmigration/v1_28/v347.go | 25 +++++++ models/activities/notification_list.go | 24 ++++++- models/activities/notification_test.go | 34 +++++++++ models/issues/issue_watch.go | 6 +- models/issues/pull.go | 13 ++++ models/repo/watch.go | 95 +++++++++++++++++++++---- models/repo/watch_test.go | 35 ++++++++- options/locale/locale_en-US.json | 15 +++- routers/web/repo/watch.go | 50 +++++++++++-- routers/web/user/notification.go | 7 ++ routers/web/web.go | 3 +- services/context/repo.go | 8 ++- services/mailer/mail_issue.go | 11 ++- services/mailer/mail_release.go | 2 +- services/repository/repository_test.go | 8 +-- services/uinotification/notify.go | 2 +- templates/repo/header.tmpl | 1 + templates/repo/header/fork.tmpl | 14 ++-- templates/repo/header/star.tmpl | 33 ++++----- templates/repo/header/watch.tmpl | 70 +++++++++++++----- templates/repo/home_sidebar_top.tmpl | 12 ++++ templates/repo/release/new.tmpl | 2 +- templates/repo/watch_options_modal.tmpl | 15 ++++ templates/shared/repo/list.tmpl | 9 +++ tests/e2e/repo-star-watch.test.ts | 5 +- tests/integration/issue_test.go | 2 +- tests/integration/pull_create_test.go | 6 +- tests/integration/release_test.go | 2 +- web_src/css/repo/home.css | 5 ++ web_src/js/features/repo-watch.ts | 33 +++++++++ web_src/js/index.ts | 2 + 32 files changed, 467 insertions(+), 83 deletions(-) create mode 100644 modelmigration/v1_28/v347.go create mode 100644 templates/repo/watch_options_modal.tmpl create mode 100644 web_src/js/features/repo-watch.ts diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 9acf58764c3..5a805c17885 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -420,6 +420,7 @@ func prepareMigrationTasks() []*migration { newMigration(344, "Add deferred-matrix columns to ActionRunJob", v1_28.AddDeferredMatrixColumnsToActionRunJob), newMigration(345, "Add block on CODEOWNERS reviews branch protection", v1_28.AddBlockOnCodeownerReviews), newMigration(346, "Add license_path column to repo_license and backfill", v1_28.AddLicensePathToRepoLicense), + newMigration(347, "Add watch options", v1_28.AddWatchOptions), } return preparedMigrations } diff --git a/modelmigration/v1_28/v347.go b/modelmigration/v1_28/v347.go new file mode 100644 index 00000000000..ee8262be703 --- /dev/null +++ b/modelmigration/v1_28/v347.go @@ -0,0 +1,25 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_28 + +import ( + "context" + + "gitea.dev/modelmigration/base" + + "xorm.io/xorm" +) + +func AddWatchOptions(_ context.Context, x base.EngineMigration) error { + type Watch struct { + PullRequests bool `xorm:"NOT NULL DEFAULT true"` + Issues bool `xorm:"NOT NULL DEFAULT true"` + Releases bool `xorm:"NOT NULL DEFAULT true"` + } + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreConstrains: true, + IgnoreIndices: true, + }, new(Watch)) + return err +} diff --git a/models/activities/notification_list.go b/models/activities/notification_list.go index 97170263b20..e344b7f21de 100644 --- a/models/activities/notification_list.go +++ b/models/activities/notification_list.go @@ -106,7 +106,8 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n } toNotify.AddMultiple(issueWatches...) if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) { - repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID) + watchType := util.Iif(issue.IsPull, repo_model.WatchPullRequests, repo_model.WatchIssues) + repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID, watchType) if err != nil { return nil, err } @@ -117,6 +118,18 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n return nil, err } toNotify.AddMultiple(issueParticipants...) + issueAssignees, err := issues_model.GetAssigneeIDsByIssue(ctx, issueID) + if err != nil { + return nil, err + } + toNotify.AddMultiple(issueAssignees...) + if issue.IsPull { + issueReviewers, err := issues_model.GetPullRequestRequestedReviewerIDs(ctx, issueID) + if err != nil { + return nil, err + } + toNotify.AddMultiple(issueReviewers...) + } // don't notify user who cause notification delete(toNotify, notificationAuthorID) @@ -130,6 +143,15 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n } } + // muting the repository outranks every other source, including mentions + ignorers, err := repo_model.GetRepoIgnorersIDs(ctx, issue.RepoID) + if err != nil { + return nil, err + } + for _, id := range ignorers { + toNotify.Remove(id) + } + if err := issue.LoadRepo(ctx); err != nil { return nil, err } diff --git a/models/activities/notification_test.go b/models/activities/notification_test.go index 4f978449e20..72e6bc21c4b 100644 --- a/models/activities/notification_test.go +++ b/models/activities/notification_test.go @@ -10,6 +10,7 @@ import ( activities_model "gitea.dev/models/activities" "gitea.dev/models/db" issues_model "gitea.dev/models/issues" + repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" @@ -32,6 +33,39 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) { assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status) } +func TestCreateOrUpdateIssueNotificationsForAssigneeAndReviewer(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // user 13 neither watches repo 1 nor participates in PR 3 + assert.NoError(t, db.Insert(t.Context(), &issues_model.IssueAssignees{AssigneeID: 13, IssueID: 3})) + _, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 3, 0, 1, 0) + assert.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 13, IssueID: 3}) + + // user 1 is a requested reviewer of PR 12 and does not participate in it + _, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), 12, 0, 2, 0) + assert.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 1, IssueID: 12}) +} + +func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // user 4 watches repo 1 and would be notified about issue 1 + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + assert.NoError(t, repo_model.IgnoreRepo(t.Context(), user, repo)) + + notified, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, 0) + assert.NoError(t, err) + assert.NotContains(t, notified, user.ID) + + // muting outranks a direct receiver too + notified, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, user.ID) + assert.NoError(t, err) + assert.Empty(t, notified) +} + func TestNotificationsForUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) diff --git a/models/issues/issue_watch.go b/models/issues/issue_watch.go index 9ac7278f83f..e16621a7ec3 100644 --- a/models/issues/issue_watch.go +++ b/models/issues/issue_watch.go @@ -10,6 +10,7 @@ import ( repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/timeutil" + "gitea.dev/modules/util" ) // IssueWatch is connection request for receiving issue notification. @@ -81,7 +82,10 @@ func CheckIssueWatch(ctx context.Context, user *user_model.User, issue *Issue) ( if err != nil { return false, err } - return repo_model.IsWatchMode(w.Mode) || IsUserParticipantsOfIssue(ctx, user, issue), nil + if repo_model.IsWatchMode(w.Mode) && util.Iif(issue.IsPull, w.PullRequests, w.Issues) { + return true, nil + } + return IsUserParticipantsOfIssue(ctx, user, issue), nil } // GetIssueWatchersIDs returns IDs of subscribers or explicit unsubscribers to a given issue id diff --git a/models/issues/pull.go b/models/issues/pull.go index 7eb0a5b72db..f7a62a0bf5a 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -1012,3 +1012,16 @@ func GetPullRequestByMergedCommit(ctx context.Context, repoID int64, sha string) return pr, nil } + +// GetPullRequestRequestedReviewerIDs returns IDs of reviewers currently requested for the given pull request. +func GetPullRequestRequestedReviewerIDs(ctx context.Context, issueID int64) ([]int64, error) { + userIDs := make([]int64, 0, 5) + return userIDs, db.GetEngine(ctx). + Table("review"). + Cols("reviewer_id"). + Where("issue_id=?", issueID). + And("type=?", ReviewTypeRequest). + And("reviewer_id > 0"). + Distinct("reviewer_id"). + Find(&userIDs) +} diff --git a/models/repo/watch.go b/models/repo/watch.go index 7b7c331c2ba..fdbcb745131 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -28,14 +28,26 @@ const ( WatchModeAuto // 3 ) +// WatchType is the `watch` column gating one kind of notification +type WatchType string + +const ( + WatchPullRequests WatchType = "pull_requests" + WatchIssues WatchType = "issues" + WatchReleases WatchType = "releases" +) + // Watch is connection request for receiving repository notification. type Watch struct { - ID int64 `xorm:"pk autoincr"` - UserID int64 `xorm:"UNIQUE(watch)"` - RepoID int64 `xorm:"UNIQUE(watch)"` - Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` - UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"UNIQUE(watch)"` + RepoID int64 `xorm:"UNIQUE(watch)"` + Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + PullRequests bool `xorm:"NOT NULL DEFAULT true"` + Issues bool `xorm:"NOT NULL DEFAULT true"` + Releases bool `xorm:"NOT NULL DEFAULT true"` } func init() { @@ -48,8 +60,8 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) { if err != nil { return watch, err } - if watch == nil { - watch = &Watch{UserID: userID, RepoID: repoID} + if watch == nil { // the dummy record must mirror the column defaults + watch = &Watch{UserID: userID, RepoID: repoID, PullRequests: true, Issues: true, Releases: true} } if !has { watch.Mode = WatchModeNone @@ -57,6 +69,11 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) { return watch, nil } +// IsIgnoring reports whether the user muted the repository entirely +func (w *Watch) IsIgnoring() bool { + return w.Mode == WatchModeDont +} + // IsWatchMode Decodes watchability of WatchMode func IsWatchMode(mode WatchMode) bool { return mode != WatchModeNone && mode != WatchModeDont @@ -87,15 +104,16 @@ func watchRepoMode(ctx context.Context, watch *Watch, mode WatchMode) (err error repodiff = -1 } + if repodiff == 1 { // starting to watch resets the options, otherwise a custom selection survives + watch.PullRequests, watch.Issues, watch.Releases = true, true, true + } watch.Mode = mode if !hadrec && needsrec { - watch.Mode = mode if err = db.Insert(ctx, watch); err != nil { return err } } else if needsrec { - watch.Mode = mode if _, err := db.GetEngine(ctx).ID(watch.ID).AllCols().Update(watch); err != nil { return err } @@ -127,6 +145,48 @@ func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doW return watchRepoMode(ctx, watch, WatchModeNormal) } +// IgnoreRepo mutes the repository, so nothing about it reaches the user. +func IgnoreRepo(ctx context.Context, doer *user_model.User, repo *Repository) error { + watch, err := GetWatch(ctx, doer.ID, repo.ID) + if err != nil { + return err + } + return watchRepoMode(ctx, watch, WatchModeDont) +} + +type WatchOptions struct { + PullRequests bool + Issues bool + Releases bool +} + +// SetWatchOptions updates the per-event options of a watch, callers must run WatchRepo first +func SetWatchOptions(ctx context.Context, userID, repoID int64, opts WatchOptions) error { + _, err := db.GetEngine(ctx).Where("user_id=? AND repo_id=?", userID, repoID). + Cols(string(WatchPullRequests), string(WatchIssues), string(WatchReleases)). + Update(&Watch{PullRequests: opts.PullRequests, Issues: opts.Issues, Releases: opts.Releases}) + return err +} + +// GetUserWatches returns the watches of one user, keyed by repository ID +func GetUserWatches(ctx context.Context, userID int64, repoIDs []int64) (map[int64]*Watch, error) { + if len(repoIDs) == 0 { + return map[int64]*Watch{}, nil + } + watches := make([]*Watch, 0, len(repoIDs)) + if err := db.GetEngine(ctx).Where("user_id=?", userID). + In("repo_id", repoIDs). + And("mode<>?", WatchModeDont). + Find(&watches); err != nil { + return nil, err + } + watchesByRepo := make(map[int64]*Watch, len(watches)) + for _, watch := range watches { + watchesByRepo[watch.RepoID] = watch + } + return watchesByRepo, nil +} + // GetWatchers returns all watchers of given repository. func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) { watches := make([]*Watch, 0, 10) @@ -138,14 +198,25 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) { Find(&watches) } -// GetRepoWatchersIDs returns IDs of watchers for a given repo ID +// GetRepoIgnorersIDs returns IDs of users who muted the given repo ID +func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) { + ids := make([]int64, 0, 8) + return ids, db.GetEngine(ctx).Table("watch"). + Where("repo_id=?", repoID). + And("mode=?", WatchModeDont). + Select("user_id"). + Find(&ids) +} + +// GetRepoWatchersIDs returns IDs of watchers for a given repo ID that opted into watchType // but avoids joining with `user` for performance reasons // User permissions must be verified elsewhere if required -func GetRepoWatchersIDs(ctx context.Context, repoID int64) ([]int64, error) { +func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) { ids := make([]int64, 0, 64) return ids, db.GetEngine(ctx).Table("watch"). Where("watch.repo_id=?", repoID). And("watch.mode<>?", WatchModeDont). + And(builder.Eq{"watch." + string(watchType): true}). Select("user_id"). Find(&ids) } diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index a0603e38d84..ef4ee3403e0 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -125,16 +125,47 @@ func TestClearRepoWatches(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) const repoID int64 = 1 - watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repoID) + watchers, err := repo_model.GetRepoWatchers(t.Context(), repoID, db.ListOptions{Page: 1}) require.NoError(t, err) require.NotEmpty(t, watchers) assert.NoError(t, repo_model.ClearRepoWatches(t.Context(), repoID)) - watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repoID) + watchers, err = repo_model.GetRepoWatchers(t.Context(), repoID, db.ListOptions{Page: 1}) assert.NoError(t, err) assert.Empty(t, watchers) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) assert.Zero(t, repo.NumWatches) } + +func TestWatchOptions(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + assert.NoError(t, repo_model.SetWatchOptions(t.Context(), user.ID, repo.ID, repo_model.WatchOptions{PullRequests: true})) + + for watchType, expected := range map[repo_model.WatchType][]int64{ + repo_model.WatchPullRequests: {1, 4, 9, 11}, + repo_model.WatchIssues: {4, 9, 11}, + repo_model.WatchReleases: {4, 9, 11}, + } { + ids, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID, watchType) + assert.NoError(t, err) + assert.ElementsMatch(t, expected, ids, watchType) + } + + // the options of one user must not show up for another + watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID}) + assert.NoError(t, err) + assert.True(t, watches[repo.ID].Issues) + + // watching again resets a custom selection + assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, false)) + assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, true)) + watch, err := repo_model.GetWatch(t.Context(), user.ID, repo.ID) + assert.NoError(t, err) + assert.True(t, watch.PullRequests && watch.Issues && watch.Releases) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 626aeb65d8f..d00ad1ca266 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1154,8 +1154,21 @@ "repo.fork_guest_user": "Sign in to fork this repository.", "repo.watch_guest_user": "Sign in to watch this repository.", "repo.star_guest_user": "Sign in to star this repository.", - "repo.unwatch": "Unwatch", "repo.watch": "Watch", + "repo.watching": "Watching", + "repo.ignoring": "Ignoring", + "repo.watch.options.required": "Select at least one event type.", + "repo.watch.options.issues": "Issues", + "repo.watch.options.pull_requests": "Pull Requests", + "repo.watch.options.releases": "Releases", + "repo.watch.mode.participating": "Participating and mentions", + "repo.watch.mode.participating.desc": "Receive notifications from this repository when participating or mentioned.", + "repo.watch.mode.all": "All activity", + "repo.watch.mode.all.desc": "Receive notifications for all events.", + "repo.watch.mode.ignore": "Ignore", + "repo.watch.mode.ignore.desc": "Never receive notifications from this repository.", + "repo.watch.mode.custom": "Custom", + "repo.watch.mode.custom.desc": "Choose the events you want to receive notifications for.", "repo.unstar": "Unstar", "repo.star": "Star", "repo.fork": "Fork", diff --git a/routers/web/repo/watch.go b/routers/web/repo/watch.go index 8d745cb91f8..5e5cce5a126 100644 --- a/routers/web/repo/watch.go +++ b/routers/web/repo/watch.go @@ -11,20 +11,62 @@ import ( "gitea.dev/services/context" ) -const tplWatchUnwatch templates.TplName = "repo/header/watch" +const tplWatch templates.TplName = "repo/header/watch" func ActionWatch(ctx *context.Context) { - err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, ctx.PathParam("action") == "watch") + action := ctx.PathParam("action") + var err error + if action == "ignore" { + err = repo_model.IgnoreRepo(ctx, ctx.Doer, ctx.Repo.Repository) + } else { + err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, action == "watch") + } if err != nil { handleActionError(ctx, err) return } + if action == "watch" { // watching again always restores every event, so "all activity" can undo a custom selection + opts := repo_model.WatchOptions{PullRequests: true, Issues: true, Releases: true} + if err := repo_model.SetWatchOptions(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, opts); err != nil { + ctx.ServerError("SetWatchOptions", err) + return + } + } + + watch, err := repo_model.GetWatch(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetWatch", err) + return + } + ctx.Data["Watch"] = watch + ctx.Data["IsWatchingRepo"] = repo_model.IsWatchMode(watch.Mode) - ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) if err != nil { ctx.ServerError("GetRepositoryByName", err) return } - ctx.HTML(http.StatusOK, tplWatchUnwatch) + ctx.HTML(http.StatusOK, tplWatch) +} + +// ActionWatchOptions watches the repository with a custom selection of events +func ActionWatchOptions(ctx *context.Context) { + opts := repo_model.WatchOptions{ + PullRequests: ctx.FormBool(string(repo_model.WatchPullRequests)), + Issues: ctx.FormBool(string(repo_model.WatchIssues)), + Releases: ctx.FormBool(string(repo_model.WatchReleases)), + } + if !opts.PullRequests && !opts.Issues && !opts.Releases { + ctx.JSONError(ctx.Tr("repo.watch.options.required")) + return + } + if err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, true); err != nil { + handleActionError(ctx, err) + return + } + if err := repo_model.SetWatchOptions(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, opts); err != nil { + ctx.ServerError("SetWatchOptions", err) + return + } + ctx.JSONRedirect("") } diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index 6301e525429..11bb5ec3eed 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -399,6 +399,13 @@ func NotificationWatching(ctx *context.Context) { ctx.Data["Total"] = count ctx.Data["Repos"] = repos + watches, err := repo_model.GetUserWatches(ctx, ctx.Doer.ID, repos.IDs()) + if err != nil { + ctx.ServerError("GetUserWatches", err) + return + } + ctx.Data["Watches"] = watches + // redirect to last page if request page is more than total pages pager := context.NewPagination(count, setting.UI.User.RepoPagingNum, page, 5) pager.AddParamFromRequest(ctx.Req) diff --git a/routers/web/web.go b/routers/web/web.go index 99ffc06756c..e2a90c0d733 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1743,7 +1743,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/watchers", repo.Watchers) m.Get("/search", reqUnitCodeReader, repo.Search) m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar) - m.Post("/action/{action:watch|unwatch}", reqSignIn, repo.ActionWatch) + m.Post("/action/{action:watch|unwatch|ignore}", reqSignIn, repo.ActionWatch) + m.Post("/action/watch/options", reqSignIn, repo.ActionWatchOptions) m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer) }, optSignIn, context.RepoAssignment) diff --git a/services/context/repo.go b/services/context/repo.go index cb6964b435c..da5dc689529 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -639,7 +639,13 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare } if ctx.IsSigned { - ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, repo.ID) + watch, err := repo_model.GetWatch(ctx, ctx.Doer.ID, repo.ID) + if err != nil { + ctx.ServerError("GetWatch", err) + return + } + ctx.Data["Watch"] = watch + ctx.Data["IsWatchingRepo"] = repo_model.IsWatchMode(watch.Mode) ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID) } diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index 482bb3c9aca..c5e0e039f84 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -16,6 +16,7 @@ import ( "gitea.dev/modules/container" "gitea.dev/modules/log" "gitea.dev/modules/setting" + "gitea.dev/modules/util" ) const MailBatchSize = 100 // batch size used in mailIssueCommentBatch @@ -66,7 +67,8 @@ func mailIssueCommentToParticipants(ctx context.Context, comment *mailComment, m // =========== Repo watchers =========== // Make repo watchers last, since it's likely the list with the most users if !(comment.Issue.IsPull && comment.Issue.PullRequest.IsWorkInProgress(ctx) && comment.ActionType != activities_model.ActionCreatePullRequest) { - ids, err = repo_model.GetRepoWatchersIDs(ctx, comment.Issue.RepoID) + watchType := util.Iif(comment.Issue.IsPull, repo_model.WatchPullRequests, repo_model.WatchIssues) + ids, err = repo_model.GetRepoWatchersIDs(ctx, comment.Issue.RepoID, watchType) if err != nil { return fmt.Errorf("GetRepoWatchersIDs(%d): %w", comment.Issue.RepoID, err) } @@ -75,6 +77,13 @@ func mailIssueCommentToParticipants(ctx context.Context, comment *mailComment, m visited := make(container.Set[int64], len(unfiltered)+len(mentions)+1) + // muting the repository outranks every other source, including mentions + ignorers, err := repo_model.GetRepoIgnorersIDs(ctx, comment.Issue.RepoID) + if err != nil { + return fmt.Errorf("GetRepoIgnorersIDs(%d): %w", comment.Issue.RepoID, err) + } + visited.AddMultiple(ignorers...) + // Avoid mailing the doer if comment.Doer.EmailNotificationsPreference != user_model.EmailNotificationsAndYourOwn && !comment.ForceDoerNotification { visited.Add(comment.Doer.ID) diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index db45c201007..8b19916a069 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -35,7 +35,7 @@ func MailNewRelease(ctx context.Context, rel *repo_model.Release) { return } - watcherIDList, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID) + watcherIDList, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID, repo_model.WatchReleases) if err != nil { log.Error("GetRepoWatchersIDs(%d): %v", rel.RepoID, err) return diff --git a/services/repository/repository_test.go b/services/repository/repository_test.go index 756801c29f3..d7bceae0329 100644 --- a/services/repository/repository_test.go +++ b/services/repository/repository_test.go @@ -76,13 +76,13 @@ func TestMakeRepoPrivateClearsWatches(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) assert.False(t, repo.IsPrivate) - watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID) + watchers, err := repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) require.NoError(t, err) require.NotEmpty(t, watchers) assert.NoError(t, MakeRepoPrivate(t.Context(), repo, true)) - watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repo.ID) + watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) assert.NoError(t, err) assert.Empty(t, watchers) @@ -99,14 +99,14 @@ func TestUpdateRepositoryClearsWatchesOnVisibilityChange(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) assert.False(t, repo.IsPrivate) - watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID) + watchers, err := repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) require.NoError(t, err) require.NotEmpty(t, watchers) repo.IsPrivate = true require.NoError(t, updateRepository(t.Context(), repo, true)) - watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repo.ID) + watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1}) assert.NoError(t, err) assert.Empty(t, watchers) diff --git a/services/uinotification/notify.go b/services/uinotification/notify.go index 072f32d0b5e..b0aa8c1ac32 100644 --- a/services/uinotification/notify.go +++ b/services/uinotification/notify.go @@ -145,7 +145,7 @@ func (ns *notificationService) NewPullRequest(ctx context.Context, pr *issues_mo return } toNotify := make(container.Set[int64], 32) - repoWatchers, err := repo_model.GetRepoWatchersIDs(ctx, pr.Issue.RepoID) + repoWatchers, err := repo_model.GetRepoWatchersIDs(ctx, pr.Issue.RepoID, repo_model.WatchPullRequests) if err != nil { log.Error("GetRepoWatchersIDs: %v", err) return diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index c522bf38655..e5426cde462 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -78,6 +78,7 @@ {{if .IsGenerated}}
{{ctx.Locale.Tr "repo.generated_from"}} {{(.TemplateRepo ctx).FullName}}
{{end}} {{end}} + {{if .IsSigned}}{{template "repo/watch_options_modal"}}{{end}}
diff --git a/templates/repo/header/fork.tmpl b/templates/repo/header/fork.tmpl index 3054729d2ae..86480694d3f 100644 --- a/templates/repo/header/fork.tmpl +++ b/templates/repo/header/fork.tmpl @@ -9,20 +9,18 @@ {{else if $canNotForkOwn}} {{$forkHref = "#"}} {{end}} - + {{svg "octicon-repo-forked"}} + {{ctx.Locale.Tr "repo.fork"}} + {{CountFmt $.Repository.NumForks}} + {{if $.ShowForkModal}} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index a930664554e..a14c2710ec5 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -13,7 +13,7 @@ {{template "base/alert" .}} -
+
{{ctx.Locale.Tr "notifications"}}
+ +
+ {{range $name := StringUtils.Split "issues,pull_requests,releases" ","}} +
+
+ +
+
+ {{end}} +
+ {{template "base/modal_actions_confirm" dict "ModalButtonTypes" "confirm"}} +
+
diff --git a/templates/shared/repo/list.tmpl b/templates/shared/repo/list.tmpl index 0b84aa0812e..d574da9be7e 100644 --- a/templates/shared/repo/list.tmpl +++ b/templates/shared/repo/list.tmpl @@ -55,6 +55,14 @@ {{svg "octicon-repo-forked" 16}} {{CountFmt .NumForks}} + {{$watch := and $.Watches (index $.Watches .ID)}} + {{if $watch}} + + {{end}} {{$description := .DescriptionHTML ctx}} @@ -76,4 +84,5 @@ {{ctx.Locale.Tr "search.no_results"}} {{end}} + {{if $.Watches}}{{template "repo/watch_options_modal"}}{{end}} diff --git a/tests/e2e/repo-star-watch.test.ts b/tests/e2e/repo-star-watch.test.ts index d5f84491651..8d3d0b8cade 100644 --- a/tests/e2e/repo-star-watch.test.ts +++ b/tests/e2e/repo-star-watch.test.ts @@ -11,10 +11,11 @@ test('star and watch a repository', async ({page, request}) => { ]); await page.goto(`/${owner}/${repoName}`); - // exact match so "Star"/"Watch" don't also match "Unstar"/"Unwatch" + // exact match so "Star"/"Watch" don't also match "Unstar"/"Watching" await page.getByRole('button', {name: 'Star', exact: true}).click(); await expect(page.getByRole('button', {name: 'Unstar'})).toBeVisible(); await page.getByRole('button', {name: 'Watch', exact: true}).click(); - await expect(page.getByRole('button', {name: 'Unwatch'})).toBeVisible(); + await page.getByRole('menuitem', {name: 'All activity', exact: true}).click(); + await expect(page.getByRole('button', {name: 'Watching'})).toBeVisible(); }); diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go index 8b21c96541e..ccc730326e3 100644 --- a/tests/integration/issue_test.go +++ b/tests/integration/issue_test.go @@ -127,7 +127,7 @@ func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content resp := session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action") + link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action") assert.True(t, exists, "The template has changed") req = NewRequestWithValues(t, "POST", link, map[string]string{ "title": title, diff --git a/tests/integration/pull_create_test.go b/tests/integration/pull_create_test.go index eaee27652a8..7bc08a2b6dd 100644 --- a/tests/integration/pull_create_test.go +++ b/tests/integration/pull_create_test.go @@ -55,7 +55,7 @@ func testPullCreate(t *testing.T, session *TestSession, user, repo string, toSel // Submit the form for creating the pull htmlDoc = NewHTMLParser(t, resp.Body) - link, exists = htmlDoc.doc.Find("form.ui.form").Attr("action") + link, exists = htmlDoc.doc.Find("form#new-issue").Attr("action") assert.True(t, exists, "The template has changed") req = NewRequestWithValues(t, "POST", link, map[string]string{ "title": title, @@ -98,7 +98,7 @@ func testPullCreateDirectly(t *testing.T, session *TestSession, opts createPullR // Submit the form for creating the pull htmlDoc := NewHTMLParser(t, resp.Body) - link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action") + link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action") assert.True(t, exists, "The template has changed") params := map[string]string{ "title": opts.Title, @@ -125,7 +125,7 @@ func testPullCreateFailure(t *testing.T, session *TestSession, baseRepoOwner, ba // Submit the form for creating the pull htmlDoc := NewHTMLParser(t, resp.Body) - link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action") + link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action") assert.True(t, exists, "The template has changed") req = NewRequestWithValues(t, "POST", link, map[string]string{ "title": title, diff --git a/tests/integration/release_test.go b/tests/integration/release_test.go index f495e417d93..7fd9d1f10b5 100644 --- a/tests/integration/release_test.go +++ b/tests/integration/release_test.go @@ -25,7 +25,7 @@ func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title st resp := session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action") + link, exists := htmlDoc.doc.Find("form#new-release").Attr("action") assert.True(t, exists, "The template has changed") postData := map[string]string{ diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index b34fde2b460..3f26fd04ab6 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -21,6 +21,11 @@ grid-row: 2; } +.repo-home-sidebar-top a:hover, +.repo-home-sidebar-bottom a:hover { + text-decoration-line: none; +} + .repo-home-sidebar-header { font-weight: var(--font-weight-semibold); font-size: 16px; diff --git a/web_src/js/features/repo-watch.ts b/web_src/js/features/repo-watch.ts new file mode 100644 index 00000000000..dba04f49b60 --- /dev/null +++ b/web_src/js/features/repo-watch.ts @@ -0,0 +1,33 @@ +import {createTippy} from '../modules/tippy.ts'; +import {showFomanticModal} from '../modules/fomantic/modal.ts'; +import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts'; +import type {Instance} from 'tippy.js'; + +let watchMenuTippy: Instance | null = null; + +export function initRepoWatch() { + registerGlobalInitFunc('initRepoWatchMenu', (btn: HTMLElement) => { + watchMenuTippy?.destroy(); // a watch action replaces the button, orphaning the old menu + const menu = btn.nextElementSibling!; + watchMenuTippy = createTippy(btn, { + content: menu, + theme: 'menu', + maxWidth: 350, + placement: 'bottom-end', + trigger: 'click', + interactive: true, + hideOnClick: true, + }); + menu.addEventListener('click', () => watchMenuTippy!.hide()); + }); + + registerGlobalEventFunc('click', 'onRepoWatchOptionsClick', (btn: HTMLElement) => { + const elModal = document.querySelector('#repo-watch-options-modal')!; + const form = elModal.querySelector('form')!; + form.action = btn.getAttribute('data-url')!; + for (const el of form.querySelectorAll('input[type=checkbox]')) { + el.checked = btn.getAttribute(`data-${el.name.replaceAll('_', '-')}`) === 'true'; + } + showFomanticModal(elModal); + }); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index ef586d22018..bae6d37ab79 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -65,6 +65,7 @@ import {initActionsPermissionsForm} from './features/common-actions-permissions. import {initRefIssueContextPopup} from './features/ref-issue.ts'; import {initGlobalShortcut} from './modules/shortcut.ts'; import {initDevtest} from './modules/devtest.ts'; +import {initRepoWatch} from './features/repo-watch.ts'; const initStartTime = performance.now(); const initPerformanceTracer = callInitFunctions([ @@ -141,6 +142,7 @@ const initPerformanceTracer = callInitFunctions([ initRepoContributors, initRepoCodeFrequency, initRepoRecentCommits, + initRepoWatch, initCommitStatuses, initAvatarStackPopup,