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 9acf58764c..5a805c1788 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 0000000000..ee8262be70 --- /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 97170263b2..e344b7f21d 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 4f978449e2..72e6bc21c4 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 9ac7278f83..e16621a7ec 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 7eb0a5b72d..f7a62a0bf5 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 7b7c331c2b..fdbcb74513 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 a0603e38d8..ef4ee3403e 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 626aeb65d8..d00ad1ca26 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 8d745cb91f..5e5cce5a12 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 6301e52542..11bb5ec3ee 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 99ffc06756..e2a90c0d73 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 cb6964b435..da5dc68952 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 482bb3c9ac..c5e0e039f8 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 db45c20100..8b19916a06 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 756801c29f..d7bceae032 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 072f32d0b5..b0aa8c1ac3 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 c522bf3865..e5426cde46 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 3054729d2a..86480694d3 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 a930664554..a14c2710ec 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 0b84aa0812..d574da9be7 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 d5f8449165..8d3d0b8cad 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 8b21c96541..ccc730326e 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 eaee27652a..7bc08a2b6d 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 f495e417d9..7fd9d1f10b 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 b34fde2b46..3f26fd04ab 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 0000000000..dba04f49b6 --- /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 ef586d2201..bae6d37ab7 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,