From 80eb737976954423a47ef0c2978757512d32c0f8 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Fri, 5 Jun 2026 21:22:46 +0100 Subject: [PATCH 1/3] fix(join): fire CHATHISTORY/WHO on server-initiated own JOIN PR #248 stopped the CHATHISTORY-FAIL-into-permanently-leaked needsWhoRequest leak when joinChannel() was the entry point. But joinChannel is only one of the two entry points. The other one -- server-initiated auto-join (UnrealIRCd's persistence module, sajoin, operblock perform-on-connect, InspIRCd auto-join) -- sends a JOIN that the client never asked for. The handler at users.ts:115-147 created the channel entry with needsWhoRequest=true and stopped there, never firing CHATHISTORY or WHO. Nothing downstream cleared the flag, so the nicklist stayed empty until something else triggered it (e.g. a manual /who). Why Ergo never showed this even though it auto-joins too: Ergo's auto-join sends a draft/chathistory BATCH alongside the JOIN, so the batch close fires CHATHISTORY_LOADING(false) -> messages.ts:2028 sees needsWhoRequest=true -> WHO goes out. UnrealIRCd and InspIRCd don't send a batch on auto-join, so nothing kicks the chain. Mirror joinChannel's tail in the own-join + new-channel branch: - draft/chathistory cap present: send `CHATHISTORY LATEST #chan * 50`, trigger CHATHISTORY_LOADING(true), leave needsWhoRequest=true so the existing batch-close -> WHO path runs. - cap absent (InspIRCd networks that don't ship the module): send `WHO #chan %cuhnfaro` immediately and clear needsWhoRequest. ircClient I/O runs OUTSIDE the setState callback -- triggerEvent inside setState causes nested-setState clobber (same reason batches.ts:215-218 captures chathistoryChannelName before firing). Tests cover all three branches: cap present, cap absent, and the joinChannel-already-ran path (must not double-send). --- src/store/handlers/users.ts | 31 +++++++-- tests/store/join.test.ts | 129 ++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/store/handlers/users.ts b/src/store/handlers/users.ts index 608daac5..c56e5783 100644 --- a/src/store/handlers/users.ts +++ b/src/store/handlers/users.ts @@ -110,9 +110,13 @@ export function registerUserHandlers(store: StoreApi): void { const isOurJoin = username === ourNick; if (isOurJoin) { - // Ensure the channel exists in the store; joinChannel action usually handles this, - // but this catches cases where the server JOIN confirmation arrives without a prior joinChannel call. - // Don't add ourselves to the member list — NAMES populates it with proper modes. + // Server-initiated JOIN (Unreal persistence, sajoin, perform list, + // perform-on-connect): joinChannel() never ran, so the CHATHISTORY + // + WHO chain that normally lives inside it was never kicked off. + // Mirror joinChannel's tail here for the channel-doesn't-exist case + // so the nicklist actually populates. Don't add ourselves to the + // member list — NAMES populates it with proper modes. + let newChannelHadCap: boolean | null = null; store.setState((state) => { const server = state.servers.find((s) => s.id === serverId); if (!server) return {}; @@ -122,6 +126,10 @@ export function registerUserHandlers(store: StoreApi): void { ); if (exists) return {}; + const hasChathistory = + !!server.capabilities?.includes("draft/chathistory"); + newChannelHadCap = hasChathistory; + return { servers: state.servers.map((s) => { if (s.id !== serverId) return s; @@ -138,13 +146,28 @@ export function registerUserHandlers(store: StoreApi): void { isMentioned: false, messages: [], users: [], - needsWhoRequest: true, + isLoadingHistory: hasChathistory, + hasMoreHistory: hasChathistory, + needsWhoRequest: !!hasChathistory, + chathistoryRequested: hasChathistory, }, ], }; }), }; }); + // ircClient I/O OUTSIDE setState: triggerEvent inside setState + // causes a nested-setState race (see batches.ts:215-218). + if (newChannelHadCap === true) { + ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${channelName} * 50`); + ircClient.triggerEvent("CHATHISTORY_LOADING", { + serverId, + channelName, + isLoading: true, + }); + } else if (newChannelHadCap === false) { + ircClient.sendRaw(serverId, `WHO ${channelName} %cuhnfaro`); + } // Fall through to shared message creation below — same JOIN event, same path. } else { store.setState((state) => { diff --git a/tests/store/join.test.ts b/tests/store/join.test.ts index 71653af9..44db3edb 100644 --- a/tests/store/join.test.ts +++ b/tests/store/join.test.ts @@ -190,3 +190,132 @@ describe("readyProcessedServers — reconnect guard", () => { expect(readyProcessedServers.has("srv-1")).toBe(true); }); }); + +describe("server-initiated own-JOIN — CHATHISTORY/WHO fanout", () => { + function setupServerWithCaps(caps: string[]) { + useStore.setState({ + servers: [ + { + id: "srv-1", + name: "TestServer", + host: "irc.example.com", + port: 6667, + channels: [], + privateChats: [], + isConnected: true, + users: [], + capabilities: caps, + }, + ], + messages: {}, + activeBatches: {}, + globalSettings: { + showEvents: true, + showJoinsParts: true, + }, + } as unknown as AppState); + ircClient.nicks.set("srv-1", "me"); + } + + beforeEach(() => { + ircClient.nicks.delete("srv-1"); + }); + + it("draft/chathistory cap: requests CHATHISTORY + triggers LOADING(true), leaves needsWhoRequest=true for the batch close to clear", () => { + setupServerWithCaps(["draft/chathistory", "message-tags"]); + const sent: string[] = []; + const origSendRaw = ircClient.sendRaw.bind(ircClient); + ircClient.sendRaw = (id: string, line: string) => sent.push(line); + + const loadings: { channel: string; isLoading: boolean }[] = []; + ircClient.on( + "CHATHISTORY_LOADING", + ({ channelName, isLoading }) => + loadings.push({ channel: channelName, isLoading }), + ); + + ircClient.triggerEvent("JOIN", { + serverId: "srv-1", + username: "me", + channelName: "#unreal-support", + }); + + ircClient.sendRaw = origSendRaw; + + const ch = useStore + .getState() + .servers.find((s) => s.id === "srv-1") + ?.channels.find((c) => c.name === "#unreal-support"); + expect(ch).toBeDefined(); + expect(ch?.needsWhoRequest).toBe(true); + expect(ch?.chathistoryRequested).toBe(true); + expect(ch?.isLoadingHistory).toBe(true); + expect(sent).toContain("CHATHISTORY LATEST #unreal-support * 50"); + expect(sent.some((l) => l.startsWith("WHO "))).toBe(false); + expect(loadings).toContainEqual({ + channel: "#unreal-support", + isLoading: true, + }); + }); + + it("no chathistory cap: sends WHO immediately, marks needsWhoRequest=false", () => { + setupServerWithCaps(["message-tags"]); + const sent: string[] = []; + const origSendRaw = ircClient.sendRaw.bind(ircClient); + ircClient.sendRaw = (id: string, line: string) => sent.push(line); + + ircClient.triggerEvent("JOIN", { + serverId: "srv-1", + username: "me", + channelName: "#inspircd-help", + }); + + ircClient.sendRaw = origSendRaw; + + const ch = useStore + .getState() + .servers.find((s) => s.id === "srv-1") + ?.channels.find((c) => c.name === "#inspircd-help"); + expect(ch).toBeDefined(); + expect(ch?.needsWhoRequest).toBe(false); + expect(ch?.chathistoryRequested).toBe(false); + expect(ch?.isLoadingHistory).toBe(false); + expect(sent).toContain("WHO #inspircd-help %cuhnfaro"); + expect(sent.some((l) => l.startsWith("CHATHISTORY "))).toBe(false); + }); + + it("channel already exists (joinChannel ran first): does NOT double-send CHATHISTORY/WHO", () => { + setupServerWithCaps(["draft/chathistory"]); + useStore.setState((state) => ({ + servers: state.servers.map((s) => + s.id === "srv-1" + ? { + ...s, + channels: [ + makeChannel({ + name: "#preexisting", + chathistoryRequested: true, + isLoadingHistory: true, + needsWhoRequest: true, + }), + ], + } + : s, + ), + })); + const sent: string[] = []; + const origSendRaw = ircClient.sendRaw.bind(ircClient); + ircClient.sendRaw = (id: string, line: string) => sent.push(line); + + ircClient.triggerEvent("JOIN", { + serverId: "srv-1", + username: "me", + channelName: "#preexisting", + }); + + ircClient.sendRaw = origSendRaw; + + expect(sent.some((l) => l.startsWith("CHATHISTORY "))).toBe(false); + expect(sent.some((l) => l.startsWith("WHO "))).toBe(false); + }); +}); From 3bfb203cafa0bdc4c8bd3aa990d7382fc3d2a779 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Fri, 5 Jun 2026 21:32:14 +0100 Subject: [PATCH 2/3] fix(join): biome format on test (CI lint repair) --- tests/store/join.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/store/join.test.ts b/tests/store/join.test.ts index 44db3edb..f5ae8051 100644 --- a/tests/store/join.test.ts +++ b/tests/store/join.test.ts @@ -228,10 +228,8 @@ describe("server-initiated own-JOIN — CHATHISTORY/WHO fanout", () => { ircClient.sendRaw = (id: string, line: string) => sent.push(line); const loadings: { channel: string; isLoading: boolean }[] = []; - ircClient.on( - "CHATHISTORY_LOADING", - ({ channelName, isLoading }) => - loadings.push({ channel: channelName, isLoading }), + ircClient.on("CHATHISTORY_LOADING", ({ channelName, isLoading }) => + loadings.push({ channel: channelName, isLoading }), ); ircClient.triggerEvent("JOIN", { From 6ab84ab8cd23ca247d0c6214a2897083013cb5d7 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Fri, 5 Jun 2026 21:37:08 +0100 Subject: [PATCH 3/3] lint: drop trailing whitespace in channels.ts (CI biome --check) --- src/store/handlers/channels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/handlers/channels.ts b/src/store/handlers/channels.ts index f3211376..14b8b838 100644 --- a/src/store/handlers/channels.ts +++ b/src/store/handlers/channels.ts @@ -405,7 +405,7 @@ export function registerChannelHandlers(store: StoreApi): void { } return server; }); - + // Check if current user has operator status in this channel and update their modes const currentUser = state.currentUser; if (currentUser) {