fix: IRC SPA cleanup — /motd, /query, Firefox / key, default MOTD (#58)
All checks were successful
check / check (push) Successful in 1m0s
All checks were successful
check / check (push) Successful in 1m0s
## Summary Fixes IRC client SPA issues reported in [issue #57](#57). ## Changes ### Server-side - **Default MOTD**: Added figlet-style ASCII art MOTD for "neoirc" as the default when no MOTD is configured via environment/config - **MOTD command handler**: Added `MOTD` case to `dispatchCommand` so clients can re-request the MOTD at any time (proper IRC behavior) ### SPA (web client) - **`/motd` command**: Sends MOTD request to server, displays 375/372/376 numerics in server window - **`/query nick [message]`**: Opens a DM tab with the specified user, optionally sends a message - **`/clear`**: Clears messages in the current tab - **Firefox `/` key fix**: Added global `keydown` listener that captures `/` when input is not focused, preventing Firefox quick search and redirecting focus to the input element. Also auto-focuses input on SPA init. - **MOTD on resumed sessions**: When restoring from a saved token, the MOTD is re-requested so it always appears in the server window - **Updated `/help`**: Shows all new commands with descriptions - **Login screen MOTD styling**: Improved for ASCII art display (monospace, proper line height) ## Testing - `docker build .` passes (includes `make check` with tests, lint, fmt-check) - All existing tests pass with no modifications closes #57 <!-- session: agent:sdlc-manager:subagent:7c880fec-f818-49ff-a548-2d3c26758bb6 --> Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #58 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #58.
This commit is contained in:
264
web/src/app.jsx
264
web/src/app.jsx
@@ -71,7 +71,7 @@ function LoginScreen({ onLogin }) {
|
||||
const saved = localStorage.getItem("neoirc_token");
|
||||
if (saved) {
|
||||
api("/state")
|
||||
.then((u) => onLogin(u.nick))
|
||||
.then((u) => onLogin(u.nick, true))
|
||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
@@ -419,6 +419,100 @@ function App() {
|
||||
|
||||
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":
|
||||
@@ -497,6 +591,31 @@ function App() {
|
||||
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;
|
||||
@@ -512,10 +631,24 @@ function App() {
|
||||
}, [loggedIn, activeTab, tabs]);
|
||||
|
||||
const onLogin = useCallback(
|
||||
async (userNick) => {
|
||||
async (userNick, isResumed) => {
|
||||
setNick(userNick);
|
||||
setLoggedIn(true);
|
||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||
|
||||
// Request MOTD on resumed sessions (new sessions get
|
||||
// it automatically from the server during creation).
|
||||
if (isResumed) {
|
||||
try {
|
||||
await api("/messages", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: "MOTD" }),
|
||||
});
|
||||
} catch (e) {
|
||||
// MOTD is non-critical.
|
||||
}
|
||||
}
|
||||
|
||||
const saved = JSON.parse(
|
||||
localStorage.getItem("neoirc_channels") || "[]",
|
||||
);
|
||||
@@ -791,18 +924,127 @@ function App() {
|
||||
|
||||
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",
|
||||
" /me action — Send an action",
|
||||
" /nick newnick — Change your nickname",
|
||||
" /topic [text] — View or set channel topic",
|
||||
" /mode +/-flags — Set channel modes",
|
||||
" /quit [reason] — Disconnect from server",
|
||||
" /help — Show this help",
|
||||
" /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);
|
||||
|
||||
@@ -70,14 +70,14 @@ body,
|
||||
}
|
||||
|
||||
.login-box .motd {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
white-space: pre;
|
||||
font-family: inherit;
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 12px;
|
||||
line-height: 1.2;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.login-box form {
|
||||
|
||||
Reference in New Issue
Block a user