diff --git a/.eslintrc b/.eslintrc index 8fd53d54a..a8f7f1ae2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -55,6 +55,7 @@ rules: no-param-reassign: [0] no-plusplus: [0] no-restricted-syntax: [0] + no-return-await: [0] no-shadow: [0] no-unused-vars: [2, {args: all, argsIgnorePattern: ^_, varsIgnorePattern: ^_, ignoreRestSiblings: true}] no-use-before-define: [0] diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample index 691a65cc5..fdf974d11 100644 --- a/custom/conf/app.ini.sample +++ b/custom/conf/app.ini.sample @@ -200,6 +200,14 @@ AUTHOR = Gitea - Git with a cup of tea DESCRIPTION = Gitea (Git with a cup of tea) is a painless self-hosted Git service written in Go KEYWORDS = go,git,self-hosted,gitea +[ui.notification] +; Control how often notification is queried to update the notification +; The timeout will increase to MAX_TIMEOUT in TIMEOUT_STEPs if the notification count is unchanged +; Set MIN_TIMEOUT to 0 to turn off +MIN_TIMEOUT = 10s +MAX_TIMEOUT = 60s +TIMEOUT_STEP = 10s + [markdown] ; Render soft line breaks as hard line breaks, which means a single newline character between ; paragraphs will cause a line break and adding trailing whitespace to paragraphs is not diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index fd32bfd16..9d9d2755e 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -140,6 +140,13 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. - `NOTICE_PAGING_NUM`: **25**: Number of notices that are shown in one page. - `ORG_PAGING_NUM`: **50**: Number of organizations that are shown in one page. +### UI - Notification (`ui.notification`) + +- `MIN_TIMEOUT`: **10s**: These options control how often notification is queried to update the notification count. On page load the notification count will be checked after `MIN_TIMEOUT`. The timeout will increase to `MAX_TIMEOUT` by `TIMEOUT_STEP` if the notification count is unchanged. Set MIN_TIMEOUT to 0 to turn off. +- `MAX_TIMEOUT`: **60s**. +- `TIMEOUT_STEP`: **10s**. + + ## Markdown (`markdown`) - `ENABLE_HARD_LINE_BREAK`: **true**: Render soft line breaks as hard line breaks, which diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 069a3556d..bf2ed6111 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -181,6 +181,12 @@ var ( SearchRepoDescription bool UseServiceWorker bool + Notification struct { + MinTimeout time.Duration + TimeoutStep time.Duration + MaxTimeout time.Duration + } `ini:"ui.notification"` + Admin struct { UserPagingNum int RepoPagingNum int @@ -209,6 +215,15 @@ var ( DefaultTheme: `gitea`, Themes: []string{`gitea`, `arc-green`}, Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, + Notification: struct { + MinTimeout time.Duration + TimeoutStep time.Duration + MaxTimeout time.Duration + }{ + MinTimeout: 10 * time.Second, + TimeoutStep: 10 * time.Second, + MaxTimeout: 60 * time.Second, + }, Admin: struct { UserPagingNum int RepoPagingNum int diff --git a/modules/templates/helper.go b/modules/templates/helper.go index b5b498742..8112880f4 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -278,6 +278,13 @@ func NewFuncMap() []template.FuncMap { return "" } }, + "NotificationSettings": func() map[string]int { + return map[string]int{ + "MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond), + "TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond), + "MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond), + } + }, "contain": func(s []int64, id int64) bool { for i := 0; i < len(s); i++ { if s[i] == id { diff --git a/routers/user/notification.go b/routers/user/notification.go index 74803f149..9724c8108 100644 --- a/routers/user/notification.go +++ b/routers/user/notification.go @@ -7,6 +7,7 @@ package user import ( "errors" "fmt" + "net/http" "strconv" "strings" @@ -17,7 +18,8 @@ import ( ) const ( - tplNotification base.TplName = "user/notification/notification" + tplNotification base.TplName = "user/notification/notification" + tplNotificationDiv base.TplName = "user/notification/notification_div" ) // GetNotificationCount is the middleware that sets the notification count in the context @@ -30,17 +32,31 @@ func GetNotificationCount(c *context.Context) { return } - count, err := models.GetNotificationCount(c.User, models.NotificationStatusUnread) - if err != nil { - c.ServerError("GetNotificationCount", err) - return - } + c.Data["NotificationUnreadCount"] = func() int64 { + count, err := models.GetNotificationCount(c.User, models.NotificationStatusUnread) + if err != nil { + c.ServerError("GetNotificationCount", err) + return -1 + } - c.Data["NotificationUnreadCount"] = count + return count + } } // Notifications is the notifications page func Notifications(c *context.Context) { + getNotifications(c) + if c.Written() { + return + } + if c.QueryBool("div-only") { + c.HTML(http.StatusOK, tplNotificationDiv) + return + } + c.HTML(http.StatusOK, tplNotification) +} + +func getNotifications(c *context.Context) { var ( keyword = strings.Trim(c.Query("q"), " ") status models.NotificationStatus @@ -115,19 +131,13 @@ func Notifications(c *context.Context) { c.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount)) } - title := c.Tr("notifications") - if status == models.NotificationStatusUnread && total > 0 { - title = fmt.Sprintf("(%d) %s", total, title) - } - c.Data["Title"] = title + c.Data["Title"] = c.Tr("notifications") c.Data["Keyword"] = keyword c.Data["Status"] = status c.Data["Notifications"] = notifications pager.SetDefaultParams(c) c.Data["Page"] = pager - - c.HTML(200, tplNotification) } // NotificationStatusPost is a route for changing the status of a notification @@ -155,8 +165,17 @@ func NotificationStatusPost(c *context.Context) { return } - url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, c.Query("page")) - c.Redirect(url, 303) + if !c.QueryBool("noredirect") { + url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, c.Query("page")) + c.Redirect(url, http.StatusSeeOther) + } + + getNotifications(c) + if c.Written() { + return + } + + c.HTML(http.StatusOK, tplNotificationDiv) } // NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read @@ -168,5 +187,5 @@ func NotificationPurgePost(c *context.Context) { } url := fmt.Sprintf("%s/notifications", setting.AppSubURL) - c.Redirect(url, 303) + c.Redirect(url, http.StatusSeeOther) } diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index e0765d59d..2d7d737a0 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -94,6 +94,11 @@ U2F: {{if .RequireU2F}}true{{else}}false{{end}}, Heatmap: {{if .EnableHeatmap}}true{{else}}false{{end}}, heatmapUser: {{if .HeatmapUser}}'{{.HeatmapUser}}'{{else}}null{{end}}, + NotificationSettings: { + MinTimeout: {{NotificationSettings.MinTimeout}}, + TimeoutStep: {{NotificationSettings.TimeoutStep}}, + MaxTimeout: {{NotificationSettings.MaxTimeout}}, + }, }; diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index de02bca1f..cedf29e2e 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -46,12 +46,11 @@ {{svg "octicon-bell" 16}} {{.i18n.Tr "notifications"}} - - {{if .NotificationUnreadCount}} - - {{.NotificationUnreadCount}} - - {{end}} + {{$notificationUnreadCount := 0}} + {{if .NotificationUnreadCount}}{{$notificationUnreadCount = call .NotificationUnreadCount}}{{end}} + + {{$notificationUnreadCount}} + diff --git a/templates/user/notification/notification.tmpl b/templates/user/notification/notification.tmpl index c4f744a29..b483c15e9 100644 --- a/templates/user/notification/notification.tmpl +++ b/templates/user/notification/notification.tmpl @@ -1,119 +1,3 @@ {{template "base/head" .}} - -
-
-

{{.i18n.Tr "notification.notifications"}}

- - -
- {{if eq (len .Notifications) 0}} - {{if eq .Status 1}} - {{.i18n.Tr "notification.no_unread"}} - {{else}} - {{.i18n.Tr "notification.no_read"}} - {{end}} - {{else}} - - - {{range $notification := .Notifications}} - {{$issue := $notification.Issue}} - {{$repo := $notification.Repository}} - {{$repoOwner := $repo.MustOwner}} - - - - - - - - - {{end}} - -
- {{if eq $notification.Status 3}} - {{svg "octicon-pin" 16}} - {{else if $issue.IsPull}} - {{if $issue.IsClosed}} - {{if $issue.GetPullRequest.HasMerged}} - {{svg "octicon-git-merge" 16}} - {{else}} - {{svg "octicon-git-pull-request" 16}} - {{end}} - {{else}} - {{svg "octicon-git-pull-request" 16}} - {{end}} - {{else}} - {{if $issue.IsClosed}} - {{svg "octicon-issue-closed" 16}} - {{else}} - {{svg "octicon-issue-opened" 16}} - {{end}} - {{end}} - - - #{{$issue.Index}} - {{$issue.Title}} - - - - {{$repoOwner.Name}}/{{$repo.Name}} - - - {{if ne $notification.Status 3}} -
- {{$.CsrfTokenHtml}} - - - -
- {{end}} -
- {{if or (eq $notification.Status 1) (eq $notification.Status 3)}} -
- {{$.CsrfTokenHtml}} - - - - -
- {{else if eq $notification.Status 2}} -
- {{$.CsrfTokenHtml}} - - - - -
- {{end}} -
- {{end}} -
- - {{template "base/paginate" .}} -
-
- +{{template "user/notification/notification_div" .}} {{template "base/footer" .}} diff --git a/templates/user/notification/notification_div.tmpl b/templates/user/notification/notification_div.tmpl new file mode 100644 index 000000000..18054c479 --- /dev/null +++ b/templates/user/notification/notification_div.tmpl @@ -0,0 +1,128 @@ +
+
+

{{.i18n.Tr "notification.notifications"}}

+ +
+ {{if eq (len .Notifications) 0}} + {{if eq .Status 1}} + {{.i18n.Tr "notification.no_unread"}} + {{else}} + {{.i18n.Tr "notification.no_read"}} + {{end}} + {{else}} + + + {{range $notification := .Notifications}} + {{$issue := .Issue}} + {{$repo := .Repository}} + {{$repoOwner := $repo.MustOwner}} + + + + + + + + {{end}} + +
+ {{if eq .Status 3}} + {{svg "octicon-pin" 16}} + {{else if $issue.IsPull}} + {{if $issue.IsClosed}} + {{if $issue.GetPullRequest.HasMerged}} + {{svg "octicon-git-merge" 16}} + {{else}} + {{svg "octicon-git-pull-request" 16}} + {{end}} + {{else}} + {{svg "octicon-git-pull-request" 16}} + {{end}} + {{else}} + {{if $issue.IsClosed}} + {{svg "octicon-issue-closed" 16}} + {{else}} + {{svg "octicon-issue-opened" 16}} + {{end}} + {{end}} + + + #{{$issue.Index}} - {{$issue.Title}} + + + + {{$repoOwner.Name}}/{{$repo.Name}} + + + {{if ne .Status 3}} +
+ {{$.CsrfTokenHtml}} + + + +
+ {{end}} +
+ {{if or (eq .Status 1) (eq .Status 3)}} +
+ {{$.CsrfTokenHtml}} + + + + +
+ {{else if eq .Status 2}} +
+ {{$.CsrfTokenHtml}} + + + + +
+ {{end}} +
+ {{end}} +
+ {{template "base/paginate" .}} +
+
diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js new file mode 100644 index 000000000..3f2af4de9 --- /dev/null +++ b/web_src/js/features/notification.js @@ -0,0 +1,110 @@ +const {AppSubUrl, csrf, NotificationSettings} = window.config; + +export function initNotificationsTable() { + $('#notification_table .button').on('click', async function () { + const data = await updateNotification( + $(this).data('url'), + $(this).data('status'), + $(this).data('page'), + $(this).data('q'), + $(this).data('notification-id'), + ); + + $('#notification_div').replaceWith(data); + initNotificationsTable(); + await updateNotificationCount(); + + return false; + }); +} + +export function initNotificationCount() { + if (NotificationSettings.MinTimeout <= 0) { + return; + } + + const notificationCount = $('.notification_count'); + + if (notificationCount.length > 0) { + const fn = (timeout, lastCount) => { + setTimeout(async () => { + await updateNotificationCountWithCallback(fn, timeout, lastCount); + }, timeout); + }; + + fn(NotificationSettings.MinTimeout, notificationCount.text()); + } +} + +async function updateNotificationCountWithCallback(callback, timeout, lastCount) { + const currentCount = $('.notification_count').text(); + if (lastCount !== currentCount) { + callback(NotificationSettings.MinTimeout, currentCount); + return; + } + + const newCount = await updateNotificationCount(); + let needsUpdate = false; + + if (lastCount !== newCount) { + needsUpdate = true; + timeout = NotificationSettings.MinTimeout; + } else if (timeout < NotificationSettings.MaxTimeout) { + timeout += NotificationSettings.TimeoutStep; + } + + callback(timeout, newCount); + + const notificationDiv = $('#notification_div'); + if (notificationDiv.length > 0 && needsUpdate) { + const data = await $.ajax({ + type: 'GET', + url: `${AppSubUrl}/notifications?${notificationDiv.data('params')}`, + data: { + 'div-only': true, + } + }); + notificationDiv.replaceWith(data); + initNotificationsTable(); + } +} + +async function updateNotificationCount() { + const data = await $.ajax({ + type: 'GET', + url: `${AppSubUrl}/api/v1/notifications/new`, + headers: { + 'X-Csrf-Token': csrf, + }, + }); + + const notificationCount = $('.notification_count'); + if (data.new === 0) { + notificationCount.addClass('hidden'); + } else { + notificationCount.removeClass('hidden'); + } + + notificationCount.text(`${data.new}`); + + return `${data.new}`; +} + +async function updateNotification(url, status, page, q, notificationID) { + if (status !== 'pinned') { + $(`#notification_${notificationID}`).remove(); + } + + return $.ajax({ + type: 'POST', + url, + data: { + _csrf: csrf, + notification_id: notificationID, + status, + page, + q, + noredirect: true, + }, + }); +} diff --git a/web_src/js/index.js b/web_src/js/index.js index ed747765a..9e699c1a2 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -18,6 +18,7 @@ import initDateTimePicker from './features/datetimepicker.js'; import createDropzone from './features/dropzone.js'; import highlight from './features/highlight.js'; import ActivityTopAuthors from './components/ActivityTopAuthors.vue'; +import {initNotificationsTable, initNotificationCount} from './features/notification.js'; const {AppSubUrl, StaticUrlPrefix, csrf} = window.config; @@ -2431,6 +2432,11 @@ $(document).ready(async () => { window.location = $(this).data('href'); }); + // make table element clickable like a link + $('td[data-href]').click(function () { + window.location = $(this).data('href'); + }); + // Dropzone const $dropzone = $('#dropzone'); if ($dropzone.length > 0) { @@ -2606,6 +2612,8 @@ $(document).ready(async () => { initRepoStatusChecker(); initTemplateSearch(); initContextPopups(); + initNotificationsTable(); + initNotificationCount(); // Repo clone url. if ($('#repo-clone-url').length > 0) {