diff options
-rw-r--r-- | .eslintrc | 1 | ||||
-rw-r--r-- | custom/conf/app.ini.sample | 8 | ||||
-rw-r--r-- | docs/content/doc/advanced/config-cheat-sheet.en-us.md | 7 | ||||
-rw-r--r-- | modules/setting/setting.go | 15 | ||||
-rw-r--r-- | modules/templates/helper.go | 7 | ||||
-rw-r--r-- | routers/user/notification.go | 53 | ||||
-rw-r--r-- | templates/base/head.tmpl | 5 | ||||
-rw-r--r-- | templates/base/head_navbar.tmpl | 11 | ||||
-rw-r--r-- | templates/user/notification/notification.tmpl | 118 | ||||
-rw-r--r-- | templates/user/notification/notification_div.tmpl | 128 | ||||
-rw-r--r-- | web_src/js/features/notification.js | 110 | ||||
-rw-r--r-- | web_src/js/index.js | 8 |
12 files changed, 331 insertions, 140 deletions
@@ -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 691a65cc53..fdf974d117 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 fd32bfd161..9d9d2755ed 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 069a3556da..bf2ed6111e 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 b5b4987427..8112880f43 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 74803f149e..9724c81088 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 e0765d59d3..2d7d737a00 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}}, + }, }; </script> <link rel="shortcut icon" href="{{StaticUrlPrefix}}/img/favicon.png"> diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index de02bca1f7..cedf29e2e9 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -46,12 +46,11 @@ <span class="text"> <span class="fitted">{{svg "octicon-bell" 16}}</span> <span class="sr-mobile-only">{{.i18n.Tr "notifications"}}</span> - - {{if .NotificationUnreadCount}} - <span class="ui red label"> - {{.NotificationUnreadCount}} - </span> - {{end}} + {{$notificationUnreadCount := 0}} + {{if .NotificationUnreadCount}}{{$notificationUnreadCount = call .NotificationUnreadCount}}{{end}} + <span class="ui red label {{if not $notificationUnreadCount}}hidden{{end}} notification_count"> + {{$notificationUnreadCount}} + </span> </span> </a> diff --git a/templates/user/notification/notification.tmpl b/templates/user/notification/notification.tmpl index c4f744a291..b483c15e95 100644 --- a/templates/user/notification/notification.tmpl +++ b/templates/user/notification/notification.tmpl @@ -1,119 +1,3 @@ {{template "base/head" .}} - -<div class="user notification"> - <div class="ui container"> - <h1 class="ui dividing header">{{.i18n.Tr "notification.notifications"}}</h1> - - <div class="ui top attached tabular menu"> - <a href="{{AppSubUrl}}/notifications?q=unread" class="{{if eq .Status 1}}active{{end}} item"> - {{.i18n.Tr "notification.unread"}} - {{if .NotificationUnreadCount}} - <div class="ui label">{{.NotificationUnreadCount}}</div> - {{end}} - </a> - <a href="{{AppSubUrl}}/notifications?q=read" class="{{if eq .Status 2}}active{{end}} item"> - {{.i18n.Tr "notification.read"}} - </a> - {{if and (eq .Status 1) (.NotificationUnreadCount)}} - <form action="{{AppSubUrl}}/notifications/purge" method="POST" style="margin-left: auto;"> - {{$.CsrfTokenHtml}} - <button class="ui mini button primary" title='{{$.i18n.Tr "notification.mark_all_as_read"}}'> - {{svg "octicon-checklist" 16}} - </button> - </form> - {{end}} - </div> - <div class="ui bottom attached active tab segment"> - {{if eq (len .Notifications) 0}} - {{if eq .Status 1}} - {{.i18n.Tr "notification.no_unread"}} - {{else}} - {{.i18n.Tr "notification.no_read"}} - {{end}} - {{else}} - <table class="ui unstackable striped very compact small selectable table"> - <tbody> - {{range $notification := .Notifications}} - {{$issue := $notification.Issue}} - {{$repo := $notification.Repository}} - {{$repoOwner := $repo.MustOwner}} - - <tr data-href="{{$notification.HTMLURL}}"> - <td class="collapsing"> - {{if eq $notification.Status 3}} - <span class="blue">{{svg "octicon-pin" 16}}</span> - {{else if $issue.IsPull}} - {{if $issue.IsClosed}} - {{if $issue.GetPullRequest.HasMerged}} - <span class="purple">{{svg "octicon-git-merge" 16}}</span> - {{else}} - <span class="red">{{svg "octicon-git-pull-request" 16}}</span> - {{end}} - {{else}} - <span class="green">{{svg "octicon-git-pull-request" 16}}</span> - {{end}} - {{else}} - {{if $issue.IsClosed}} - <span class="red">{{svg "octicon-issue-closed" 16}}</span> - {{else}} - <span class="green">{{svg "octicon-issue-opened" 16}}</span> - {{end}} - {{end}} - </td> - <td class="eleven wide"> - <a class="item" href="{{$notification.HTMLURL}}"> - #{{$issue.Index}} - {{$issue.Title}} - </a> - </td> - <td> - <a class="item" href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}"> - {{$repoOwner.Name}}/{{$repo.Name}} - </a> - </td> - <td class="collapsing"> - {{if ne $notification.Status 3}} - <form action="{{AppSubUrl}}/notifications/status" method="POST"> - {{$.CsrfTokenHtml}} - <input type="hidden" name="notification_id" value="{{$notification.ID}}" /> - <input type="hidden" name="status" value="pinned" /> - <button class="ui mini button" title='{{$.i18n.Tr "notification.pin"}}'> - {{svg "octicon-pin" 16}} - </button> - </form> - {{end}} - </td> - <td class="collapsing"> - {{if or (eq $notification.Status 1) (eq $notification.Status 3)}} - <form action="{{AppSubUrl}}/notifications/status" method="POST"> - {{$.CsrfTokenHtml}} - <input type="hidden" name="notification_id" value="{{$notification.ID}}" /> - <input type="hidden" name="status" value="read" /> - <input type="hidden" name="page" value="{{$.Page.Paginater.Current}}" /> - <button class="ui mini button" title='{{$.i18n.Tr "notification.mark_as_read"}}'> - {{svg "octicon-check" 16}} - </button> - </form> - {{else if eq $notification.Status 2}} - <form action="{{AppSubUrl}}/notifications/status" method="POST"> - {{$.CsrfTokenHtml}} - <input type="hidden" name="notification_id" value="{{$notification.ID}}" /> - <input type="hidden" name="status" value="unread" /> - <input type="hidden" name="page" value="{{$.Page.Paginater.Current}}" /> - <button class="ui mini button" title='{{$.i18n.Tr "notification.mark_as_unread"}}'> - {{svg "octicon-bell" 16}} - </button> - </form> - {{end}} - </td> - </tr> - {{end}} - </tbody> - </table> - {{end}} - </div> - - {{template "base/paginate" .}} - </div> -</div> - +{{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 0000000000..18054c479a --- /dev/null +++ b/templates/user/notification/notification_div.tmpl @@ -0,0 +1,128 @@ +<div class="user notification" id="notification_div" data-params="{{.Page.GetParams}}"> + <div class="ui container"> + <h1 class="ui dividing header">{{.i18n.Tr "notification.notifications"}}</h1> + <div class="ui top attached tabular menu"> + {{ $notificationUnreadCount := call .NotificationUnreadCount}} + <a href="{{AppSubUrl}}/notifications?q=unread" class="{{if eq .Status 1}}active{{end}} item"> + {{.i18n.Tr "notification.unread"}} + <div class="ui label {{if not $notificationUnreadCount}}hidden{{end}}">{{$notificationUnreadCount}}</div> + </a> + <a href="{{AppSubUrl}}/notifications?q=read" class="{{if eq .Status 2}}active{{end}} item"> + {{.i18n.Tr "notification.read"}} + </a> + {{if and (eq .Status 1)}} + <form action="{{AppSubUrl}}/notifications/purge" method="POST" style="margin-left: auto;"> + {{$.CsrfTokenHtml}} + <div class="{{if not $notificationUnreadCount}}hide{{end}}"> + <button class="ui mini button primary" title='{{$.i18n.Tr "notification.mark_all_as_read"}}'> + {{svg "octicon-checklist" 16}} + </button> + </div> + </form> + {{end}} + </div> + <div class="ui bottom attached active tab segment"> + {{if eq (len .Notifications) 0}} + {{if eq .Status 1}} + {{.i18n.Tr "notification.no_unread"}} + {{else}} + {{.i18n.Tr "notification.no_read"}} + {{end}} + {{else}} + <table class="ui unstackable striped very compact small selectable table" id="notification_table"> + <tbody> + {{range $notification := .Notifications}} + {{$issue := .Issue}} + {{$repo := .Repository}} + {{$repoOwner := $repo.MustOwner}} + <tr id="notification_{{.ID}}"> + <td class="collapsing" data-href="{{.HTMLURL}}"> + {{if eq .Status 3}} + <span class="blue">{{svg "octicon-pin" 16}}</span> + {{else if $issue.IsPull}} + {{if $issue.IsClosed}} + {{if $issue.GetPullRequest.HasMerged}} + <span class="purple">{{svg "octicon-git-merge" 16}}</span> + {{else}} + <span class="red">{{svg "octicon-git-pull-request" 16}}</span> + {{end}} + {{else}} + <span class="green">{{svg "octicon-git-pull-request" 16}}</span> + {{end}} + {{else}} + {{if $issue.IsClosed}} + <span class="red">{{svg "octicon-issue-closed" 16}}</span> + {{else}} + <span class="green">{{svg "octicon-issue-opened" 16}}</span> + {{end}} + {{end}} + </td> + <td class="eleven wide" data-href="{{.HTMLURL}}"> + <a class="item" href="{{.HTMLURL}}"> + #{{$issue.Index}} - {{$issue.Title}} + </a> + </td> + <td data-href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}"> + <a class="item" href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}"> + {{$repoOwner.Name}}/{{$repo.Name}} + </a> + </td> + <td class="collapsing"> + {{if ne .Status 3}} + <form action="{{AppSubUrl}}/notifications/status" method="POST"> + {{$.CsrfTokenHtml}} + <input type="hidden" name="notification_id" value="{{.ID}}" /> + <input type="hidden" name="status" value="pinned" /> + <button class="ui mini button" title='{{$.i18n.Tr "notification.pin"}}' + data-url="{{AppSubUrl}}/notifications/status" + data-status="pinned" + data-page="{{$.Page.Paginater.Current}}" + data-notification-id="{{.ID}}" + data-q="{{$.Keyword}}"> + {{svg "octicon-pin" 16}} + </button> + </form> + {{end}} + </td> + <td class="collapsing"> + {{if or (eq .Status 1) (eq .Status 3)}} + <form action="{{AppSubUrl}}/notifications/status" method="POST"> + {{$.CsrfTokenHtml}} + <input type="hidden" name="notification_id" value="{{.ID}}" /> + <input type="hidden" name="status" value="read" /> + <input type="hidden" name="page" value="{{$.Page.Paginater.Current}}" /> + <button class="ui mini button" title='{{$.i18n.Tr "notification.mark_as_read"}}' + data-url="{{AppSubUrl}}/notifications/status" + data-status="read" + data-page="{{$.Page.Paginater.Current}}" + data-notification-id="{{.ID}}" + data-q="{{$.Keyword}}"> + {{svg "octicon-check" 16}} + </button> + </form> + {{else if eq .Status 2}} + <form action="{{AppSubUrl}}/notifications/status" method="POST"> + {{$.CsrfTokenHtml}} + <input type="hidden" name="notification_id" value="{{.ID}}" /> + <input type="hidden" name="status" value="unread" /> + <input type="hidden" name="page" value="{{$.Page.Paginater.Current}}" /> + <button class="ui mini button" title='{{$.i18n.Tr "notification.mark_as_unread"}}' + data-url="{{AppSubUrl}}/notifications/status" + data-status="unread" + data-page="{{$.Page.Paginater.Current}}" + data-notification-id="{{.ID}}" + data-q="{{$.Keyword}}"> + {{svg "octicon-bell" 16}} + </button> + </form> + {{end}} + </td> + </tr> + {{end}} + </tbody> + </table> + {{end}} + </div> + {{template "base/paginate" .}} + </div> +</div> diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js new file mode 100644 index 0000000000..3f2af4de91 --- /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 ed747765a0..9e699c1a2e 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 <td> 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) { |