enhance: refine repo watching (#38835)

Follow-up to https://github.com/go-gitea/gitea/pull/37571.

"Participating and mentions" deleted the watch row, so choosing it
dropped you out of the watcher count. It is a watch like the others, so
it now keeps a row and simply subscribes to no events.

The dashboard feed ignored the per-event options, so a "Custom: issues"
watcher still got pull request activity there. It now gates on the same
options as mail and notifications. That also closes a gap where pull
request reviews bypassed the permission check.

Also, address
https://github.com/go-gitea/gitea/pull/37571#discussion_r3740487363 and
reword a UI text for clarity.

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-08-09 10:54:31 +00:00
committed by GitHub
co-authored by wxiaoguang
parent 76a81b24f9
commit ad6107ab88
14 changed files with 137 additions and 92 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) {
// user 4 watches repo 1 and would be notified about issue 1 // user 4 watches repo 1 and would be notified about issue 1
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
assert.NoError(t, repo_model.IgnoreRepo(t.Context(), user, repo)) assert.NoError(t, repo_model.WatchIgnoreRepo(t.Context(), user, repo))
notified, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, 0) notified, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, 0)
assert.NoError(t, err) assert.NoError(t, err)
+37 -3
View File
@@ -74,6 +74,29 @@ func (w *Watch) IsIgnoring() bool {
return w.Mode == WatchModeDont return w.Mode == WatchModeDont
} }
// IsWatching reports whether the watch counts the user as a watcher of the repository
func (w *Watch) IsWatching() bool {
return IsWatchMode(w.Mode)
}
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
func (w *Watch) IsWatchingAll() bool {
return w.PullRequests && w.Issues && w.Releases
}
// SelectedMode returns the mode the user picked in the watch menu
func (w *Watch) SelectedMode() string {
switch {
case w.IsIgnoring():
return "ignore"
case !IsWatchMode(w.Mode), !(w.PullRequests || w.Issues || w.Releases):
return "participate" // also the default while there is no watch row
case w.IsWatchingAll():
return "all"
}
return "custom"
}
// IsWatchMode Decodes watchability of WatchMode // IsWatchMode Decodes watchability of WatchMode
func IsWatchMode(mode WatchMode) bool { func IsWatchMode(mode WatchMode) bool {
return mode != WatchModeNone && mode != WatchModeDont return mode != WatchModeNone && mode != WatchModeDont
@@ -145,8 +168,8 @@ func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doW
return watchRepoMode(ctx, watch, WatchModeNormal) return watchRepoMode(ctx, watch, WatchModeNormal)
} }
// IgnoreRepo mutes the repository, so nothing about it reaches the user. // WatchIgnoreRepo mutes the repository (unwatch), so nothing about it reaches the user.
func IgnoreRepo(ctx context.Context, doer *user_model.User, repo *Repository) error { func WatchIgnoreRepo(ctx context.Context, doer *user_model.User, repo *Repository) error {
watch, err := GetWatch(ctx, doer.ID, repo.ID) watch, err := GetWatch(ctx, doer.ID, repo.ID)
if err != nil { if err != nil {
return err return err
@@ -160,6 +183,16 @@ type WatchOptions struct {
Releases bool Releases bool
} }
// WatchRepoWithOptions starts watching the repository and subscribes to the given events
func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repository, opts WatchOptions) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := WatchRepo(ctx, doer, repo, true); err != nil {
return err
}
return SetWatchOptions(ctx, doer.ID, repo.ID, opts)
})
}
// SetWatchOptions updates the per-event options of a watch, callers must run WatchRepo first // 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 { func SetWatchOptions(ctx context.Context, userID, repoID int64, opts WatchOptions) error {
_, err := db.GetEngine(ctx).Where("user_id=? AND repo_id=?", userID, repoID). _, err := db.GetEngine(ctx).Where("user_id=? AND repo_id=?", userID, repoID).
@@ -187,11 +220,12 @@ func GetUserWatches(ctx context.Context, userID int64, repoIDs []int64) (map[int
return watchesByRepo, nil return watchesByRepo, nil
} }
// GetWatchers returns all watchers of given repository. // GetWatchers returns all watchers of given repository, skipping those subscribed to no event.
func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) { func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
watches := make([]*Watch, 0, 10) watches := make([]*Watch, 0, 10)
return watches, db.GetEngine(ctx).Where("`watch`.repo_id=?", repoID). return watches, db.GetEngine(ctx).Where("`watch`.repo_id=?", repoID).
And("`watch`.mode<>?", WatchModeDont). And("`watch`.mode<>?", WatchModeDont).
And(builder.Or(builder.Eq{"`watch`.pull_requests": true}, builder.Eq{"`watch`.issues": true}, builder.Eq{"`watch`.releases": true})).
And("`user`.is_active=?", true). And("`user`.is_active=?", true).
And("`user`.prohibit_login=?", false). And("`user`.prohibit_login=?", false).
Join("INNER", "`user`", "`user`.id = `watch`.user_id"). Join("INNER", "`user`", "`user`.id = `watch`.user_id").
+10 -1
View File
@@ -167,5 +167,14 @@ func TestWatchOptions(t *testing.T) {
assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, true)) assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, true))
watch, err := repo_model.GetWatch(t.Context(), user.ID, repo.ID) watch, err := repo_model.GetWatch(t.Context(), user.ID, repo.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, watch.PullRequests && watch.Issues && watch.Releases) assert.True(t, watch.IsWatchingAll())
}
func TestWatchSelectedMode(t *testing.T) {
// a user without a watch row gets the dummy record, whose flags are the column defaults
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNone, PullRequests: true, Issues: true, Releases: true}).SelectedMode())
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, Issues: true}).SelectedMode())
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, PullRequests: true, Issues: true, Releases: true}).SelectedMode())
} }
+1 -2
View File
@@ -1157,7 +1157,6 @@
"repo.watch": "Watch", "repo.watch": "Watch",
"repo.watching": "Watching", "repo.watching": "Watching",
"repo.ignoring": "Ignoring", "repo.ignoring": "Ignoring",
"repo.watch.options.required": "Select at least one event type.",
"repo.watch.options.issues": "Issues", "repo.watch.options.issues": "Issues",
"repo.watch.options.pull_requests": "Pull Requests", "repo.watch.options.pull_requests": "Pull Requests",
"repo.watch.options.releases": "Releases", "repo.watch.options.releases": "Releases",
@@ -1168,7 +1167,7 @@
"repo.watch.mode.ignore": "Ignore", "repo.watch.mode.ignore": "Ignore",
"repo.watch.mode.ignore.desc": "Never receive notifications from this repository.", "repo.watch.mode.ignore.desc": "Never receive notifications from this repository.",
"repo.watch.mode.custom": "Custom", "repo.watch.mode.custom": "Custom",
"repo.watch.mode.custom.desc": "Choose the events you want to receive notifications for.", "repo.watch.mode.custom.desc": "Select events you want to be notified of, in addition to participating and mentions.",
"repo.unstar": "Unstar", "repo.unstar": "Unstar",
"repo.star": "Star", "repo.star": "Star",
"repo.fork": "Fork", "repo.fork": "Fork",
+6 -21
View File
@@ -17,29 +17,22 @@ func ActionWatch(ctx *context.Context) {
action := ctx.PathParam("action") action := ctx.PathParam("action")
var err error var err error
if action == "ignore" { if action == "ignore" {
err = repo_model.IgnoreRepo(ctx, ctx.Doer, ctx.Repo.Repository) err = repo_model.WatchIgnoreRepo(ctx, ctx.Doer, ctx.Repo.Repository)
} else { } else {
err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, action == "watch") all := action == "watch" // "participate" is a watch that subscribes to no event on its own
err = repo_model.WatchRepoWithOptions(ctx, ctx.Doer, ctx.Repo.Repository, repo_model.WatchOptions{PullRequests: all, Issues: all, Releases: all})
} }
if err != nil { if err != nil {
handleActionError(ctx, err) handleActionError(ctx, err)
return 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) watch, err := repo_model.GetWatch(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
if err != nil { if err != nil {
ctx.ServerError("GetWatch", err) ctx.ServerError("GetWatch", err)
return return
} }
ctx.Data["Watch"] = watch ctx.Data["RepoWatch"] = watch
ctx.Data["IsWatchingRepo"] = repo_model.IsWatchMode(watch.Mode)
ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
if err != nil { if err != nil {
@@ -51,22 +44,14 @@ func ActionWatch(ctx *context.Context) {
// ActionWatchOptions watches the repository with a custom selection of events // ActionWatchOptions watches the repository with a custom selection of events
func ActionWatchOptions(ctx *context.Context) { func ActionWatchOptions(ctx *context.Context) {
opts := repo_model.WatchOptions{ opts := repo_model.WatchOptions{ // clearing every event is allowed, it leaves the participating state
PullRequests: ctx.FormBool(string(repo_model.WatchPullRequests)), PullRequests: ctx.FormBool(string(repo_model.WatchPullRequests)),
Issues: ctx.FormBool(string(repo_model.WatchIssues)), Issues: ctx.FormBool(string(repo_model.WatchIssues)),
Releases: ctx.FormBool(string(repo_model.WatchReleases)), Releases: ctx.FormBool(string(repo_model.WatchReleases)),
} }
if !opts.PullRequests && !opts.Issues && !opts.Releases { if err := repo_model.WatchRepoWithOptions(ctx, ctx.Doer, ctx.Repo.Repository, opts); err != nil {
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) handleActionError(ctx, err)
return return
} }
if err := repo_model.SetWatchOptions(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, opts); err != nil {
ctx.ServerError("SetWatchOptions", err)
return
}
ctx.JSONRedirect("") ctx.JSONRedirect("")
} }
+1 -1
View File
@@ -1739,7 +1739,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/watchers", repo.Watchers) m.Get("/watchers", repo.Watchers)
m.Get("/search", reqUnitCodeReader, repo.Search) m.Get("/search", reqUnitCodeReader, repo.Search)
m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar) m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar)
m.Post("/action/{action:watch|unwatch|ignore}", reqSignIn, repo.ActionWatch) m.Post("/action/{action:watch|participate|ignore}", reqSignIn, repo.ActionWatch)
m.Post("/action/watch/options", reqSignIn, repo.ActionWatchOptions) m.Post("/action/watch/options", reqSignIn, repo.ActionWatchOptions)
m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer) m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer)
}, optSignIn, context.RepoAssignment) }, optSignIn, context.RepoAssignment)
+1 -2
View File
@@ -644,8 +644,7 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
ctx.ServerError("GetWatch", err) ctx.ServerError("GetWatch", err)
return return
} }
ctx.Data["Watch"] = watch ctx.Data["RepoWatch"] = watch
ctx.Data["IsWatchingRepo"] = repo_model.IsWatchMode(watch.Mode)
ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID) ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID)
} }
+14 -12
View File
@@ -69,20 +69,23 @@ func notifyWatchers(ctx context.Context, act *activities_model.Action, watchers
act.UserID = watcher.UserID act.UserID = watcher.UserID
act.Repo.Units = nil act.Repo.Units = nil
var allowed bool
switch act.OpType { switch act.OpType {
case activities_model.ActionCommitRepo, activities_model.ActionPushTag, activities_model.ActionDeleteTag, activities_model.ActionPublishRelease, activities_model.ActionDeleteBranch: case activities_model.ActionCommitRepo, activities_model.ActionPushTag, activities_model.ActionDeleteTag, activities_model.ActionDeleteBranch:
if !permCode[i] { allowed = permCode[i] && watcher.IsWatchingAll()
continue case activities_model.ActionPublishRelease:
} allowed = permCode[i] && watcher.Releases
case activities_model.ActionCreateIssue, activities_model.ActionCommentIssue, activities_model.ActionCloseIssue, activities_model.ActionReopenIssue: case activities_model.ActionCreateIssue, activities_model.ActionCommentIssue, activities_model.ActionCloseIssue, activities_model.ActionReopenIssue:
if !permIssue[i] { allowed = permIssue[i] && watcher.Issues
continue case activities_model.ActionCreatePullRequest, activities_model.ActionCommentPull, activities_model.ActionMergePullRequest, activities_model.ActionClosePullRequest,
} activities_model.ActionReopenPullRequest, activities_model.ActionAutoMergePullRequest, activities_model.ActionApprovePullRequest,
case activities_model.ActionCreatePullRequest, activities_model.ActionCommentPull, activities_model.ActionMergePullRequest, activities_model.ActionClosePullRequest, activities_model.ActionReopenPullRequest, activities_model.ActionAutoMergePullRequest: activities_model.ActionRejectPullRequest, activities_model.ActionPullReviewDismissed, activities_model.ActionPullRequestReadyForReview:
if !permPR[i] { allowed = permPR[i] && watcher.PullRequests
continue
}
default: default:
allowed = watcher.IsWatchingAll() // repository events have no watch option of their own
}
if !allowed {
continue
} }
if err := db.Insert(ctx, act); err != nil { if err := db.Insert(ctx, act); err != nil {
@@ -120,7 +123,6 @@ func NotifyWatchers(ctx context.Context, acts ...*activities_model.Action) error
if err != nil { if err != nil {
return fmt.Errorf("get watchers: %w", err) return fmt.Errorf("get watchers: %w", err)
} }
permCode := make([]bool, len(watchers)) permCode := make([]bool, len(watchers))
permIssue := make([]bool, len(watchers)) permIssue := make([]bool, len(watchers))
permPR := make([]bool, len(watchers)) permPR := make([]bool, len(watchers))
+16
View File
@@ -200,3 +200,19 @@ func TestNotifyWatchers(t *testing.T) {
OpType: action.OpType, OpType: action.OpType,
}) })
} }
func TestNotifyWatchersRespectsWatchOptions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// user 1 watches repo 1 for issues only, user 4 keeps every event
assert.NoError(t, repo_model.SetWatchOptions(t.Context(), 1, 1, repo_model.WatchOptions{Issues: true}))
assert.NoError(t, NotifyWatchers(t.Context(),
&activities_model.Action{ActUserID: 8, RepoID: 1, OpType: activities_model.ActionCreateIssue},
&activities_model.Action{ActUserID: 8, RepoID: 1, OpType: activities_model.ActionApprovePullRequest},
))
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{UserID: 1, RepoID: 1, OpType: activities_model.ActionCreateIssue})
unittest.AssertNotExistsBean(t, &activities_model.Action{UserID: 1, RepoID: 1, OpType: activities_model.ActionApprovePullRequest})
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{UserID: 4, RepoID: 1, OpType: activities_model.ActionApprovePullRequest})
}
+25 -21
View File
@@ -1,11 +1,12 @@
{{$isIgnoring := and $.IsSigned $.Watch.IsIgnoring}} {{$isIgnoring := and $.IsSigned $.RepoWatch.IsIgnoring}}
{{$buttonText := ctx.Locale.Tr (Iif $isIgnoring "repo.ignoring" (Iif $.IsWatchingRepo "repo.watching" "repo.watch"))}} {{$buttonText := ctx.Locale.Tr (Iif $isIgnoring "repo.ignoring" (Iif $.RepoWatch.IsWatching "repo.watching" "repo.watch"))}}
{{$count := CountFmt .Repository.NumWatches}} {{$count := CountFmt .Repository.NumWatches}}
<div id="repo-header-watch" class="flex-text-block"> <div id="repo-header-watch" class="flex-text-block">
{{if $.IsSigned}} {{if $.IsSigned}}
{{$isAll := and $.IsWatchingRepo $.Watch.PullRequests $.Watch.Issues $.Watch.Releases}} {{$mode := $.RepoWatch.SelectedMode}}
{{$isCustom := and $.IsWatchingRepo (not $isAll)}} {{$isAll := eq $mode "all"}}
{{$isParticipating := not (or $.IsWatchingRepo $isIgnoring)}} {{$isCustom := eq $mode "custom"}}
{{$isParticipating := eq $mode "participate"}}
<button class="ui dropdown custom compact small basic button" data-global-init="initRepoWatchMenu" aria-label="{{$buttonText}}"> <button class="ui dropdown custom compact small basic button" data-global-init="initRepoWatchMenu" aria-label="{{$buttonText}}">
{{svg (Iif $isIgnoring "octicon-eye-closed" "octicon-eye")}} {{svg (Iif $isIgnoring "octicon-eye-closed" "octicon-eye")}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span> <span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
@@ -13,34 +14,37 @@
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
</button> </button>
<div class="tippy-target"> <div class="tippy-target">
{{$participating := ctx.Locale.Tr "repo.watch.mode.participating"}} {{$testParticipating := ctx.Locale.Tr "repo.watch.mode.participating"}}
<a class="item{{if $isParticipating}} active{{end}}" role="menuitem" aria-label="{{$participating}}" <a class="item{{if $isParticipating}} active{{end}}" role="menuitem" aria-label="{{$testParticipating}}"
data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/unwatch" data-fetch-sync="$body #repo-header-watch" data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/participate" data-fetch-sync="$body #repo-header-watch"
> >
{{svg "octicon-check" 16 (Iif $isParticipating "" "tw-invisible")}} {{svg "octicon-check" 16 (Iif $isParticipating "" "tw-invisible")}}
<div>{{$participating}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.participating.desc"}}</div></div> <div>{{$testParticipating}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.participating.desc"}}</div></div>
</a> </a>
{{$all := ctx.Locale.Tr "repo.watch.mode.all"}} {{$textAll := ctx.Locale.Tr "repo.watch.mode.all"}}
<a class="item{{if $isAll}} active{{end}}" role="menuitem" aria-label="{{$all}}" <a class="item{{if $isAll}} active{{end}}" role="menuitem" aria-label="{{$textAll}}"
data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/watch" data-fetch-sync="$body #repo-header-watch" data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/watch" data-fetch-sync="$body #repo-header-watch"
> >
{{svg "octicon-check" 16 (Iif $isAll "" "tw-invisible")}} {{svg "octicon-check" 16 (Iif $isAll "" "tw-invisible")}}
<div>{{$all}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.all.desc"}}</div></div> <div>{{$textAll}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.all.desc"}}</div></div>
</a> </a>
{{$ignore := ctx.Locale.Tr "repo.watch.mode.ignore"}} {{$textIgnore := ctx.Locale.Tr "repo.watch.mode.ignore"}}
<a class="item{{if $.Watch.IsIgnoring}} active{{end}}" role="menuitem" aria-label="{{$ignore}}" <a class="item{{if $isIgnoring}} active{{end}}" role="menuitem" aria-label="{{$textIgnore}}"
data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/ignore" data-fetch-sync="$body #repo-header-watch" data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/ignore" data-fetch-sync="$body #repo-header-watch"
> >
{{svg "octicon-check" 16 (Iif $.Watch.IsIgnoring "" "tw-invisible")}} {{svg "octicon-check" 16 (Iif $isIgnoring "" "tw-invisible")}}
<div>{{$ignore}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.ignore.desc"}}</div></div> <div>{{$textIgnore}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.ignore.desc"}}</div></div>
</a> </a>
{{$custom := ctx.Locale.Tr "repo.watch.mode.custom"}} {{$textCustom := ctx.Locale.Tr "repo.watch.mode.custom"}}
<a class="item{{if $isCustom}} active{{end}}" role="menuitem" aria-label="{{$custom}}" <a class="item {{if $isCustom}}active{{end}} show-modal" role="menuitem" aria-label="{{$textCustom}}"
data-global-click="onRepoWatchOptionsClick" data-url="{{$.RepoLink}}/action/watch/options" data-modal="#repo-watch-options-modal"
data-issues="{{$.Watch.Issues}}" data-pull-requests="{{$.Watch.PullRequests}}" data-releases="{{$.Watch.Releases}}" data-modal-form.url="{{$.RepoLink}}/action/watch/options"
data-modal-issues="{{$.RepoWatch.Issues}}"
data-modal-pull_requests="{{$.RepoWatch.PullRequests}}"
data-modal-releases="{{$.RepoWatch.Releases}}"
> >
{{svg "octicon-check" 16 (Iif $isCustom "" "tw-invisible")}} {{svg "octicon-check" 16 (Iif $isCustom "" "tw-invisible")}}
<div>{{$custom}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.custom.desc"}}</div></div> <div>{{$textCustom}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.custom.desc"}}</div></div>
</a> </a>
</div> </div>
{{else}} {{else}}
+13 -6
View File
@@ -2,13 +2,20 @@
<div class="header">{{ctx.Locale.Tr "notifications"}}</div> <div class="header">{{ctx.Locale.Tr "notifications"}}</div>
<form class="ui form form-fetch-action" method="post"> <form class="ui form form-fetch-action" method="post">
<div class="content"> <div class="content">
{{range $name := StringUtils.Split "issues,pull_requests,releases" ","}} <div class="flex-relaxed-list tw-gap-4">
<div class="field"> <div class="ui checkbox">
<div class="ui checkbox"> <input name="issues" type="checkbox">
<input name="{{$name}}" type="checkbox"><label>{{ctx.Locale.Tr (printf "repo.watch.options.%s" $name)}}</label> <label>{{ctx.Locale.Tr "repo.watch.options.issues"}}</label>
</div>
</div> </div>
{{end}} <div class="ui checkbox">
<input name="pull_requests" type="checkbox">
<label>{{ctx.Locale.Tr "repo.watch.options.pull_requests"}}</label>
</div>
<div class="ui checkbox">
<input name="releases" type="checkbox">
<label>{{ctx.Locale.Tr "repo.watch.options.releases"}}</label>
</div>
</div>
</div> </div>
{{template "base/modal_actions_confirm" dict "ModalButtonTypes" "confirm"}} {{template "base/modal_actions_confirm" dict "ModalButtonTypes" "confirm"}}
</form> </form>
+6 -3
View File
@@ -57,9 +57,12 @@
</a> </a>
{{$watch := and $.Watches (index $.Watches .ID)}} {{$watch := and $.Watches (index $.Watches .ID)}}
{{if $watch}} {{if $watch}}
<button class="btn flex-text-inline" data-global-click="onRepoWatchOptionsClick" <button class="btn flex-text-inline show-modal"
data-url="{{.Link}}/action/watch/options" data-modal="#repo-watch-options-modal"
data-issues="{{$watch.Issues}}" data-pull-requests="{{$watch.PullRequests}}" data-releases="{{$watch.Releases}}" data-modal-form.url="{{.Link}}/action/watch/options"
data-modal-issues="{{$watch.Issues}}"
data-modal-pull_requests="{{$watch.PullRequests}}"
data-modal-releases="{{$watch.Releases}}"
data-tooltip-content="{{ctx.Locale.Tr "notifications"}}" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}"
>{{svg "octicon-gear" 16}}</button> >{{svg "octicon-gear" 16}}</button>
{{end}} {{end}}
+3 -1
View File
@@ -111,8 +111,10 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
if (attrTargetProp) { if (attrTargetProp) {
assignElementProperty(attrTarget, attrTargetProp, attrib.value); assignElementProperty(attrTarget, attrTargetProp, attrib.value);
} else if (attrTarget.matches('input[type=checkbox], input[type=radio]')) {
(attrTarget as HTMLInputElement).checked = attrib.value === 'true';
} else if (attrTarget.matches('input, textarea')) { } else if (attrTarget.matches('input, textarea')) {
(attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox (attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value;
} else { } else {
attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
} }
+3 -18
View File
@@ -1,15 +1,10 @@
import {createTippy} from '../modules/tippy.ts'; import {createTippy} from '../modules/tippy.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts';
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
import type {Instance} from 'tippy.js';
let watchMenuTippy: Instance | null = null;
export function initRepoWatch() { export function initRepoWatch() {
registerGlobalInitFunc('initRepoWatchMenu', (btn: HTMLElement) => { registerGlobalInitFunc('initRepoWatchMenu', (btn: HTMLElement) => {
watchMenuTippy?.destroy(); // a watch action replaces the button, orphaning the old menu
const menu = btn.nextElementSibling!; const menu = btn.nextElementSibling!;
watchMenuTippy = createTippy(btn, { const watchMenuTippy = createTippy(btn, {
content: menu, content: menu,
theme: 'menu', theme: 'menu',
maxWidth: 350, maxWidth: 350,
@@ -18,16 +13,6 @@ export function initRepoWatch() {
interactive: true, interactive: true,
hideOnClick: true, hideOnClick: true,
}); });
menu.addEventListener('click', () => watchMenuTippy!.hide()); menu.addEventListener('click', () => watchMenuTippy.hide());
});
registerGlobalEventFunc('click', 'onRepoWatchOptionsClick', (btn: HTMLElement) => {
const elModal = document.querySelector<HTMLElement>('#repo-watch-options-modal')!;
const form = elModal.querySelector<HTMLFormElement>('form')!;
form.action = btn.getAttribute('data-url')!;
for (const el of form.querySelectorAll<HTMLInputElement>('input[type=checkbox]')) {
el.checked = btn.getAttribute(`data-${el.name.replaceAll('_', '-')}`) === 'true';
}
showFomanticModal(elModal);
}); });
} }