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";
// Hashcash proof-of-work helpers using Web Crypto API.
function checkLeadingZeros(hashBytes, bits) {
let count = 0;
for (let i = 0; i < hashBytes.length; i++) {
if (hashBytes[i] === 0) {
count += 8;
continue;
}
let b = hashBytes[i];
while ((b & 0x80) === 0) {
count++;
b <<= 1;
}
break;
}
return count >= bits;
}
async function mintHashcash(bits, resource) {
const encoder = new TextEncoder();
const now = new Date();
const date =
String(now.getUTCFullYear()).slice(2) +
String(now.getUTCMonth() + 1).padStart(2, "0") +
String(now.getUTCDate()).padStart(2, "0");
const prefix = `1:${bits}:${date}:${resource}::`;
let nonce = Math.floor(Math.random() * 0x100000);
const batchSize = 1024;
for (;;) {
const stamps = [];
const hashPromises = [];
for (let i = 0; i < batchSize; i++) {
const stamp = prefix + (nonce + i).toString(16);
stamps.push(stamp);
hashPromises.push(
crypto.subtle.digest("SHA-256", encoder.encode(stamp)),
);
}
const hashes = await Promise.all(hashPromises);
for (let i = 0; i < hashes.length; i++) {
if (checkLeadingZeros(new Uint8Array(hashes[i]), bits)) {
return stamps[i];
}
}
nonce += batchSize;
}
}
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();
const hashcashBitsRef = useRef(0);
const hashcashResourceRef = useRef("neoirc");
useEffect(() => {
api("/server")
.then((s) => {
if (s.name) setServerName(s.name);
if (s.motd) setMotd(s.motd);
hashcashBitsRef.current = s.hashcash_bits || 0;
if (s.name) hashcashResourceRef.current = s.name;
})
.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 {
let hashcashStamp = "";
if (hashcashBitsRef.current > 0) {
setError("Computing proof-of-work...");
hashcashStamp = await mintHashcash(
hashcashBitsRef.current,
hashcashResourceRef.current,
);
setError("");
}
const reqBody = { nick: nick.trim() };
if (hashcashStamp) {
reqBody.pow_token = hashcashStamp;
}
const res = await api("/session", {
method: "POST",
body: JSON.stringify(reqBody),
});
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick);
} catch (err) {
setError(err.data?.error || "Connection failed");
}
};
return (
{serverName}
{motd &&
{motd} }
{error &&
{error}
}
);
}
// Message renders a single chat line in IRC format.
function Message({ msg, myNick }) {
const time = formatTime(msg.ts);
if (msg.system) {
return (
[{time}]
* {msg.text}
);
}
if (msg.isAction) {
return (
[{time}]
{" "}
* {msg.from} {" "}
{msg.text}
);
}
return (
[{time}] {" "}
<{msg.from}>
{" "}
{msg.text}
);
}
// 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) => (
onNickClick(m.nick)}
title={m.nick}
>
{prefix}
{m.nick}
);
return (
{ops.map((m) => renderNick(m, "@"))}
{voiced.map((m) => renderNick(m, "+"))}
{regular.map((m) => renderNick(m, ""))}
);
}
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 ");
}
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 ");
}
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 ");
}
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 [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 ");
}
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 ;
const currentTab = tabs[activeTab] || tabs[0];
const currentMessages = messages[currentTab.name] || [];
const currentMembers = members[currentTab.name] || [];
const currentTopic = topics[currentTab.name] || "";
return (
{/* Tab bar */}
{tabs.map((tab, i) => (
0 && i !== activeTab ? "has-unread" : ""}`}
onClick={() => setActiveTab(i)}
key={tab.name}
>
{tab.type === "dm" ? tab.name : tab.name}
{unread[tab.name] > 0 && i !== activeTab && (
({unread[tab.name]})
)}
{tab.type !== "server" && (
{
e.stopPropagation();
closeTab(i);
}}
>
×
)}
))}
{!connected && ● Reconnecting }
{nick}
{/* Topic bar — channels only */}
{currentTab.type === "channel" && (
Topic: {" "}
{currentTopic || "(no topic set)"}
)}
{/* Main content area */}
{/* Messages */}
{/* User list — channels only */}
{currentTab.type === "channel" && (
)}
{/* Persistent input line */}
[{nick}]
{currentTab.type !== "server"
? ` ${currentTab.name}`
: ""}
{" >"}
setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={
currentTab.type === "server"
? "Type /help for commands"
: ""
}
spellCheck={false}
autoComplete="off"
/>
);
}
render( , document.getElementById("root"));