From ea208aba6a9f9763ef766d839b668bb8dd8eaeb2 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 21 Oct 2026 03:20:21 +0000 Subject: [PATCH] Bring the web client in line with the current API (closes #145) The web client was written against an older shape of the API. MODE now carries its change in `body`, so /mode sets modes; /mode alone queries them and the 324 reply is shown. Numeric replies are matched on `code`, so /list, /who and /whois print their lines, and 353 names are read as hostmasks. The member list takes @ and + from `isOperator` and `isVoiced`. A 401 on the poll reloads the page, which shows the login screen, where it used to retry for ever. The token code is gone: no Authorization header, nothing stored, and the page always asks /state whether it can resume a session. Model: opus-5-5 --- web/src/app.jsx | 153 ++++++++++++++++++++++++++++++------------------ 1 file changed, 95 insertions(+), 58 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index 2bea255..932ee72 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -58,13 +58,13 @@ async function mintHashcash(bits, resource) { } } +// Requests are authenticated by the auth cookie, which the browser +// sends on its own. 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) => { @@ -122,12 +122,11 @@ function LoginScreen({ onLogin }) { 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")); - } + // 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(); }, []); @@ -152,7 +151,6 @@ function LoginScreen({ onLogin }) { method: "POST", body: JSON.stringify(reqBody), }); - localStorage.setItem("neoirc_token", res.token); onLogin(res.nick); } catch (err) { setError(err.data?.error || "Connection failed"); @@ -227,10 +225,9 @@ function UserList({ members, onNickClick }) { const regular = []; for (const m of members) { - const mode = m.mode || ""; - if (mode === "o") { + if (m.isOperator) { ops.push(m); - } else if (mode === "v") { + } else if (m.isVoiced) { voiced.push(m); } else { regular.push(m); @@ -366,7 +363,9 @@ function App() { command: msg.command, }; - switch (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; @@ -464,8 +463,9 @@ function App() { break; } - case "353": { - // RPL_NAMREPLY — parse mode prefixes from names list. + 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 && @@ -477,21 +477,18 @@ function App() { : 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" }; - } + const isOperator = n.startsWith("@"); + const isVoiced = n.startsWith("+"); + const hostmask = isOperator || isVoiced ? n.slice(1) : n; - return { nick: n, mode: "" }; + return { nick: hostmask.split("!")[0], isOperator, isVoiced }; }); setMembers((prev) => ({ ...prev, [channel]: parsed })); } break; } - case "332": { + case 332: { // RPL_TOPIC — set topic from server. if (Array.isArray(msg.params) && msg.params.length >= 1) { const channel = msg.params[0]; @@ -503,7 +500,7 @@ function App() { break; } - case "322": { + case 322: { // RPL_LIST — channel, member count, topic. if (Array.isArray(msg.params) && msg.params.length >= 2) { const chName = msg.params[0]; @@ -518,7 +515,7 @@ function App() { break; } - case "323": + case 323: addMessage("Server", { ...base, text: body || "End of channel list", @@ -526,7 +523,7 @@ function App() { }); break; - case "352": { + case 352: { // RPL_WHOREPLY — channel, user, host, server, nick, flags. if (Array.isArray(msg.params) && msg.params.length >= 5) { const whoCh = msg.params[0]; @@ -541,7 +538,7 @@ function App() { break; } - case "315": + case 315: addMessage("Server", { ...base, text: body || "End of /WHO list", @@ -549,20 +546,20 @@ function App() { }); break; - case "311": { - // RPL_WHOISUSER — nick, user, host, *, realname. - if (Array.isArray(msg.params) && msg.params.length >= 1) { - const wiNick = msg.params[0]; + 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} (${body})`, + text: `${wiNick} is ${wiUser}@${wiHost} (${body})`, system: true, }); } break; } - case "312": { + case 312: { // RPL_WHOISSERVER — nick, server, server info. if (Array.isArray(msg.params) && msg.params.length >= 2) { const wiNick = msg.params[0]; @@ -576,7 +573,33 @@ function App() { break; } - case "319": { + 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]; @@ -589,7 +612,7 @@ function App() { break; } - case "318": + case 318: addMessage("Server", { ...base, text: body || "End of /WHOIS list", @@ -597,9 +620,21 @@ function App() { }); break; - case "375": - case "372": - case "376": + 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; @@ -637,6 +672,14 @@ function App() { } 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)); } @@ -972,27 +1015,22 @@ function App() { 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); - 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 { + 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", - "Usage: /mode <+/-mode> [params]", + `MODE failed: ${err.data?.error || "error"}`, ); } @@ -1009,7 +1047,6 @@ function App() { } catch (e) { // Ignore. } - localStorage.removeItem("neoirc_token"); localStorage.removeItem("neoirc_channels"); window.location.reload(); @@ -1128,7 +1165,7 @@ function App() { " /me action — Send an action", " /nick newnick — Change your nickname", " /topic [text] — View or set channel topic", - " /mode +/-flags — Set channel modes", + " /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", -- 2.54.0