diff --git a/internal/config/config.go b/internal/config/config.go index df94550..468ca75 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,14 @@ import ( _ "github.com/joho/godotenv/autoload" // loads .env file ) +const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___ +| '_ \ / _ \/ _ \ | | '__/ __| +| | | | __/ (_) || | | | (__ +|_| |_|\___|\___/ |_|_| \___| + +Welcome to NeoIRC — IRC semantics over HTTP. +Type /help for available commands.` + // Params defines the dependencies for creating a Config. type Params struct { fx.In @@ -62,7 +70,7 @@ func New( viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("MAX_HISTORY", "10000") viper.SetDefault("MAX_MESSAGE_SIZE", "4096") - viper.SetDefault("MOTD", "") + viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("SERVER_NAME", "") viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h") diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 092428a..6b59383 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -670,6 +670,13 @@ func (hdlr *Handlers) dispatchCommand( hdlr.handleQuit( writer, request, sessionID, nick, body, ) + case "MOTD": + hdlr.deliverMOTD( + request, clientID, sessionID, nick, + ) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) case "PING": hdlr.respondJSON(writer, request, map[string]string{ diff --git a/web/src/app.jsx b/web/src/app.jsx index 9086bf4..8f95398 100644 --- a/web/src/app.jsx +++ b/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(); @@ -497,6 +497,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 +537,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 +830,70 @@ 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 [message]"); + } + + 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", + " /clear — Clear messages in the current tab", + " /quit [reason] — Disconnect from server", + " /help — Show this help", ]; for (const line of helpLines) { addSystemMessage("Server", line); diff --git a/web/src/style.css b/web/src/style.css index 0fcd97f..f8b3784 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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 {