3 Commits

Author SHA1 Message Date
clawbot
ab49c32148 rename replay query parameter to initChannelState
Some checks failed
check / check (push) Has been cancelled
Rename ?replay=1 to ?initChannelState=1 across server, SPA, and docs
per review feedback: the parameter initialises fresh channel state
rather than replaying past state.

- Rename replayChannelState() to initChannelState()
- Update query parameter check in HandleState
- Update SPA fetch URL and comments
- Update README documentation and curl examples
2026-03-09 17:00:29 -07:00
user
e9e0151950 docs: document ?replay=1 query parameter for GET /state 2026-03-09 17:00:29 -07:00
user
0aeae8188e fix: replay channel state on SPA reconnect
When a client reconnects to an existing session (e.g. browser tab
closed and reopened), the server now enqueues synthetic JOIN messages
plus TOPIC/NAMES numerics for every channel the session belongs to.
These are delivered only to the reconnecting client, not broadcast
to other users.

Server changes:
- Add replayChannelState() to handlers that enqueues per-channel
  JOIN + join-numerics (332/353/366) to a specific client.
- HandleState accepts ?replay=1 query parameter to trigger replay.
- HandleLogin (password auth) also replays channel state for the
  new client since it creates a fresh client for an existing session.

SPA changes:
- On resume, call /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 to other channel members.

Closes #60
2026-03-09 17:00:29 -07:00
5 changed files with 21 additions and 20 deletions

View File

@@ -1036,7 +1036,7 @@ Return the current user's session state.
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
|-----------|--------|---------|-------------| |-----------|--------|---------|-------------|
| `replay` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. | | `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
**Response:** `200 OK` **Response:** `200 OK`
```json ```json
@@ -1070,9 +1070,9 @@ curl -s http://localhost:8080/api/v1/state \
-H "Authorization: Bearer $TOKEN" | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```
**Reconnect with channel replay:** **Reconnect with channel state init:**
```bash ```bash
curl -s "http://localhost:8080/api/v1/state?replay=1" \ curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-H "Authorization: Bearer $TOKEN" | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```

View File

@@ -444,10 +444,11 @@ func (hdlr *Handlers) enqueueNumeric(
} }
// HandleState returns the current session's info and // HandleState returns the current session's info and
// channels. When called with ?replay=1, it also enqueues // channels. When called with ?initChannelState=1, it also
// synthetic JOIN + TOPIC + NAMES messages for every channel // enqueues synthetic JOIN + TOPIC + NAMES messages for every
// the session belongs to so that a reconnecting client can // channel the session belongs to so that a reconnecting
// rebuild its channel tabs from the message stream. // client can rebuild its channel tabs from the message
// stream.
func (hdlr *Handlers) HandleState() http.HandlerFunc { func (hdlr *Handlers) HandleState() http.HandlerFunc {
return func( return func(
writer http.ResponseWriter, writer http.ResponseWriter,
@@ -475,8 +476,8 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
return return
} }
if request.URL.Query().Get("replay") == "1" { if request.URL.Query().Get("initChannelState") == "1" {
hdlr.replayChannelState( hdlr.initChannelState(
request, clientID, sessionID, nick, request, clientID, sessionID, nick,
) )
} }
@@ -489,12 +490,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
} }
} }
// replayChannelState enqueues synthetic JOIN messages and // initChannelState enqueues synthetic JOIN messages and
// join-numerics (TOPIC, NAMES) for every channel the // join-numerics (TOPIC, NAMES) for every channel the
// session belongs to. Messages are enqueued only to the // session belongs to. Messages are enqueued only to the
// specified client so other clients/sessions are not // specified client so other clients/sessions are not
// affected. // affected.
func (hdlr *Handlers) replayChannelState( func (hdlr *Handlers) initChannelState(
request *http.Request, request *http.Request,
clientID, sessionID int64, clientID, sessionID int64,
nick string, nick string,
@@ -516,7 +517,7 @@ func (hdlr *Handlers) replayChannelState(
) )
if insErr != nil { if insErr != nil {
hdlr.log.Error( hdlr.log.Error(
"replay: insert JOIN", "initChannelState: insert JOIN",
"error", insErr, "error", insErr,
) )

View File

@@ -182,9 +182,9 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick, request, clientID, sessionID, payload.Nick,
) )
// Replay channel state so the new client knows which // Init channel state so the new client knows which
// channels the session already belongs to. // channels the session already belongs to.
hdlr.replayChannelState( hdlr.initChannelState(
request, clientID, sessionID, payload.Nick, request, clientID, sessionID, payload.Nick,
) )

4
web/dist/app.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -70,7 +70,7 @@ function LoginScreen({ onLogin }) {
.catch(() => {}); .catch(() => {});
const saved = localStorage.getItem("neoirc_token"); const saved = localStorage.getItem("neoirc_token");
if (saved) { if (saved) {
api("/state?replay=1") api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true)) .then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token")); .catch(() => localStorage.removeItem("neoirc_token"));
} }
@@ -335,7 +335,7 @@ function App() {
if (msg.to) addMessage(msg.to, { ...base, text, system: true }); if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith("#")) { if (msg.to && msg.to.startsWith("#")) {
// Create a tab when the current user joins a channel // Create a tab when the current user joins a channel
// (including replayed JOINs on reconnect). // (including initial JOINs on reconnect).
if (msg.from === nickRef.current) { if (msg.from === nickRef.current) {
setTabs((prev) => { setTabs((prev) => {
if ( if (
@@ -656,8 +656,8 @@ function App() {
if (isResumed) { if (isResumed) {
// Request MOTD on resumed sessions (new sessions // Request MOTD on resumed sessions (new sessions
// get it automatically from the server during // get it automatically from the server during
// creation). Channel state is replayed by the // creation). Channel state is initialised by the
// server via the message queue (?replay=1), so we // server via the message queue (?initChannelState=1), so we
// do not need to re-JOIN channels here. // do not need to re-JOIN channels here.
try { try {
await api("/messages", { await api("/messages", {