mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-12 15:18:14 +00:00
feat: Replace SSE with WebSocket for UI notifications (#36965)
* Closes #36942 * Fixes #19265 Replaces the SSE-based push channel (`/user/events`) with a WebSocket endpoint (`/-/ws`). ### What changes - **New `/-/ws` endpoint** (authenticated). One WebSocket per origin, shared across tabs via a single `SharedWorker`. - **Pubsub broker** (`services/pubsub`) for fan-out by topic, behind a `Broker` interface. `MemoryBroker` is the default (single process); a Redis backend is available for multi-process setups, configured via `[websocket].PUBSUB_TYPE` / `PUBSUB_CONN_STR`. The internal Gitea queue was not usable here because it has FIFO/single-consumer semantics. - **Push-only event production.** Events are emitted by write-triggered notifiers — `NotificationCountChange`, `PublishStopwatchesForUser`, and the logout publisher — wired into the existing `notify.Notifier` interface. No server-side pollers. - **Typed pub/sub on the client.** `web_src/js/modules/worker.ts` is a singleton transport; features subscribe per event type via `onUserEvent('notification-count', cb)` instead of branching on `event.data.type`. - **Wire contract** (`UserEventType` union) is shared between the worker and consumers via `web_src/js/types.ts`, kept in sync with `services/websocket/events.go`. - **Client-side periodic polling fallback** kicks in only when the WebSocket cannot be established (e.g. proxy blocks WS, browser lacks module-SharedWorker support). ### What's removed - `modules/eventsource` (SSE manager, run loop, messenger). - `/user/events` route and `tests/integration/eventsource_test.go`. - All server-side polling for stopwatches and notification counts. ### Stopwatch multi-tab fix The navbar stopwatch icon was previously rendered conditionally on `{{if $activeStopwatch}}`, so tabs loaded before the timer started had no DOM element to update. The icon and popup are now always rendered (toggled with `tw-hidden`), and the start/stop/cancel handlers POST silently so all open tabs reflect the change in real time. ### Deployment note WebSocket needs the upgrade headers to pass through a reverse proxy, e.g. for nginx: ```nginx proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; ``` Without them the WebSocket cannot be established, and after 3 consecutive failed opens the shared worker signals `push-unavailable`: the notification count and stopwatch fall back to periodic polling on the existing `[ui.notification]` timeouts. Real-time push is lost, the features keep working. The reverse-proxy docs need the same note (see the `docs-update-needed` label). --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Epid <rexmrj@gmail.com>
This commit is contained in:
@@ -16,7 +16,6 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/password"
|
||||
"gitea.dev/modules/eventsource"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
@@ -34,6 +33,7 @@ import (
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/mailer"
|
||||
user_service "gitea.dev/services/user"
|
||||
websocket_service "gitea.dev/services/websocket"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
)
|
||||
@@ -460,10 +460,7 @@ func HandleSignOut(ctx *context.Context) {
|
||||
// SignOut sign out from login status
|
||||
func SignOut(ctx *context.Context) {
|
||||
if ctx.Doer != nil {
|
||||
eventsource.GetManager().SendMessageBlocking(ctx.Doer.ID, &eventsource.Event{
|
||||
Name: "logout",
|
||||
Data: ctx.Session.ID(),
|
||||
})
|
||||
websocket_service.PublishLogout(ctx.Doer.ID, ctx.Session.ID())
|
||||
}
|
||||
|
||||
exitedImpersonated, err := auth_service.ExitImpersonatedUser(ctx.Session)
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/eventsource"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/routers/web/auth"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
if !ctx.IsSigned {
|
||||
// Return unauthorized status event
|
||||
event := &eventsource.Event{
|
||||
Name: "close",
|
||||
Data: "unauthorized",
|
||||
}
|
||||
_, _ = event.WriteTo(ctx)
|
||||
ctx.Resp.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
// Listen to connection close and un-register messageChan
|
||||
notify := ctx.Done()
|
||||
|
||||
shutdownCtx := graceful.GetManager().ShutdownContext()
|
||||
|
||||
uid := ctx.Doer.ID
|
||||
|
||||
messageChan := eventsource.GetManager().Register(uid)
|
||||
|
||||
unregister := func() {
|
||||
eventsource.GetManager().Unregister(uid, messageChan)
|
||||
// ensure the messageChan is closed
|
||||
for {
|
||||
_, ok := <-messageChan
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send the initial response bytes only after registering messageChan, so a client whose
|
||||
// connection is open can rely on receiving all subsequent events
|
||||
if _, err := ctx.Resp.Write([]byte("\n")); err != nil {
|
||||
log.Error("Unable to write to EventStream: %v", err)
|
||||
unregister()
|
||||
return
|
||||
}
|
||||
ctx.Resp.Flush()
|
||||
|
||||
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.Doer.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()
|
||||
auth.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.Doer.Name, err)
|
||||
go unregister()
|
||||
break loop
|
||||
}
|
||||
ctx.Resp.Flush()
|
||||
}
|
||||
}
|
||||
timer.Stop()
|
||||
}
|
||||
@@ -4,10 +4,9 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/modules/eventsource"
|
||||
"gitea.dev/services/context"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
// IssueStartStopwatch creates a stopwatch for the given issue.
|
||||
@@ -29,6 +28,7 @@ func IssueStartStopwatch(c *context.Context) {
|
||||
c.Flash.Warning(c.Tr("repo.issues.stopwatch_already_created"))
|
||||
} else {
|
||||
c.Flash.Success(c.Tr("repo.issues.tracker_auto_close"))
|
||||
notify_service.StopwatchChanged(c, c.Doer)
|
||||
}
|
||||
c.JSONRedirect("")
|
||||
}
|
||||
@@ -50,6 +50,8 @@ func IssueStopStopwatch(c *context.Context) {
|
||||
return
|
||||
} else if !ok {
|
||||
c.Flash.Warning(c.Tr("repo.issues.stopwatch_already_stopped"))
|
||||
} else {
|
||||
notify_service.StopwatchChanged(c, c.Doer)
|
||||
}
|
||||
c.JSONRedirect("")
|
||||
}
|
||||
@@ -70,17 +72,6 @@ func CancelStopwatch(c *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
stopwatches, err := issues_model.GetUserStopwatches(c, c.Doer.ID, db.ListOptions{})
|
||||
if err != nil {
|
||||
c.ServerError("GetUserStopwatches", err)
|
||||
return
|
||||
}
|
||||
if len(stopwatches) == 0 {
|
||||
eventsource.GetManager().SendMessage(c.Doer.ID, &eventsource.Event{
|
||||
Name: "stopwatches",
|
||||
Data: "{}",
|
||||
})
|
||||
}
|
||||
|
||||
notify_service.StopwatchChanged(c, c.Doer)
|
||||
c.JSONRedirect("")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
@@ -37,6 +36,7 @@ import (
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/context/upload"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
"gitea.dev/services/notifications"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
user_service "gitea.dev/services/user"
|
||||
)
|
||||
@@ -355,7 +355,7 @@ func ViewIssue(ctx *context.Context) {
|
||||
|
||||
if ctx.IsSigned {
|
||||
// Update issue-user.
|
||||
if err := activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil {
|
||||
if err := notifications.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil {
|
||||
ctx.ServerError("ReadBy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
@@ -49,6 +48,7 @@ import (
|
||||
"gitea.dev/services/forms"
|
||||
git_service "gitea.dev/services/git"
|
||||
"gitea.dev/services/gitdiff"
|
||||
"gitea.dev/services/notifications"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
@@ -151,7 +151,7 @@ func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) {
|
||||
|
||||
if ctx.IsSigned {
|
||||
// Update issue-user.
|
||||
if err = activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil {
|
||||
if err := notifications.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil {
|
||||
ctx.ServerError("ReadBy", err)
|
||||
return nil, false
|
||||
}
|
||||
@@ -1301,8 +1301,12 @@ func CancelAutoMergePullRequest(ctx *context.Context) {
|
||||
}
|
||||
|
||||
func stopTimerIfAvailable(ctx *context.Context, user *user_model.User, issue *issues_model.Issue) error {
|
||||
_, err := issues_model.FinishIssueStopwatch(ctx, user, issue)
|
||||
return err
|
||||
stopped, err := issues_model.FinishIssueStopwatch(ctx, user, issue)
|
||||
if err != nil || !stopped {
|
||||
return err
|
||||
}
|
||||
notify_service.StopwatchChanged(ctx, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func PullsNewRedirect(ctx *context.Context) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
"gitea.dev/services/notifications"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
)
|
||||
|
||||
@@ -175,7 +176,7 @@ func NotificationStatusPost(ctx *context.Context) {
|
||||
default:
|
||||
return // ignore user's invalid input
|
||||
}
|
||||
if _, err := activities_model.SetNotificationStatus(ctx, notificationID, ctx.Doer, newStatus); err != nil {
|
||||
if _, err := notifications.SetNotificationStatus(ctx, notificationID, ctx.Doer, newStatus); err != nil {
|
||||
ctx.ServerError("SetNotificationStatus", err)
|
||||
return
|
||||
}
|
||||
@@ -189,9 +190,8 @@ func NotificationStatusPost(ctx *context.Context) {
|
||||
|
||||
// NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read
|
||||
func NotificationPurgePost(ctx *context.Context) {
|
||||
err := activities_model.UpdateNotificationStatuses(ctx, ctx.Doer, activities_model.NotificationStatusUnread, activities_model.NotificationStatusRead)
|
||||
if err != nil {
|
||||
ctx.ServerError("UpdateNotificationStatuses", err)
|
||||
if err := notifications.MarkAllRead(ctx, ctx.Doer); err != nil {
|
||||
ctx.ServerError("MarkAllRead", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -29,7 +29,6 @@ import (
|
||||
"gitea.dev/routers/web/admin"
|
||||
"gitea.dev/routers/web/auth"
|
||||
"gitea.dev/routers/web/devtest"
|
||||
"gitea.dev/routers/web/events"
|
||||
"gitea.dev/routers/web/explore"
|
||||
"gitea.dev/routers/web/feed"
|
||||
"gitea.dev/routers/web/healthcheck"
|
||||
@@ -43,6 +42,7 @@ import (
|
||||
"gitea.dev/routers/web/user"
|
||||
user_setting "gitea.dev/routers/web/user/setting"
|
||||
"gitea.dev/routers/web/user/setting/security"
|
||||
gitea_websocket "gitea.dev/routers/web/websocket"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
@@ -599,7 +599,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
})
|
||||
}, reqSignOut)
|
||||
|
||||
m.Any("/user/events", routing.MarkLongPolling(), events.Events)
|
||||
m.Get("/-/ws", routing.MarkLongPolling(), gitea_websocket.Serve)
|
||||
|
||||
m.Group("/login/oauth", func() {
|
||||
m.Group("", func() {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package websocket
|
||||
|
||||
import (
|
||||
gocontext "context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/pubsub"
|
||||
websocket_service "gitea.dev/services/websocket"
|
||||
|
||||
gitea_ws "github.com/coder/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
pingInterval = 30 * time.Second
|
||||
pingTimeout = 10 * time.Second
|
||||
writeTimeout = 10 * time.Second
|
||||
|
||||
// First code in the IANA library/framework reserved range (3000–3999).
|
||||
// Sentinel for an unauthenticated session so the SharedWorker can tell
|
||||
// "your cookie is gone" apart from a transient network failure and stop
|
||||
// reconnecting in a tight loop.
|
||||
closeCodeUnauthenticated gitea_ws.StatusCode = 3000
|
||||
)
|
||||
|
||||
// filterLogout forwards a session-free logout only to the targeted connection
|
||||
// (its own session, or every session when SessionID is empty) and drops it for
|
||||
// the rest. Non-logout messages pass through untouched.
|
||||
func filterLogout(eventType string, eventDataBytes []byte, connSessionID string) []byte {
|
||||
if eventType != websocket_service.EventLogout {
|
||||
return eventDataBytes
|
||||
}
|
||||
var lm websocket_service.UserEventMessage[websocket_service.LogoutEventData]
|
||||
if err := json.Unmarshal(eventDataBytes, &lm); err != nil {
|
||||
return eventDataBytes
|
||||
}
|
||||
if lm.EventData.SessionID == "" || lm.EventData.SessionID == connSessionID {
|
||||
return []byte(`{"eventType":"logout"}`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Serve(ctx *context.Context) {
|
||||
// Answer plain GETs (health checks, crawlers) here; letting Accept reject them
|
||||
// would log an error per request. Same reply it would have sent.
|
||||
if ctx.Req.Header.Get("Upgrade") == "" {
|
||||
ctx.Resp.Header().Set("Connection", "Upgrade")
|
||||
ctx.Resp.Header().Set("Upgrade", "websocket")
|
||||
ctx.Resp.WriteHeader(http.StatusUpgradeRequired)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, nil)
|
||||
if err != nil {
|
||||
log.Error("websocket: accept failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow() //nolint:errcheck // best-effort close
|
||||
|
||||
if !ctx.IsSigned {
|
||||
_ = conn.Close(closeCodeUnauthenticated, "unauthenticated")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := ctx.Session.ID()
|
||||
ch, cancel := pubsub.DefaultBroker.Subscribe(pubsub.UserTopic(ctx.Doer.ID))
|
||||
defer cancel()
|
||||
|
||||
// Ping requires a concurrent reader to observe the pong frame; CloseRead
|
||||
// spawns one and cancels its context when the peer goes away.
|
||||
wsCtx := conn.CloseRead(ctx.Req.Context())
|
||||
shutdownCtx := graceful.GetManager().ShutdownContext()
|
||||
pingTicker := time.NewTicker(pingInterval)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-wsCtx.Done():
|
||||
return
|
||||
case <-shutdownCtx.Done():
|
||||
return
|
||||
case <-pingTicker.C:
|
||||
pingCtx, cancelPing := gocontext.WithTimeout(wsCtx, pingTimeout)
|
||||
err := conn.Ping(pingCtx)
|
||||
cancelPing()
|
||||
if err != nil {
|
||||
log.Trace("websocket: ping failed: %v", err)
|
||||
return
|
||||
}
|
||||
case brokerPayload, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventType, eventDataBytes := websocket_service.ExtractUserEventMessage(brokerPayload)
|
||||
eventDataBytes = filterLogout(eventType, eventDataBytes, sessionID)
|
||||
if eventDataBytes == nil {
|
||||
continue
|
||||
}
|
||||
// Bound the write so a stalled/slow peer can't block this goroutine
|
||||
// indefinitely and starve the ping ticker.
|
||||
writeCtx, cancelWrite := gocontext.WithTimeout(wsCtx, writeTimeout)
|
||||
err := conn.Write(writeCtx, gitea_ws.MessageText, eventDataBytes)
|
||||
cancelWrite()
|
||||
if err != nil {
|
||||
log.Trace("websocket: write failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/services/websocket"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFilterLogout(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
brokerMsg []byte
|
||||
connSessID string
|
||||
want []byte // expected payload forwarded to the client
|
||||
}{
|
||||
{
|
||||
name: "originating session gets a session-free logout",
|
||||
brokerMsg: websocket.MakeUserEventMessage("logout", websocket.LogoutEventData{SessionID: "sess-A"}),
|
||||
connSessID: "sess-A",
|
||||
want: []byte(`{"eventType":"logout"}`),
|
||||
},
|
||||
{
|
||||
name: "other session is dropped",
|
||||
brokerMsg: websocket.MakeUserEventMessage("logout", websocket.LogoutEventData{SessionID: "sess-A"}),
|
||||
connSessID: "sess-B",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "empty sessionID reaches every session",
|
||||
brokerMsg: websocket.MakeUserEventMessage("logout", nil),
|
||||
connSessID: "sess-A",
|
||||
want: []byte(`{"eventType":"logout"}`),
|
||||
},
|
||||
{
|
||||
name: "non-logout message passes through unchanged",
|
||||
brokerMsg: websocket.MakeUserEventMessage("other", map[string]any{"k": "v"}),
|
||||
connSessID: "sess-A",
|
||||
want: []byte(`{"eventType":"other","eventData":{"k":"v"}}`),
|
||||
},
|
||||
{
|
||||
name: "malformed JSON with logout marker passes through unchanged",
|
||||
brokerMsg: []byte("any type\nany data"),
|
||||
connSessID: "sess-A",
|
||||
want: []byte("any data"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
eventType, eventData := websocket.ExtractUserEventMessage(tc.brokerMsg)
|
||||
out := filterLogout(eventType, eventData, tc.connSessID)
|
||||
assert.Equal(t, tc.want, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user