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) { 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..f5ae8051 100644 --- a/tests/store/join.test.ts +++ b/tests/store/join.test.ts @@ -190,3 +190,130 @@ 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); + }); +});