check / check (push) Waiting to run
The web client saved its channel tabs to localStorage whenever the tabs changed, including on page load, when only the Server tab exists. That wrote an empty list over the saved one before login read it, so a new session never rejoined anything. The save now waits until the user is logged in, and login reads the saved list before it marks the user logged in. A MODE message from the server fell through to the Server tab as its bare body. It now shows in the channel's tab as "alice sets mode +v bob on #chan". Model: opus-5-5
1383 lines
39 KiB
React
1383 lines
39 KiB
React
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;
|
||
}
|
||
}
|
||
|
||
// Requests are authenticated by the auth cookie, which the browser
|
||
// sends on its own.
|
||
function api(path, opts = {}) {
|
||
const headers = {
|
||
"Content-Type": "application/json",
|
||
...(opts.headers || {}),
|
||
};
|
||
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(() => {});
|
||
// Resume the session if the browser still holds a valid auth
|
||
// cookie; a 401 leaves the login form up.
|
||
api("/state?initChannelState=1")
|
||
.then((u) => onLogin(u.nick, true))
|
||
.catch(() => {});
|
||
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),
|
||
});
|
||
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) }}>
|
||
<{msg.from}>
|
||
</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) {
|
||
if (m.isOperator) {
|
||
ops.push(m);
|
||
} else if (m.isVoiced) {
|
||
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, once logged in: before then only the
|
||
// Server tab exists, and writing would erase the saved channels
|
||
// that onLogin rejoins.
|
||
useEffect(() => {
|
||
if (!loggedIn) return;
|
||
const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
|
||
localStorage.setItem("neoirc_channels", JSON.stringify(channels));
|
||
}, [loggedIn, 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,
|
||
};
|
||
|
||
// A numeric reply carries its number in `code` and its name in
|
||
// `command`; other messages have no `code`.
|
||
switch (msg.code || 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 "MODE": {
|
||
// The body is the change, with its value where it has one:
|
||
// ["+o bob"], ["+m"], ["+k", "secretpass"].
|
||
const modes = Array.isArray(msg.body) ? msg.body.join(" ") : "";
|
||
const text = `${msg.from} sets mode ${modes} on ${msg.to}`;
|
||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
||
|
||
break;
|
||
}
|
||
case 353: {
|
||
// RPL_NAMREPLY — each name is a hostmask (nick!user@host),
|
||
// prefixed with @ for an operator or + for a voiced member.
|
||
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) => {
|
||
const isOperator = n.startsWith("@");
|
||
const isVoiced = n.startsWith("+");
|
||
const hostmask = isOperator || isVoiced ? n.slice(1) : n;
|
||
|
||
return { nick: hostmask.split("!")[0], isOperator, isVoiced };
|
||
});
|
||
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 — params nick, user, host, *; body realname.
|
||
if (Array.isArray(msg.params) && msg.params.length >= 3) {
|
||
const [wiNick, wiUser, wiHost] = msg.params;
|
||
addMessage("Server", {
|
||
...base,
|
||
text: `${wiNick} is ${wiUser}@${wiHost} (${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 313: {
|
||
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
|
||
if (Array.isArray(msg.params) && msg.params.length >= 1) {
|
||
addMessage("Server", {
|
||
...base,
|
||
text: `${msg.params[0]} ${body}`,
|
||
system: true,
|
||
});
|
||
}
|
||
|
||
break;
|
||
}
|
||
case 317: {
|
||
// RPL_WHOISIDLE — nick, idle seconds, signon time (Unix).
|
||
if (Array.isArray(msg.params) && msg.params.length >= 3) {
|
||
const [wiNick, idle, signon] = msg.params;
|
||
const signedOn = new Date(Number(signon) * 1000).toLocaleString();
|
||
addMessage("Server", {
|
||
...base,
|
||
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
|
||
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 324: {
|
||
// RPL_CHANNELMODEIS — channel, mode string.
|
||
if (Array.isArray(msg.params) && msg.params.length >= 2) {
|
||
addMessage("Server", {
|
||
...base,
|
||
text: `${msg.params[0]} modes: ${msg.params[1]}`,
|
||
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;
|
||
// 401: the session is gone (QUIT from another client, or
|
||
// expiry). Reloading drops this page's state and brings
|
||
// back the login screen.
|
||
if (err.status === 401) {
|
||
window.location.reload();
|
||
|
||
return;
|
||
}
|
||
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) => {
|
||
// Read before setLoggedIn(true): from then on the effect under
|
||
// "Persist joined channels" writes the open channel tabs over it.
|
||
const saved = JSON.parse(
|
||
localStorage.getItem("neoirc_channels") || "[]",
|
||
);
|
||
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.
|
||
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;
|
||
}
|
||
// With no arguments MODE is a query. A change is one sign and
|
||
// letter, then its value where it takes one: +m, +o nick.
|
||
const modeArgs = parts.slice(1);
|
||
const body =
|
||
modeArgs.length > 0
|
||
? { command: "MODE", to: tab.name, body: modeArgs }
|
||
: { command: "MODE", to: tab.name };
|
||
try {
|
||
await api("/messages", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
} catch (err) {
|
||
addSystemMessage(
|
||
"Server",
|
||
`MODE failed: ${err.data?.error || "error"}`,
|
||
);
|
||
}
|
||
|
||
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_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 [+/-flag [arg]] — View or 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"));
|