fix: replay channel state on SPA reconnect (#61)
All checks were successful
check / check (push) Successful in 4s
All checks were successful
check / check (push) Successful in 4s
## Summary When closing and reopening the SPA, channel tabs were not restored because the client relied on localStorage to remember joined channels and re-sent JOIN commands on reconnect. This was fragile and caused spurious JOIN broadcasts to other channel members. ## Changes ### Server (`internal/handlers/api.go`, `internal/handlers/auth.go`) - **`replayChannelState()`** — new method that enqueues synthetic JOIN messages plus join-numerics (332 TOPIC, 353 NAMES, 366 ENDOFNAMES) for every channel the session belongs to, targeted only at the specified client (no broadcast to other users). - **`HandleState`** — accepts `?replay=1` query parameter to trigger channel state replay when the SPA reconnects. - **`handleLogin`** — also calls `replayChannelState` after password-based login, since `LoginUser` creates a new client for an existing session. ### SPA (`web/src/app.jsx`, `web/dist/app.js`) - On resume, calls `/state?replay=1` instead of `/state` so the server enqueues channel state into the message queue. - `processMessage` now creates channel tabs when receiving a JOIN where `msg.from` matches the current nick (handles both live joins and replayed joins on reconnect). - `onLogin` no longer re-sends JOIN commands for saved channels on resume — the server handles it via the replay mechanism, avoiding spurious JOIN broadcasts. ## How It Works 1. SPA loads, finds saved token in localStorage 2. Calls `GET /api/v1/state?replay=1` — server validates token and enqueues synthetic JOIN + TOPIC + NAMES for all session channels into the client's queue 3. `onLogin(nick, true)` sets `loggedIn = true` and requests MOTD (no re-JOIN needed) 4. Poll loop starts, picks up replayed channel messages 5. `processMessage` handles the JOIN messages, creating tabs and refreshing members/topics naturally closes #60 Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Co-authored-by: Jeffrey Paul <sneak@noreply.example.org> Reviewed-on: #61 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #61.
This commit is contained in:
@@ -444,13 +444,17 @@ func (hdlr *Handlers) enqueueNumeric(
|
||||
}
|
||||
|
||||
// HandleState returns the current session's info and
|
||||
// channels.
|
||||
// channels. When called with ?initChannelState=1, it also
|
||||
// enqueues synthetic JOIN + TOPIC + NAMES messages for
|
||||
// every channel the session belongs to so that a
|
||||
// reconnecting client can rebuild its channel tabs from
|
||||
// the message stream.
|
||||
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||
return func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
sessionID, _, nick, ok :=
|
||||
sessionID, clientID, nick, ok :=
|
||||
hdlr.requireAuth(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
@@ -472,6 +476,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if request.URL.Query().Get("initChannelState") == "1" {
|
||||
hdlr.initChannelState(
|
||||
request, clientID, sessionID, nick,
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": nick,
|
||||
@@ -480,6 +490,52 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// initChannelState enqueues synthetic JOIN messages and
|
||||
// join-numerics (TOPIC, NAMES) for every channel the
|
||||
// session belongs to. Messages are enqueued only to the
|
||||
// specified client so other clients/sessions are not
|
||||
// affected.
|
||||
func (hdlr *Handlers) initChannelState(
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
channels, err := hdlr.params.Database.
|
||||
GetSessionChannels(ctx, sessionID)
|
||||
if err != nil || len(channels) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, chanInfo := range channels {
|
||||
// Enqueue a synthetic JOIN (only to this client).
|
||||
dbID, _, insErr := hdlr.params.Database.
|
||||
InsertMessage(
|
||||
ctx, "JOIN", nick, chanInfo.Name,
|
||||
nil, nil, nil,
|
||||
)
|
||||
if insErr != nil {
|
||||
hdlr.log.Error(
|
||||
"initChannelState: insert JOIN",
|
||||
"error", insErr,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
_ = hdlr.params.Database.EnqueueToClient(
|
||||
ctx, clientID, dbID,
|
||||
)
|
||||
|
||||
// Enqueue TOPIC + NAMES numerics.
|
||||
hdlr.deliverJoinNumerics(
|
||||
request, clientID, sessionID,
|
||||
nick, chanInfo.Name, chanInfo.ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListAllChannels returns all channels on the server.
|
||||
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
||||
return func(
|
||||
|
||||
@@ -182,6 +182,12 @@ func (hdlr *Handlers) handleLogin(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
)
|
||||
|
||||
// Initialize channel state so the new client knows
|
||||
// which channels the session already belongs to.
|
||||
hdlr.initChannelState(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
|
||||
Reference in New Issue
Block a user