Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/store/handlers/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ export function registerChannelHandlers(store: StoreApi<AppState>): void {
}
return server;
});

// Check if current user has operator status in this channel and update their modes
const currentUser = state.currentUser;
if (currentUser) {
Expand Down
31 changes: 27 additions & 4 deletions src/store/handlers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,13 @@ export function registerUserHandlers(store: StoreApi<AppState>): 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 {};
Expand All @@ -122,6 +126,10 @@ export function registerUserHandlers(store: StoreApi<AppState>): 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;
Expand All @@ -138,13 +146,28 @@ export function registerUserHandlers(store: StoreApi<AppState>): 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) => {
Expand Down
127 changes: 127 additions & 0 deletions tests/store/join.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading