Files
chat/web/src/app.jsx

1332 lines
36 KiB
JavaScript
Raw Blame History

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