Bring the web client in line with the current API (closes #145)
check / check (push) Waiting to run

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
This commit is contained in:
2026-10-21 03:20:21 +00:00
parent c267cd215c
commit ea208aba6a
+95 -58
View File
@@ -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",