Files
chat/web/src/app.jsx
clawbot f287fdf6d1
All checks were successful
check / check (push) Successful in 4s
fix: replay channel state on SPA reconnect (#61)
## Summary

When closing and reopening the SPA, channel tabs were not restored because the client relied on localStorage to remember joined channels and re-sent JOIN commands on reconnect. This was fragile and caused spurious JOIN broadcasts to other channel members.

## Changes

### Server (`internal/handlers/api.go`, `internal/handlers/auth.go`)

- **`replayChannelState()`** — new method that enqueues synthetic JOIN messages plus join-numerics (332 TOPIC, 353 NAMES, 366 ENDOFNAMES) for every channel the session belongs to, targeted only at the specified client (no broadcast to other users).
- **`HandleState`** — accepts `?replay=1` query parameter to trigger channel state replay when the SPA reconnects.
- **`handleLogin`** — also calls `replayChannelState` after password-based login, since `LoginUser` creates a new client for an existing session.

### SPA (`web/src/app.jsx`, `web/dist/app.js`)

- On resume, calls `/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.

## How It Works

1. SPA loads, finds saved token in localStorage
2. Calls `GET /api/v1/state?replay=1` — server validates token and enqueues synthetic JOIN + TOPIC + NAMES for all session channels into the client's queue
3. `onLogin(nick, true)` sets `loggedIn = true` and requests MOTD (no re-JOIN needed)
4. Poll loop starts, picks up replayed channel messages
5. `processMessage` handles the JOIN messages, creating tabs and refreshing members/topics naturally

closes #60

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #61
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:08:13 +01:00

1265 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h, render } from "preact";
import { useState, useEffect, useRef, useCallback } from "preact/hooks";
const API = "/api/v1";
const POLL_TIMEOUT = 15;
const RECONNECT_DELAY = 3000;
const MEMBER_REFRESH_INTERVAL = 10000;
const ACTION_PREFIX = "\x01ACTION ";
const ACTION_SUFFIX = "\x01";
function api(path, opts = {}) {
const token = localStorage.getItem("neoirc_token");
const headers = {
"Content-Type": "application/json",
...(opts.headers || {}),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
const data = await r.json().catch(() => null);
if (!r.ok) throw { status: r.status, data };
return data;
});
}
function formatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
function nickColor(nick) {
let h = 0;
for (let i = 0; i < nick.length; i++)
h = nick.charCodeAt(i) + ((h << 5) - h);
const hue = Math.abs(h) % 360;
return `hsl(${hue}, 60%, 65%)`;
}
function isAction(text) {
return text.startsWith(ACTION_PREFIX) && text.endsWith(ACTION_SUFFIX);
}
function parseAction(text) {
return text.slice(ACTION_PREFIX.length, -ACTION_SUFFIX.length);
}
// LoginScreen renders the initial nick selection form.
function LoginScreen({ onLogin }) {
const [nick, setNick] = useState("");
const [error, setError] = useState("");
const [motd, setMotd] = useState("");
const [serverName, setServerName] = useState("NeoIRC");
const inputRef = useRef();
useEffect(() => {
api("/server")
.then((s) => {
if (s.name) setServerName(s.name);
if (s.motd) setMotd(s.motd);
})
.catch(() => {});
const saved = localStorage.getItem("neoirc_token");
if (saved) {
api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token"));
}
inputRef.current?.focus();
}, []);
const submit = async (e) => {
e.preventDefault();
setError("");
try {
const res = await api("/session", {
method: "POST",
body: JSON.stringify({ nick: nick.trim() }),
});
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick);
} catch (err) {
setError(err.data?.error || "Connection failed");
}
};
return (
<div class="login-screen">
<div class="login-box">
<h1>{serverName}</h1>
{motd && <pre class="motd">{motd}</pre>}
<form onSubmit={submit}>
<label>Nickname:</label>
<input
ref={inputRef}
type="text"
placeholder="Enter nickname"
value={nick}
onInput={(e) => setNick(e.target.value)}
maxLength={32}
autoFocus
/>
<button type="submit">Connect</button>
</form>
{error && <div class="error">{error}</div>}
</div>
</div>
);
}
// Message renders a single chat line in IRC format.
function Message({ msg, myNick }) {
const time = formatTime(msg.ts);
if (msg.system) {
return (
<div class="message system-message">
<span class="timestamp">[{time}]</span>
<span class="system-text"> * {msg.text}</span>
</div>
);
}
if (msg.isAction) {
return (
<div class="message action-message">
<span class="timestamp">[{time}]</span>
<span class="action-text">
{" "}
* <span style={{ color: nickColor(msg.from) }}>{msg.from}</span>{" "}
{msg.text}
</span>
</div>
);
}
return (
<div class="message">
<span class="timestamp">[{time}]</span>{" "}
<span class="nick" style={{ color: nickColor(msg.from) }}>
&lt;{msg.from}&gt;
</span>{" "}
<span class="content">{msg.text}</span>
</div>
);
}
// UserList renders the right-side nick list for channels.
function UserList({ members, onNickClick }) {
const ops = [];
const voiced = [];
const regular = [];
for (const m of members) {
const mode = m.mode || "";
if (mode === "o") {
ops.push(m);
} else if (mode === "v") {
voiced.push(m);
} else {
regular.push(m);
}
}
const sortNicks = (a, b) =>
a.nick.toLowerCase().localeCompare(b.nick.toLowerCase());
ops.sort(sortNicks);
voiced.sort(sortNicks);
regular.sort(sortNicks);
const renderNick = (m, prefix) => (
<div
class="nick-entry"
onClick={() => onNickClick(m.nick)}
title={m.nick}
>
<span class="nick-prefix">{prefix}</span>
<span class="nick-name" style={{ color: nickColor(m.nick) }}>
{m.nick}
</span>
</div>
);
return (
<div class="user-list">
<div class="user-list-header">
{members.length} user{members.length !== 1 ? "s" : ""}
</div>
<div class="user-list-entries">
{ops.map((m) => renderNick(m, "@"))}
{voiced.map((m) => renderNick(m, "+"))}
{regular.map((m) => renderNick(m, ""))}
</div>
</div>
);
}
function App() {
const [loggedIn, setLoggedIn] = useState(false);
const [nick, setNick] = useState("");
const [tabs, setTabs] = useState([{ type: "server", name: "Server" }]);
const [activeTab, setActiveTab] = useState(0);
const [messages, setMessages] = useState({ Server: [] });
const [members, setMembers] = useState({});
const [topics, setTopics] = useState({});
const [unread, setUnread] = useState({});
const [input, setInput] = useState("");
const [connected, setConnected] = useState(true);
const [inputHistory, setInputHistory] = useState([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const lastIdRef = useRef(0);
const seenIdsRef = useRef(new Set());
const pollAbortRef = useRef(null);
const tabsRef = useRef(tabs);
const activeTabRef = useRef(activeTab);
const nickRef = useRef(nick);
const messagesEndRef = useRef();
const inputRef = useRef();
useEffect(() => {
tabsRef.current = tabs;
}, [tabs]);
useEffect(() => {
activeTabRef.current = activeTab;
}, [activeTab]);
useEffect(() => {
nickRef.current = nick;
}, [nick]);
// Persist joined channels.
useEffect(() => {
const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
localStorage.setItem("neoirc_channels", JSON.stringify(channels));
}, [tabs]);
// Clear unread on tab switch.
useEffect(() => {
const tab = tabs[activeTab];
if (tab) setUnread((prev) => ({ ...prev, [tab.name]: 0 }));
}, [activeTab, tabs]);
const addMessage = useCallback((tabName, msg) => {
if (msg.id && seenIdsRef.current.has(msg.id)) return;
if (msg.id) seenIdsRef.current.add(msg.id);
setMessages((prev) => ({
...prev,
[tabName]: [...(prev[tabName] || []), msg],
}));
const currentTab = tabsRef.current[activeTabRef.current];
if (!currentTab || currentTab.name !== tabName) {
setUnread((prev) => ({
...prev,
[tabName]: (prev[tabName] || 0) + 1,
}));
}
}, []);
const addSystemMessage = useCallback((tabName, text) => {
setMessages((prev) => ({
...prev,
[tabName]: [
...(prev[tabName] || []),
{
id: "sys-" + Date.now() + "-" + Math.random(),
ts: new Date().toISOString(),
text,
system: true,
},
],
}));
}, []);
const refreshMembers = useCallback((channel) => {
const chName = channel.replace("#", "");
api(`/channels/${chName}/members`)
.then((m) => {
setMembers((prev) => ({ ...prev, [channel]: m }));
})
.catch(() => {});
}, []);
const processMessage = useCallback(
(msg) => {
const body = Array.isArray(msg.body) ? msg.body.join("\n") : "";
const base = {
id: msg.id,
ts: msg.ts,
from: msg.from,
to: msg.to,
command: msg.command,
};
switch (msg.command) {
case "PRIVMSG":
case "NOTICE": {
let text = body;
let actionMsg = false;
if (isAction(text)) {
text = parseAction(text);
actionMsg = true;
}
const parsed = { ...base, text, system: false, isAction: actionMsg };
const target = msg.to;
if (target && target.startsWith("#")) {
addMessage(target, parsed);
} else {
const dmPeer =
msg.from === nickRef.current ? msg.to : msg.from;
setTabs((prev) => {
if (!prev.find((t) => t.type === "dm" && t.name === dmPeer)) {
return [...prev, { type: "dm", name: dmPeer }];
}
return prev;
});
addMessage(dmPeer, parsed);
}
break;
}
case "JOIN": {
const text = `${msg.from} has joined ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith("#")) {
// Create a tab when the current user joins a channel
// (including JOINs from initChannelState 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;
}
case "PART": {
const reason = body ? " (" + body + ")" : "";
const text = `${msg.from} has parted ${msg.to}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
break;
}
case "QUIT": {
const reason = body ? " (" + body + ")" : "";
const text = `${msg.from} has quit${reason}`;
tabsRef.current.forEach((tab) => {
if (tab.type === "channel") {
addMessage(tab.name, { ...base, text, system: true });
}
});
break;
}
case "NICK": {
const newNick = Array.isArray(msg.body) ? msg.body[0] : body;
const text = `${msg.from} is now known as ${newNick}`;
tabsRef.current.forEach((tab) => {
if (tab.type === "channel") {
addMessage(tab.name, { ...base, text, system: true });
}
});
if (msg.from === nickRef.current && newNick) setNick(newNick);
tabsRef.current.forEach((tab) => {
if (tab.type === "channel") refreshMembers(tab.name);
});
break;
}
case "TOPIC": {
const text = `${msg.from} has changed the topic to: ${body}`;
if (msg.to) {
addMessage(msg.to, { ...base, text, system: true });
setTopics((prev) => ({ ...prev, [msg.to]: body }));
}
break;
}
case "353": {
// RPL_NAMREPLY — parse mode prefixes from names list.
if (
Array.isArray(msg.params) &&
msg.params.length >= 2 &&
msg.body
) {
const channel = msg.params[1];
const namesStr = Array.isArray(msg.body)
? msg.body[0]
: String(msg.body);
const names = namesStr.split(/\s+/).filter(Boolean);
const parsed = names.map((n) => {
if (n.startsWith("@")) {
return { nick: n.slice(1), mode: "o" };
}
if (n.startsWith("+")) {
return { nick: n.slice(1), mode: "v" };
}
return { nick: n, mode: "" };
});
setMembers((prev) => ({ ...prev, [channel]: parsed }));
}
break;
}
case "332": {
// RPL_TOPIC — set topic from server.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const channel = msg.params[0];
const topicText = Array.isArray(msg.body) ? msg.body[0] : body;
if (topicText) {
setTopics((prev) => ({ ...prev, [channel]: topicText }));
}
}
break;
}
case "322": {
// RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0];
const count = msg.params[1];
const chTopic = body || "";
addMessage("Server", {
...base,
text: `${chName} (${count} users): ${chTopic.trim()}`,
system: true,
});
}
break;
}
case "323":
addMessage("Server", {
...base,
text: body || "End of channel list",
system: true,
});
break;
case "352": {
// RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0];
const whoNick = msg.params[4];
const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
addMessage("Server", {
...base,
text: `${whoCh} ${whoNick} ${whoFlags}`,
system: true,
});
}
break;
}
case "315":
addMessage("Server", {
...base,
text: body || "End of /WHO list",
system: true,
});
break;
case "311": {
// RPL_WHOISUSER — nick, user, host, *, realname.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
addMessage("Server", {
...base,
text: `${wiNick} (${body})`,
system: true,
});
}
break;
}
case "312": {
// RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0];
const wiServer = msg.params[1];
addMessage("Server", {
...base,
text: `${wiNick} on ${wiServer}`,
system: true,
});
}
break;
}
case "319": {
// RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
addMessage("Server", {
...base,
text: `${wiNick} is on: ${body}`,
system: true,
});
}
break;
}
case "318":
addMessage("Server", {
...base,
text: body || "End of /WHOIS list",
system: true,
});
break;
case "375":
case "372":
case "376":
addMessage("Server", { ...base, text: body, system: true });
break;
default:
if (body) {
addMessage("Server", { ...base, text: body, system: true });
}
}
},
[addMessage, refreshMembers],
);
// Long-poll loop.
useEffect(() => {
if (!loggedIn) return;
let alive = true;
const poll = async () => {
while (alive) {
try {
const controller = new AbortController();
pollAbortRef.current = controller;
const result = await api(
`/messages?after=${lastIdRef.current}&timeout=${POLL_TIMEOUT}`,
{ signal: controller.signal },
);
if (!alive) break;
setConnected(true);
if (result.messages) {
for (const m of result.messages) processMessage(m);
}
if (result.last_id > lastIdRef.current) {
lastIdRef.current = result.last_id;
}
} catch (err) {
if (!alive) break;
if (err.name === "AbortError") continue;
setConnected(false);
await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
}
}
};
poll();
return () => {
alive = false;
pollAbortRef.current?.abort();
};
}, [loggedIn, processMessage]);
// Refresh members for active channel.
useEffect(() => {
if (!loggedIn) return;
const tab = tabs[activeTab];
if (!tab || tab.type !== "channel") return;
refreshMembers(tab.name);
const iv = setInterval(
() => refreshMembers(tab.name),
MEMBER_REFRESH_INTERVAL,
);
return () => clearInterval(iv);
}, [loggedIn, activeTab, tabs, refreshMembers]);
// Auto-scroll.
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, activeTab]);
// Focus input on tab change.
useEffect(() => {
inputRef.current?.focus();
}, [activeTab]);
// Global keyboard handler — capture '/' to prevent
// Firefox quick search and redirect focus to the input.
useEffect(() => {
const handleGlobalKeyDown = (e) => {
if (
e.key === "/" &&
document.activeElement !== inputRef.current &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey
) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener("keydown", handleGlobalKeyDown);
// Also focus input on initial mount.
inputRef.current?.focus();
return () =>
document.removeEventListener("keydown", handleGlobalKeyDown);
}, []);
// Fetch topic for active channel.
useEffect(() => {
if (!loggedIn) return;
const tab = tabs[activeTab];
if (!tab || tab.type !== "channel") return;
api("/channels")
.then((channels) => {
const ch = channels.find((c) => c.name === tab.name);
if (ch && ch.topic)
setTopics((prev) => ({ ...prev, [tab.name]: ch.topic }));
})
.catch(() => {});
}, [loggedIn, activeTab, tabs]);
const onLogin = useCallback(
async (userNick, isResumed) => {
setNick(userNick);
setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`);
if (isResumed) {
// Request MOTD on resumed sessions (new sessions
// get it automatically from the server during
// creation). Channel state is initialized by the
// server via the message queue
// (?initChannelState=1), so we do not need to
// re-JOIN channels here.
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "MOTD" }),
});
} catch (e) {
// MOTD is non-critical.
}
return;
}
// Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
for (const ch of saved) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "JOIN", to: ch }),
});
setTabs((prev) => {
if (prev.find((t) => t.type === "channel" && t.name === ch))
return prev;
return [...prev, { type: "channel", name: ch }];
});
} catch (e) {
// Channel may not exist anymore.
}
}
},
[addSystemMessage],
);
const joinChannel = async (name) => {
if (!name) return;
name = name.trim();
if (!name.startsWith("#")) name = "#" + name;
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "JOIN", to: name }),
});
setTabs((prev) => {
if (prev.find((t) => t.type === "channel" && t.name === name))
return prev;
return [...prev, { type: "channel", name }];
});
const newIdx = tabsRef.current.length;
setActiveTab(newIdx);
try {
const hist = await api(
`/history?target=${encodeURIComponent(name)}&limit=50`,
);
if (Array.isArray(hist)) {
for (const m of hist) processMessage(m);
}
} catch (e) {
// History may be empty.
}
} catch (err) {
addSystemMessage(
"Server",
`Failed to join ${name}: ${err.data?.error || "error"}`,
);
}
};
const partChannel = async (name, reason) => {
try {
const body = reason ? { command: "PART", to: name, body: [reason] } : { command: "PART", to: name };
await api("/messages", {
method: "POST",
body: JSON.stringify(body),
});
} catch (e) {
// Ignore.
}
setTabs((prev) =>
prev.filter((t) => !(t.type === "channel" && t.name === name)),
);
setActiveTab(0);
};
const closeTab = (idx) => {
const tab = tabs[idx];
if (tab.type === "channel") {
partChannel(tab.name);
} else if (tab.type === "dm") {
setTabs((prev) => prev.filter((_, i) => i !== idx));
if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1));
}
};
const openDM = (targetNick) => {
if (targetNick === nickRef.current) return;
setTabs((prev) => {
if (prev.find((t) => t.type === "dm" && t.name === targetNick))
return prev;
return [...prev, { type: "dm", name: targetNick }];
});
const idx = tabs.findIndex(
(t) => t.type === "dm" && t.name === targetNick,
);
setActiveTab(idx >= 0 ? idx : tabs.length);
};
const handleCommand = async (text) => {
const parts = text.split(" ");
const cmd = parts[0].toLowerCase();
const tab = tabs[activeTab];
switch (cmd) {
case "/join": {
if (parts[1]) joinChannel(parts[1]);
else addSystemMessage("Server", "Usage: /join #channel");
break;
}
case "/part": {
if (tab.type === "channel") {
const reason = parts.slice(1).join(" ") || undefined;
partChannel(tab.name, reason);
} else {
addSystemMessage("Server", "You are not in a channel");
}
break;
}
case "/msg": {
if (parts[1] && parts.slice(2).join(" ")) {
const target = parts[1];
const body = parts.slice(2).join(" ");
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({
command: "PRIVMSG",
to: target,
body: [body],
}),
});
openDM(target);
} catch (err) {
addSystemMessage(
"Server",
`Message failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /msg <nick> <message>");
}
break;
}
case "/me": {
if (tab.type === "server") {
addSystemMessage("Server", "Cannot use /me in server window");
break;
}
const actionText = parts.slice(1).join(" ");
if (actionText) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({
command: "PRIVMSG",
to: tab.name,
body: [ACTION_PREFIX + actionText + ACTION_SUFFIX],
}),
});
} catch (err) {
addSystemMessage(
tab.name,
`Action failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /me <action>");
}
break;
}
case "/nick": {
if (parts[1]) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "NICK", body: [parts[1]] }),
});
} catch (err) {
addSystemMessage(
"Server",
`Nick change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /nick <newnick>");
}
break;
}
case "/topic": {
if (tab.type !== "channel") {
addSystemMessage("Server", "You are not in a channel");
break;
}
const topicText = parts.slice(1).join(" ");
if (topicText) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({
command: "TOPIC",
to: tab.name,
body: [topicText],
}),
});
} catch (err) {
addSystemMessage(
"Server",
`Topic change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(
"Server",
`Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`,
);
}
break;
}
case "/mode": {
if (tab.type !== "channel") {
addSystemMessage("Server", "You are not in a channel");
break;
}
const modeArgs = parts.slice(1);
if (modeArgs.length > 0) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({
command: "MODE",
to: tab.name,
params: modeArgs,
}),
});
} catch (err) {
addSystemMessage(
"Server",
`Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(
"Server",
"Usage: /mode <+/-mode> [params]",
);
}
break;
}
case "/quit": {
const reason = parts.slice(1).join(" ") || undefined;
try {
const body = reason ? { command: "QUIT", body: [reason] } : { command: "QUIT" };
await api("/messages", {
method: "POST",
body: JSON.stringify(body),
});
} catch (e) {
// Ignore.
}
localStorage.removeItem("neoirc_token");
localStorage.removeItem("neoirc_channels");
window.location.reload();
break;
}
case "/motd": {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "MOTD" }),
});
} catch (err) {
addSystemMessage(
"Server",
`Failed to request MOTD: ${err.data?.error || "error"}`,
);
}
break;
}
case "/query": {
if (parts[1]) {
const target = parts[1];
openDM(target);
const msgText = parts.slice(2).join(" ");
if (msgText) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({
command: "PRIVMSG",
to: target,
body: [msgText],
}),
});
} catch (err) {
addSystemMessage(
"Server",
`Message failed: ${err.data?.error || "error"}`,
);
}
}
} else {
addSystemMessage("Server", "Usage: /query <nick> [message]");
}
break;
}
case "/list": {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "LIST" }),
});
} catch (err) {
addSystemMessage(
"Server",
`Failed to list channels: ${err.data?.error || "error"}`,
);
}
break;
}
case "/who": {
const whoTarget = parts[1] || (tab.type === "channel" ? tab.name : "");
if (whoTarget) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "WHO", to: whoTarget }),
});
} catch (err) {
addSystemMessage(
"Server",
`WHO failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /who #channel");
}
break;
}
case "/whois": {
if (parts[1]) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "WHOIS", to: parts[1] }),
});
} catch (err) {
addSystemMessage(
"Server",
`WHOIS failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /whois <nick>");
}
break;
}
case "/clear": {
const clearTarget = tab.name;
setMessages((prev) => ({ ...prev, [clearTarget]: [] }));
break;
}
case "/help": {
const helpLines = [
"Available commands:",
" /join #channel — Join a channel",
" /part [reason] — Part the current channel",
" /msg nick message — Send a private message",
" /query nick [message] — Open a DM tab (optionally send a message)",
" /me action — Send an action",
" /nick newnick — Change your nickname",
" /topic [text] — View or set channel topic",
" /mode +/-flags — Set channel modes",
" /motd — Display the message of the day",
" /list — List all channels",
" /who [#channel] — List users in a channel",
" /whois nick — Show info about a user",
" /clear — Clear messages in the current tab",
" /quit [reason] — Disconnect from server",
" /help — Show this help",
];
for (const line of helpLines) {
addSystemMessage("Server", line);
}
break;
}
default:
addSystemMessage("Server", `Unknown command: ${cmd}`);
}
};
const sendMessage = async () => {
const text = input.trim();
if (!text) return;
// Save to input history.
setInputHistory((prev) => {
const next = [...prev, text];
if (next.length > 100) next.shift();
return next;
});
setHistoryIndex(-1);
setInput("");
const tab = tabs[activeTab];
if (!tab) return;
if (text.startsWith("/")) {
await handleCommand(text);
return;
}
if (tab.type === "server") {
addSystemMessage(
"Server",
"Cannot send messages to the server window. Use /join #channel first.",
);
return;
}
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({
command: "PRIVMSG",
to: tab.name,
body: [text],
}),
});
} catch (err) {
addSystemMessage(
tab.name,
`Send failed: ${err.data?.error || "error"}`,
);
}
};
const handleInputKeyDown = (e) => {
if (e.key === "Enter") {
sendMessage();
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (inputHistory.length > 0) {
const newIdx =
historyIndex === -1
? inputHistory.length - 1
: Math.max(0, historyIndex - 1);
setHistoryIndex(newIdx);
setInput(inputHistory[newIdx]);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndex >= 0) {
const newIdx = historyIndex + 1;
if (newIdx >= inputHistory.length) {
setHistoryIndex(-1);
setInput("");
} else {
setHistoryIndex(newIdx);
setInput(inputHistory[newIdx]);
}
}
}
};
if (!loggedIn) return <LoginScreen onLogin={onLogin} />;
const currentTab = tabs[activeTab] || tabs[0];
const currentMessages = messages[currentTab.name] || [];
const currentMembers = members[currentTab.name] || [];
const currentTopic = topics[currentTab.name] || "";
return (
<div class="irc-app">
{/* Tab bar */}
<div class="tab-bar">
<div class="tabs">
{tabs.map((tab, i) => (
<div
class={`tab ${i === activeTab ? "active" : ""} ${unread[tab.name] > 0 && i !== activeTab ? "has-unread" : ""}`}
onClick={() => setActiveTab(i)}
key={tab.name}
>
<span class="tab-label">
{tab.type === "dm" ? tab.name : tab.name}
</span>
{unread[tab.name] > 0 && i !== activeTab && (
<span class="unread-count">({unread[tab.name]})</span>
)}
{tab.type !== "server" && (
<span
class="tab-close"
onClick={(e) => {
e.stopPropagation();
closeTab(i);
}}
>
×
</span>
)}
</div>
))}
</div>
<div class="status-area">
{!connected && <span class="status-warn"> Reconnecting</span>}
<span class="status-nick">{nick}</span>
</div>
</div>
{/* Topic bar — channels only */}
{currentTab.type === "channel" && (
<div class="topic-bar">
<span class="topic-label">Topic:</span>{" "}
<span class="topic-text">
{currentTopic || "(no topic set)"}
</span>
</div>
)}
{/* Main content area */}
<div class="main-area">
{/* Messages */}
<div class="messages-panel">
<div class="messages-scroll">
{currentMessages.map((m) => (
<Message msg={m} myNick={nick} key={m.id} />
))}
<div ref={messagesEndRef} />
</div>
</div>
{/* User list — channels only */}
{currentTab.type === "channel" && (
<UserList
members={currentMembers}
onNickClick={openDM}
/>
)}
</div>
{/* Persistent input line */}
<div class="input-line">
<span class="input-prompt">
[{nick}]
{currentTab.type !== "server"
? ` ${currentTab.name}`
: ""}
{" >"}
</span>
<input
ref={inputRef}
type="text"
value={input}
onInput={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={
currentTab.type === "server"
? "Type /help for commands"
: ""
}
spellCheck={false}
autoComplete="off"
/>
</div>
</div>
);
}
render(<App />, document.getElementById("root"));