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}
}
setNick(e.target.value)} maxLength={32} autoFocus />
{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 (
{members.length} user{members.length !== 1 ? "s" : ""}
{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 */}
{currentMessages.map((m) => ( ))}
{/* 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"));