1332 lines
36 KiB
JavaScript
1332 lines
36 KiB
JavaScript
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 (
|
||
<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) {
|
||
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"));
|