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
This commit is contained in:
@@ -444,13 +444,16 @@ func (hdlr *Handlers) enqueueNumeric(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleState returns the current session's info and
|
// HandleState returns the current session's info and
|
||||||
// channels.
|
// channels. When called with ?replay=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 {
|
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
sessionID, _, nick, ok :=
|
sessionID, clientID, nick, ok :=
|
||||||
hdlr.requireAuth(writer, request)
|
hdlr.requireAuth(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -472,6 +475,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if request.URL.Query().Get("replay") == "1" {
|
||||||
|
hdlr.replayChannelState(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": nick,
|
"nick": nick,
|
||||||
@@ -480,6 +489,52 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// replayChannelState 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) replayChannelState(
|
||||||
|
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(
|
||||||
|
"replay: 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.
|
// HandleListAllChannels returns all channels on the server.
|
||||||
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
|
|||||||
@@ -182,6 +182,12 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Replay channel state so the new client knows which
|
||||||
|
// channels the session already belongs to.
|
||||||
|
hdlr.replayChannelState(
|
||||||
|
request, clientID, sessionID, payload.Nick,
|
||||||
|
)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
|||||||
4
web/dist/app.js
vendored
4
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
@@ -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")
|
api("/state?replay=1")
|
||||||
.then((u) => onLogin(u.nick, true))
|
.then((u) => onLogin(u.nick, true))
|
||||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||||
}
|
}
|
||||||
@@ -333,7 +333,24 @@ function App() {
|
|||||||
case "JOIN": {
|
case "JOIN": {
|
||||||
const text = `${msg.from} has joined ${msg.to}`;
|
const text = `${msg.from} has joined ${msg.to}`;
|
||||||
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("#")) refreshMembers(msg.to);
|
if (msg.to && msg.to.startsWith("#")) {
|
||||||
|
// Create a tab when the current user joins a channel
|
||||||
|
// (including replayed JOINs on reconnect).
|
||||||
|
if (msg.from === nickRef.current) {
|
||||||
|
setTabs((prev) => {
|
||||||
|
if (
|
||||||
|
prev.find(
|
||||||
|
(t) => t.type === "channel" && t.name === msg.to,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return prev;
|
||||||
|
|
||||||
|
return [...prev, { type: "channel", name: msg.to }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshMembers(msg.to);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -636,9 +653,12 @@ function App() {
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||||
|
|
||||||
// Request MOTD on resumed sessions (new sessions get
|
|
||||||
// it automatically from the server during creation).
|
|
||||||
if (isResumed) {
|
if (isResumed) {
|
||||||
|
// Request MOTD on resumed sessions (new sessions
|
||||||
|
// get it automatically from the server during
|
||||||
|
// creation). Channel state is replayed by the
|
||||||
|
// server via the message queue (?replay=1), so we
|
||||||
|
// do not need to re-JOIN channels here.
|
||||||
try {
|
try {
|
||||||
await api("/messages", {
|
await api("/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -647,8 +667,11 @@ function App() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// MOTD is non-critical.
|
// MOTD is non-critical.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fresh session — join any previously saved channels.
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
localStorage.getItem("neoirc_channels") || "[]",
|
localStorage.getItem("neoirc_channels") || "[]",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user