mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-22 08:42:32 -05:00
Add EventSource support (#11235)
If the browser supports EventSource switch to use this instead of polling notifications. Signed-off-by: Andrew Thornton art27@cantab.net
This commit is contained in:
parent
486e4c8087
commit
791353c03b
17 changed files with 676 additions and 27 deletions
|
@ -202,12 +202,15 @@ DESCRIPTION = Gitea (Git with a cup of tea) is a painless self-hosted Git servic
|
||||||
KEYWORDS = go,git,self-hosted,gitea
|
KEYWORDS = go,git,self-hosted,gitea
|
||||||
|
|
||||||
[ui.notification]
|
[ui.notification]
|
||||||
; Control how often notification is queried to update the notification
|
; Control how often the notification endpoint is polled to update the notification
|
||||||
; The timeout will increase to MAX_TIMEOUT in TIMEOUT_STEPs if the notification count is unchanged
|
; The timeout will increase to MAX_TIMEOUT in TIMEOUT_STEPs if the notification count is unchanged
|
||||||
; Set MIN_TIMEOUT to 0 to turn off
|
; Set MIN_TIMEOUT to 0 to turn off
|
||||||
MIN_TIMEOUT = 10s
|
MIN_TIMEOUT = 10s
|
||||||
MAX_TIMEOUT = 60s
|
MAX_TIMEOUT = 60s
|
||||||
TIMEOUT_STEP = 10s
|
TIMEOUT_STEP = 10s
|
||||||
|
; This setting determines how often the db is queried to get the latest notification counts.
|
||||||
|
; If the browser client supports EventSource, it will be used in preference to polling notification.
|
||||||
|
EVENT_SOURCE_UPDATE_TIME = 10s
|
||||||
|
|
||||||
[markdown]
|
[markdown]
|
||||||
; Render soft line breaks as hard line breaks, which means a single newline character between
|
; Render soft line breaks as hard line breaks, which means a single newline character between
|
||||||
|
|
|
@ -144,9 +144,10 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
|
||||||
|
|
||||||
### UI - Notification (`ui.notification`)
|
### 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.
|
- `MIN_TIMEOUT`: **10s**: These options control how often notification endpoint is polled 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**.
|
- `MAX_TIMEOUT`: **60s**.
|
||||||
- `TIMEOUT_STEP`: **10s**.
|
- `TIMEOUT_STEP`: **10s**.
|
||||||
|
- `EVENT_SOURCE_UPDATE_TIME`: **10s**: This setting determines how often the database is queried to update notification counts. If the browser client supports `EventSource`, it will be used in preference to polling notification endpoint.
|
||||||
|
|
||||||
|
|
||||||
## Markdown (`markdown`)
|
## Markdown (`markdown`)
|
||||||
|
|
78
integrations/eventsource_test.go
Normal file
78
integrations/eventsource_test.go
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package integrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"code.gitea.io/gitea/modules/eventsource"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventSourceManagerRun(t *testing.T) {
|
||||||
|
defer prepareTestEnv(t)()
|
||||||
|
manager := eventsource.GetManager()
|
||||||
|
|
||||||
|
eventChan := manager.Register(2)
|
||||||
|
defer func() {
|
||||||
|
manager.Unregister(2, eventChan)
|
||||||
|
// ensure the eventChan is closed
|
||||||
|
for {
|
||||||
|
_, ok := <-eventChan
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
expectNotificationCountEvent := func(count int64) func() bool {
|
||||||
|
return func() bool {
|
||||||
|
select {
|
||||||
|
case event, ok := <-eventChan:
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
data, ok := event.Data.(models.UserIDCount)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return event.Name == "notification-count" && data.Count == count
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
|
||||||
|
repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
|
||||||
|
thread5 := models.AssertExistsAndLoadBean(t, &models.Notification{ID: 5}).(*models.Notification)
|
||||||
|
assert.NoError(t, thread5.LoadAttributes())
|
||||||
|
session := loginUser(t, user2.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session)
|
||||||
|
|
||||||
|
var apiNL []api.NotificationThread
|
||||||
|
|
||||||
|
// -- mark notifications as read --
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?token=%s", token))
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
DecodeJSON(t, resp, &apiNL)
|
||||||
|
assert.Len(t, apiNL, 2)
|
||||||
|
|
||||||
|
lastReadAt := "2000-01-01T00%3A50%3A01%2B00%3A00" //946687801 <- only Notification 4 is in this filter ...
|
||||||
|
req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?last_read_at=%s&token=%s", user2.Name, repo1.Name, lastReadAt, token))
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusResetContent)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?token=%s", token))
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
DecodeJSON(t, resp, &apiNL)
|
||||||
|
assert.Len(t, apiNL, 1)
|
||||||
|
|
||||||
|
assert.Eventually(t, expectNotificationCountEvent(1), 30*time.Second, 1*time.Second)
|
||||||
|
}
|
|
@ -718,6 +718,21 @@ func getNotificationCount(e Engine, user *User, status NotificationStatus) (coun
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserIDCount is a simple coalition of UserID and Count
|
||||||
|
type UserIDCount struct {
|
||||||
|
UserID int64
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUIDsAndNotificationCounts between the two provided times
|
||||||
|
func GetUIDsAndNotificationCounts(since, until timeutil.TimeStamp) ([]UserIDCount, error) {
|
||||||
|
sql := `SELECT user_id, count(*) AS count FROM notification ` +
|
||||||
|
`WHERE user_id IN (SELECT user_id FROM notification WHERE updated_unix >= ? AND ` +
|
||||||
|
`updated_unix < ?) AND status = ? GROUP BY user_id`
|
||||||
|
var res []UserIDCount
|
||||||
|
return res, x.SQL(sql, since, until, NotificationStatusUnread).Find(&res)
|
||||||
|
}
|
||||||
|
|
||||||
func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error {
|
func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error {
|
||||||
notification, err := getIssueNotification(e, userID, issueID)
|
notification, err := getIssueNotification(e, userID, issueID)
|
||||||
// ignore if not exists
|
// ignore if not exists
|
||||||
|
|
119
modules/eventsource/event.go
Normal file
119
modules/eventsource/event.go
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package eventsource
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func wrapNewlines(w io.Writer, prefix []byte, value []byte) (sum int64, err error) {
|
||||||
|
if len(value) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
last := 0
|
||||||
|
for j := bytes.IndexByte(value, '\n'); j > -1; j = bytes.IndexByte(value[last:], '\n') {
|
||||||
|
n, err = w.Write(prefix)
|
||||||
|
sum += int64(n)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err = w.Write(value[last : last+j+1])
|
||||||
|
sum += int64(n)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last += j + 1
|
||||||
|
}
|
||||||
|
n, err = w.Write(prefix)
|
||||||
|
sum += int64(n)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err = w.Write(value[last:])
|
||||||
|
sum += int64(n)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err = w.Write([]byte("\n"))
|
||||||
|
sum += int64(n)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is an eventsource event, not all fields need to be set
|
||||||
|
type Event struct {
|
||||||
|
// Name represents the value of the event: tag in the stream
|
||||||
|
Name string
|
||||||
|
// Data is either JSONified []byte or interface{} that can be JSONd
|
||||||
|
Data interface{}
|
||||||
|
// ID represents the ID of an event
|
||||||
|
ID string
|
||||||
|
// Retry tells the receiver only to attempt to reconnect to the source after this time
|
||||||
|
Retry time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteTo writes data to w until there's no more data to write or when an error occurs.
|
||||||
|
// The return value n is the number of bytes written. Any error encountered during the write is also returned.
|
||||||
|
func (e *Event) WriteTo(w io.Writer) (int64, error) {
|
||||||
|
sum := int64(0)
|
||||||
|
nint := 0
|
||||||
|
n, err := wrapNewlines(w, []byte("event: "), []byte(e.Name))
|
||||||
|
sum += n
|
||||||
|
if err != nil {
|
||||||
|
return sum, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Data != nil {
|
||||||
|
var data []byte
|
||||||
|
switch v := e.Data.(type) {
|
||||||
|
case []byte:
|
||||||
|
data = v
|
||||||
|
case string:
|
||||||
|
data = []byte(v)
|
||||||
|
default:
|
||||||
|
var err error
|
||||||
|
data, err = json.Marshal(e.Data)
|
||||||
|
if err != nil {
|
||||||
|
return sum, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err := wrapNewlines(w, []byte("data: "), data)
|
||||||
|
sum += n
|
||||||
|
if err != nil {
|
||||||
|
return sum, err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err = wrapNewlines(w, []byte("id: "), []byte(e.ID))
|
||||||
|
sum += n
|
||||||
|
if err != nil {
|
||||||
|
return sum, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Retry != 0 {
|
||||||
|
nint, err = fmt.Fprintf(w, "retry: %d\n", int64(e.Retry/time.Millisecond))
|
||||||
|
sum += int64(nint)
|
||||||
|
if err != nil {
|
||||||
|
return sum, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nint, err = w.Write([]byte("\n"))
|
||||||
|
sum += int64(nint)
|
||||||
|
|
||||||
|
return sum, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Event) String() string {
|
||||||
|
buf := new(strings.Builder)
|
||||||
|
_, _ = e.WriteTo(buf)
|
||||||
|
return buf.String()
|
||||||
|
}
|
54
modules/eventsource/event_test.go
Normal file
54
modules/eventsource/event_test.go
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package eventsource
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_wrapNewlines(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefix string
|
||||||
|
value string
|
||||||
|
output string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"check no new lines",
|
||||||
|
"prefix: ",
|
||||||
|
"value",
|
||||||
|
"prefix: value\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check simple newline",
|
||||||
|
"prefix: ",
|
||||||
|
"value1\nvalue2",
|
||||||
|
"prefix: value1\nprefix: value2\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check pathological newlines",
|
||||||
|
"p: ",
|
||||||
|
"\n1\n\n2\n3\n",
|
||||||
|
"p: \np: 1\np: \np: 2\np: 3\np: \n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
w := &bytes.Buffer{}
|
||||||
|
gotSum, err := wrapNewlines(w, []byte(tt.prefix), []byte(tt.value))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("wrapNewlines() error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if gotSum != int64(len(tt.output)) {
|
||||||
|
t.Errorf("wrapNewlines() = %v, want %v", gotSum, int64(len(tt.output)))
|
||||||
|
}
|
||||||
|
if gotW := w.String(); gotW != tt.output {
|
||||||
|
t.Errorf("wrapNewlines() = %v, want %v", gotW, tt.output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
84
modules/eventsource/manager.go
Normal file
84
modules/eventsource/manager.go
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package eventsource
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager manages the eventsource Messengers
|
||||||
|
type Manager struct {
|
||||||
|
mutex sync.Mutex
|
||||||
|
|
||||||
|
messengers map[int64]*Messenger
|
||||||
|
}
|
||||||
|
|
||||||
|
var manager *Manager
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
manager = &Manager{
|
||||||
|
messengers: make(map[int64]*Messenger),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetManager returns a Manager and initializes one as singleton if there's none yet
|
||||||
|
func GetManager() *Manager {
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register message channel
|
||||||
|
func (m *Manager) Register(uid int64) <-chan *Event {
|
||||||
|
m.mutex.Lock()
|
||||||
|
messenger, ok := m.messengers[uid]
|
||||||
|
if !ok {
|
||||||
|
messenger = NewMessenger(uid)
|
||||||
|
m.messengers[uid] = messenger
|
||||||
|
}
|
||||||
|
m.mutex.Unlock()
|
||||||
|
return messenger.Register()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister message channel
|
||||||
|
func (m *Manager) Unregister(uid int64, channel <-chan *Event) {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
messenger, ok := m.messengers[uid]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if messenger.Unregister(channel) {
|
||||||
|
delete(m.messengers, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterAll message channels
|
||||||
|
func (m *Manager) UnregisterAll() {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
for _, messenger := range m.messengers {
|
||||||
|
messenger.UnregisterAll()
|
||||||
|
}
|
||||||
|
m.messengers = map[int64]*Messenger{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a message to a particular user
|
||||||
|
func (m *Manager) SendMessage(uid int64, message *Event) {
|
||||||
|
m.mutex.Lock()
|
||||||
|
messenger, ok := m.messengers[uid]
|
||||||
|
m.mutex.Unlock()
|
||||||
|
if ok {
|
||||||
|
messenger.SendMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageBlocking sends a message to a particular user
|
||||||
|
func (m *Manager) SendMessageBlocking(uid int64, message *Event) {
|
||||||
|
m.mutex.Lock()
|
||||||
|
messenger, ok := m.messengers[uid]
|
||||||
|
m.mutex.Unlock()
|
||||||
|
if ok {
|
||||||
|
messenger.SendMessageBlocking(message)
|
||||||
|
}
|
||||||
|
}
|
50
modules/eventsource/manager_run.go
Normal file
50
modules/eventsource/manager_run.go
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package eventsource
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"code.gitea.io/gitea/modules/graceful"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init starts this eventsource
|
||||||
|
func (m *Manager) Init() {
|
||||||
|
go graceful.GetManager().RunWithShutdownContext(m.Run)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs the manager within a provided context
|
||||||
|
func (m *Manager) Run(ctx context.Context) {
|
||||||
|
then := timeutil.TimeStampNow().Add(-2)
|
||||||
|
timer := time.NewTicker(setting.UI.Notification.EventSourceUpdateTime)
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
break loop
|
||||||
|
case <-timer.C:
|
||||||
|
now := timeutil.TimeStampNow().Add(-2)
|
||||||
|
|
||||||
|
uidCounts, err := models.GetUIDsAndNotificationCounts(then, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to get UIDcounts: %v", err)
|
||||||
|
}
|
||||||
|
for _, uidCount := range uidCounts {
|
||||||
|
m.SendMessage(uidCount.UserID, &Event{
|
||||||
|
Name: "notification-count",
|
||||||
|
Data: uidCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
then = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.UnregisterAll()
|
||||||
|
}
|
78
modules/eventsource/messenger.go
Normal file
78
modules/eventsource/messenger.go
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package eventsource
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// Messenger is a per uid message store
|
||||||
|
type Messenger struct {
|
||||||
|
mutex sync.Mutex
|
||||||
|
uid int64
|
||||||
|
channels []chan *Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMessenger creates a messenger for a particular uid
|
||||||
|
func NewMessenger(uid int64) *Messenger {
|
||||||
|
return &Messenger{
|
||||||
|
uid: uid,
|
||||||
|
channels: [](chan *Event){},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register returns a new chan []byte
|
||||||
|
func (m *Messenger) Register() <-chan *Event {
|
||||||
|
m.mutex.Lock()
|
||||||
|
// TODO: Limit the number of messengers per uid
|
||||||
|
channel := make(chan *Event, 1)
|
||||||
|
m.channels = append(m.channels, channel)
|
||||||
|
m.mutex.Unlock()
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister removes the provider chan []byte
|
||||||
|
func (m *Messenger) Unregister(channel <-chan *Event) bool {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
for i, toRemove := range m.channels {
|
||||||
|
if channel == toRemove {
|
||||||
|
m.channels = append(m.channels[:i], m.channels[i+1:]...)
|
||||||
|
close(toRemove)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(m.channels) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterAll removes all chan []byte
|
||||||
|
func (m *Messenger) UnregisterAll() {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
for _, channel := range m.channels {
|
||||||
|
close(channel)
|
||||||
|
}
|
||||||
|
m.channels = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends the message to all registered channels
|
||||||
|
func (m *Messenger) SendMessage(message *Event) {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
for i := range m.channels {
|
||||||
|
channel := m.channels[i]
|
||||||
|
select {
|
||||||
|
case channel <- message:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageBlocking sends the message to all registered channels and ensures it gets sent
|
||||||
|
func (m *Messenger) SendMessageBlocking(message *Event) {
|
||||||
|
m.mutex.Lock()
|
||||||
|
defer m.mutex.Unlock()
|
||||||
|
for i := range m.channels {
|
||||||
|
m.channels[i] <- message
|
||||||
|
}
|
||||||
|
}
|
|
@ -182,9 +182,10 @@ var (
|
||||||
UseServiceWorker bool
|
UseServiceWorker bool
|
||||||
|
|
||||||
Notification struct {
|
Notification struct {
|
||||||
MinTimeout time.Duration
|
MinTimeout time.Duration
|
||||||
TimeoutStep time.Duration
|
TimeoutStep time.Duration
|
||||||
MaxTimeout time.Duration
|
MaxTimeout time.Duration
|
||||||
|
EventSourceUpdateTime time.Duration
|
||||||
} `ini:"ui.notification"`
|
} `ini:"ui.notification"`
|
||||||
|
|
||||||
Admin struct {
|
Admin struct {
|
||||||
|
@ -216,13 +217,15 @@ var (
|
||||||
Themes: []string{`gitea`, `arc-green`},
|
Themes: []string{`gitea`, `arc-green`},
|
||||||
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
|
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
|
||||||
Notification: struct {
|
Notification: struct {
|
||||||
MinTimeout time.Duration
|
MinTimeout time.Duration
|
||||||
TimeoutStep time.Duration
|
TimeoutStep time.Duration
|
||||||
MaxTimeout time.Duration
|
MaxTimeout time.Duration
|
||||||
|
EventSourceUpdateTime time.Duration
|
||||||
}{
|
}{
|
||||||
MinTimeout: 10 * time.Second,
|
MinTimeout: 10 * time.Second,
|
||||||
TimeoutStep: 10 * time.Second,
|
TimeoutStep: 10 * time.Second,
|
||||||
MaxTimeout: 60 * time.Second,
|
MaxTimeout: 60 * time.Second,
|
||||||
|
EventSourceUpdateTime: 10 * time.Second,
|
||||||
},
|
},
|
||||||
Admin: struct {
|
Admin: struct {
|
||||||
UserPagingNum int
|
UserPagingNum int
|
||||||
|
|
|
@ -284,9 +284,10 @@ func NewFuncMap() []template.FuncMap {
|
||||||
},
|
},
|
||||||
"NotificationSettings": func() map[string]int {
|
"NotificationSettings": func() map[string]int {
|
||||||
return map[string]int{
|
return map[string]int{
|
||||||
"MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
|
"MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
|
||||||
"TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
|
"TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
|
||||||
"MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond),
|
"MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond),
|
||||||
|
"EventSourceUpdateTime": int(setting.UI.Notification.EventSourceUpdateTime / time.Millisecond),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contain": func(s []int64, id int64) bool {
|
"contain": func(s []int64, id int64) bool {
|
||||||
|
|
112
routers/events/events.go
Normal file
112
routers/events/events.go
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
"code.gitea.io/gitea/modules/eventsource"
|
||||||
|
"code.gitea.io/gitea/modules/graceful"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/routers/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Events listens for events
|
||||||
|
func Events(ctx *context.Context) {
|
||||||
|
// FIXME: Need to check if resp is actually a http.Flusher! - how though?
|
||||||
|
|
||||||
|
// Set the headers related to event streaming.
|
||||||
|
ctx.Resp.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
ctx.Resp.Header().Set("Cache-Control", "no-cache")
|
||||||
|
ctx.Resp.Header().Set("Connection", "keep-alive")
|
||||||
|
ctx.Resp.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
ctx.Resp.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Listen to connection close and un-register messageChan
|
||||||
|
notify := ctx.Req.Context().Done()
|
||||||
|
ctx.Resp.Flush()
|
||||||
|
|
||||||
|
shutdownCtx := graceful.GetManager().ShutdownContext()
|
||||||
|
|
||||||
|
uid := ctx.User.ID
|
||||||
|
|
||||||
|
messageChan := eventsource.GetManager().Register(uid)
|
||||||
|
|
||||||
|
unregister := func() {
|
||||||
|
eventsource.GetManager().Unregister(uid, messageChan)
|
||||||
|
// ensure the messageChan is closed
|
||||||
|
for {
|
||||||
|
_, ok := <-messageChan
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ctx.Resp.Write([]byte("\n")); err != nil {
|
||||||
|
log.Error("Unable to write to EventStream: %v", err)
|
||||||
|
unregister()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTicker(30 * time.Second)
|
||||||
|
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
event := &eventsource.Event{
|
||||||
|
Name: "ping",
|
||||||
|
}
|
||||||
|
_, err := event.WriteTo(ctx.Resp)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to write to EventStream for user %s: %v", ctx.User.Name, err)
|
||||||
|
go unregister()
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
ctx.Resp.Flush()
|
||||||
|
case <-notify:
|
||||||
|
go unregister()
|
||||||
|
break loop
|
||||||
|
case <-shutdownCtx.Done():
|
||||||
|
go unregister()
|
||||||
|
break loop
|
||||||
|
case event, ok := <-messageChan:
|
||||||
|
if !ok {
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle logout
|
||||||
|
if event.Name == "logout" {
|
||||||
|
if ctx.Session.ID() == event.Data {
|
||||||
|
_, _ = (&eventsource.Event{
|
||||||
|
Name: "logout",
|
||||||
|
Data: "here",
|
||||||
|
}).WriteTo(ctx.Resp)
|
||||||
|
ctx.Resp.Flush()
|
||||||
|
go unregister()
|
||||||
|
user.HandleSignOut(ctx)
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
// Replace the event - we don't want to expose the session ID to the user
|
||||||
|
event = (&eventsource.Event{
|
||||||
|
Name: "logout",
|
||||||
|
Data: "elsewhere",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := event.WriteTo(ctx.Resp)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to write to EventStream for user %s: %v", ctx.User.Name, err)
|
||||||
|
go unregister()
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
ctx.Resp.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Stop()
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/auth/sso"
|
"code.gitea.io/gitea/modules/auth/sso"
|
||||||
"code.gitea.io/gitea/modules/cache"
|
"code.gitea.io/gitea/modules/cache"
|
||||||
"code.gitea.io/gitea/modules/cron"
|
"code.gitea.io/gitea/modules/cron"
|
||||||
|
"code.gitea.io/gitea/modules/eventsource"
|
||||||
"code.gitea.io/gitea/modules/git"
|
"code.gitea.io/gitea/modules/git"
|
||||||
"code.gitea.io/gitea/modules/highlight"
|
"code.gitea.io/gitea/modules/highlight"
|
||||||
code_indexer "code.gitea.io/gitea/modules/indexer/code"
|
code_indexer "code.gitea.io/gitea/modules/indexer/code"
|
||||||
|
@ -123,6 +124,7 @@ func GlobalInit(ctx context.Context) {
|
||||||
if err := task.Init(); err != nil {
|
if err := task.Init(); err != nil {
|
||||||
log.Fatal("Failed to initialize task scheduler: %v", err)
|
log.Fatal("Failed to initialize task scheduler: %v", err)
|
||||||
}
|
}
|
||||||
|
eventsource.GetManager().Init()
|
||||||
}
|
}
|
||||||
if setting.EnableSQLite3 {
|
if setting.EnableSQLite3 {
|
||||||
log.Info("SQLite3 Supported")
|
log.Info("SQLite3 Supported")
|
||||||
|
|
|
@ -27,6 +27,7 @@ import (
|
||||||
"code.gitea.io/gitea/routers/admin"
|
"code.gitea.io/gitea/routers/admin"
|
||||||
apiv1 "code.gitea.io/gitea/routers/api/v1"
|
apiv1 "code.gitea.io/gitea/routers/api/v1"
|
||||||
"code.gitea.io/gitea/routers/dev"
|
"code.gitea.io/gitea/routers/dev"
|
||||||
|
"code.gitea.io/gitea/routers/events"
|
||||||
"code.gitea.io/gitea/routers/org"
|
"code.gitea.io/gitea/routers/org"
|
||||||
"code.gitea.io/gitea/routers/private"
|
"code.gitea.io/gitea/routers/private"
|
||||||
"code.gitea.io/gitea/routers/repo"
|
"code.gitea.io/gitea/routers/repo"
|
||||||
|
@ -340,6 +341,8 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
})
|
})
|
||||||
}, reqSignOut)
|
}, reqSignOut)
|
||||||
|
|
||||||
|
m.Any("/user/events", reqSignIn, events.Events)
|
||||||
|
|
||||||
m.Group("/login/oauth", func() {
|
m.Group("/login/oauth", func() {
|
||||||
m.Get("/authorize", bindIgnErr(auth.AuthorizationForm{}), user.AuthorizeOAuth)
|
m.Get("/authorize", bindIgnErr(auth.AuthorizationForm{}), user.AuthorizeOAuth)
|
||||||
m.Post("/grant", bindIgnErr(auth.GrantApplicationForm{}), user.GrantApplicationOAuth)
|
m.Post("/grant", bindIgnErr(auth.GrantApplicationForm{}), user.GrantApplicationOAuth)
|
||||||
|
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/auth/oauth2"
|
"code.gitea.io/gitea/modules/auth/oauth2"
|
||||||
"code.gitea.io/gitea/modules/base"
|
"code.gitea.io/gitea/modules/base"
|
||||||
"code.gitea.io/gitea/modules/context"
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
"code.gitea.io/gitea/modules/eventsource"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/password"
|
"code.gitea.io/gitea/modules/password"
|
||||||
"code.gitea.io/gitea/modules/recaptcha"
|
"code.gitea.io/gitea/modules/recaptcha"
|
||||||
|
@ -991,7 +992,8 @@ func LinkAccountPostRegister(ctx *context.Context, cpt *captcha.Captcha, form au
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/login")
|
ctx.Redirect(setting.AppSubURL + "/user/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSignOut(ctx *context.Context) {
|
// HandleSignOut resets the session and sets the cookies
|
||||||
|
func HandleSignOut(ctx *context.Context) {
|
||||||
_ = ctx.Session.Delete("uid")
|
_ = ctx.Session.Delete("uid")
|
||||||
_ = ctx.Session.Delete("uname")
|
_ = ctx.Session.Delete("uname")
|
||||||
_ = ctx.Session.Delete("socialId")
|
_ = ctx.Session.Delete("socialId")
|
||||||
|
@ -1006,7 +1008,13 @@ func handleSignOut(ctx *context.Context) {
|
||||||
|
|
||||||
// SignOut sign out from login status
|
// SignOut sign out from login status
|
||||||
func SignOut(ctx *context.Context) {
|
func SignOut(ctx *context.Context) {
|
||||||
handleSignOut(ctx)
|
if ctx.User != nil {
|
||||||
|
eventsource.GetManager().SendMessageBlocking(ctx.User.ID, &eventsource.Event{
|
||||||
|
Name: "logout",
|
||||||
|
Data: ctx.Session.ID(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
HandleSignOut(ctx)
|
||||||
ctx.Redirect(setting.AppSubURL + "/")
|
ctx.Redirect(setting.AppSubURL + "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -98,6 +98,7 @@
|
||||||
MinTimeout: {{NotificationSettings.MinTimeout}},
|
MinTimeout: {{NotificationSettings.MinTimeout}},
|
||||||
TimeoutStep: {{NotificationSettings.TimeoutStep}},
|
TimeoutStep: {{NotificationSettings.TimeoutStep}},
|
||||||
MaxTimeout: {{NotificationSettings.MaxTimeout}},
|
MaxTimeout: {{NotificationSettings.MaxTimeout}},
|
||||||
|
EventSourceUpdateTime: {{NotificationSettings.EventSourceUpdateTime}},
|
||||||
},
|
},
|
||||||
{{if .RequireTribute}}
|
{{if .RequireTribute}}
|
||||||
tributeValues: [
|
tributeValues: [
|
||||||
|
|
|
@ -19,21 +19,53 @@ export function initNotificationsTable() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initNotificationCount() {
|
export function initNotificationCount() {
|
||||||
|
const notificationCount = $('.notification_count');
|
||||||
|
|
||||||
|
if (!notificationCount.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NotificationSettings.EventSourceUpdateTime > 0 && !!window.EventSource) {
|
||||||
|
// Try to connect to the event source first
|
||||||
|
const source = new EventSource(`${AppSubUrl}/user/events`);
|
||||||
|
source.addEventListener('notification-count', async (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
|
||||||
|
const notificationCount = $('.notification_count');
|
||||||
|
if (data.Count === 0) {
|
||||||
|
notificationCount.addClass('hidden');
|
||||||
|
} else {
|
||||||
|
notificationCount.removeClass('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationCount.text(`${data.Count}`);
|
||||||
|
await updateNotificationTable();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
source.addEventListener('logout', async (e) => {
|
||||||
|
if (e.data !== 'here') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.close();
|
||||||
|
window.location.href = AppSubUrl;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (NotificationSettings.MinTimeout <= 0) {
|
if (NotificationSettings.MinTimeout <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationCount = $('.notification_count');
|
const fn = (timeout, lastCount) => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
await updateNotificationCountWithCallback(fn, timeout, lastCount);
|
||||||
|
}, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
if (notificationCount.length > 0) {
|
fn(NotificationSettings.MinTimeout, notificationCount.text());
|
||||||
const fn = (timeout, lastCount) => {
|
|
||||||
setTimeout(async () => {
|
|
||||||
await updateNotificationCountWithCallback(fn, timeout, lastCount);
|
|
||||||
}, timeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
fn(NotificationSettings.MinTimeout, notificationCount.text());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateNotificationCountWithCallback(callback, timeout, lastCount) {
|
async function updateNotificationCountWithCallback(callback, timeout, lastCount) {
|
||||||
|
@ -54,9 +86,14 @@ async function updateNotificationCountWithCallback(callback, timeout, lastCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(timeout, newCount);
|
callback(timeout, newCount);
|
||||||
|
if (needsUpdate) {
|
||||||
|
await updateNotificationTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateNotificationTable() {
|
||||||
const notificationDiv = $('#notification_div');
|
const notificationDiv = $('#notification_div');
|
||||||
if (notificationDiv.length > 0 && needsUpdate) {
|
if (notificationDiv.length > 0) {
|
||||||
const data = await $.ajax({
|
const data = await $.ajax({
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
url: `${AppSubUrl}/notifications?${notificationDiv.data('params')}`,
|
url: `${AppSubUrl}/notifications?${notificationDiv.data('params')}`,
|
||||||
|
|
Loading…
Reference in a new issue