From a55127ff7c714b5ffa06ed91d870dea30223b98d Mon Sep 17 00:00:00 2001 From: user Date: Mon, 9 Mar 2026 14:44:52 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20IRC=20SPA=20cleanup=20=E2=80=94=20/m?= =?UTF-8?q?otd,=20/query,=20Firefox=20/=20key,=20default=20MOTD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add default MOTD with figlet-style ASCII art for neoirc - Add MOTD server command so /motd can be re-requested - Add /motd client command to display message of the day - Add /query command to open DM tabs with optional message - Add /clear command to clear messages in current tab - Fix Firefox quick search conflict: global keydown handler captures '/' and redirects focus to the input element - Auto-focus input on SPA init and tab changes - Show MOTD on resumed sessions (new sessions get it from server automatically during creation) - Update /help with new commands - Style MOTD on login screen for better ASCII art display --- internal/config/config.go | 10 +++- internal/handlers/api.go | 7 +++ web/src/app.jsx | 113 ++++++++++++++++++++++++++++++++++---- web/src/style.css | 10 ++-- 4 files changed, 123 insertions(+), 17 deletions(-) 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 { -- 2.49.1 From a48c7f562c71c4b5a00fc7dc87bdd133209a1599 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 9 Mar 2026 14:56:11 -0700 Subject: [PATCH 2/2] feat: add /list, /who, /whois commands + CLI parity - Server: add LIST, WHO, WHOIS command handlers in dispatchCommand with proper IRC numerics (322/323, 352/315, 311/312/319/318) - SPA: add /list, /who, /whois command parsing and numeric display - CLI: add /motd, /who, /whois commands for feature parity - Rebuild SPA dist from source --- cmd/neoirc-cli/main.go | 114 +++++++++++++++- internal/handlers/api.go | 281 +++++++++++++++++++++++++++++++++++++-- web/dist/app.js | 4 +- web/dist/style.css | 10 +- web/src/app.jsx | 151 +++++++++++++++++++++ 5 files changed, 536 insertions(+), 24 deletions(-) diff --git a/cmd/neoirc-cli/main.go b/cmd/neoirc-cli/main.go index 1fb3fb1..2b81705 100644 --- a/cmd/neoirc-cli/main.go +++ b/cmd/neoirc-cli/main.go @@ -138,16 +138,29 @@ func (a *App) dispatchCommand(cmd, args string) { a.cmdQuery(args) case "/topic": a.cmdTopic(args) - case "/names": - a.cmdNames() - case "/list": - a.cmdList() case "/window", "/w": a.cmdWindow(args) case "/quit": a.cmdQuit() case "/help": a.cmdHelp() + default: + a.dispatchInfoCommand(cmd, args) + } +} + +func (a *App) dispatchInfoCommand(cmd, args string) { + switch cmd { + case "/names": + a.cmdNames() + case "/list": + a.cmdList() + case "/motd": + a.cmdMotd() + case "/who": + a.cmdWho(args) + case "/whois": + a.cmdWhois(args) default: a.ui.AddStatus( "[red]Unknown command: " + cmd, @@ -510,6 +523,96 @@ func (a *App) cmdList() { a.ui.AddStatus("[cyan]*** End of channel list") } +func (a *App) cmdMotd() { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + err := a.client.SendMessage( + &api.Message{Command: "MOTD"}, //nolint:exhaustruct + ) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]MOTD failed: %v", err, + )) + } +} + +func (a *App) cmdWho(args string) { + a.mu.Lock() + connected := a.connected + target := a.target + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + channel := args + if channel == "" { + channel = target + } + + if channel == "" || + !strings.HasPrefix(channel, "#") { + a.ui.AddStatus( + "[red]Usage: /who #channel", + ) + + return + } + + err := a.client.SendMessage( + &api.Message{ //nolint:exhaustruct + Command: "WHO", To: channel, + }, + ) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]WHO failed: %v", err, + )) + } +} + +func (a *App) cmdWhois(args string) { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + if args == "" { + a.ui.AddStatus( + "[red]Usage: /whois ", + ) + + return + } + + err := a.client.SendMessage( + &api.Message{ //nolint:exhaustruct + Command: "WHOIS", To: args, + }, + ) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]WHOIS failed: %v", err, + )) + } +} + func (a *App) cmdWindow(args string) { if args == "" { a.ui.AddStatus( @@ -574,6 +677,9 @@ func (a *App) cmdHelp() { " /topic [text] — View/set topic", " /names — List channel members", " /list — List channels", + " /who [#channel] — List users in channel", + " /whois — Show user info", + " /motd — Show message of the day", " /window — Switch buffer", " /quit — Disconnect and exit", " /help — This help", diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 6b59383..ce752a2 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -670,20 +670,12 @@ func (hdlr *Handlers) dispatchCommand( hdlr.handleQuit( writer, request, sessionID, nick, body, ) - case "MOTD": - hdlr.deliverMOTD( - request, clientID, sessionID, nick, + case "MOTD", "LIST", "WHO", "WHOIS", "PING": + hdlr.dispatchInfoCommand( + writer, request, + sessionID, clientID, nick, + command, target, bodyLines, ) - hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, - http.StatusOK) - case "PING": - hdlr.respondJSON(writer, request, - map[string]string{ - "command": "PONG", - "from": hdlr.serverName(), - }, - http.StatusOK) default: hdlr.enqueueNumeric( request.Context(), clientID, @@ -1398,6 +1390,269 @@ func (hdlr *Handlers) executeTopic( http.StatusOK) } +// dispatchInfoCommand handles informational IRC commands +// that produce server-side numerics (MOTD, LIST, WHO, +// WHOIS, PING). +func (hdlr *Handlers) dispatchInfoCommand( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, command, target string, + bodyLines func() []string, +) { + okResp := map[string]string{"status": "ok"} + + switch command { + case "MOTD": + hdlr.deliverMOTD( + request, clientID, sessionID, nick, + ) + case "LIST": + hdlr.handleListCmd( + request, clientID, sessionID, nick, + ) + case "WHO": + hdlr.handleWhoCmd( + request, clientID, sessionID, nick, + target, + ) + case "WHOIS": + hdlr.handleWhoisCmd( + request, clientID, sessionID, nick, + target, bodyLines, + ) + case "PING": + hdlr.respondJSON(writer, request, + map[string]string{ + "command": "PONG", + "from": hdlr.serverName(), + }, + http.StatusOK) + + return + } + + hdlr.respondJSON( + writer, request, okResp, http.StatusOK, + ) +} + +// handleListCmd sends RPL_LIST (322) for each channel, +// then sends 323 to signal the end of the list. +func (hdlr *Handlers) handleListCmd( + request *http.Request, + clientID, sessionID int64, + nick string, +) { + ctx := request.Context() + + channels, err := hdlr.params.Database.ListAllChannels( + ctx, + ) + if err != nil { + hdlr.enqueueNumeric( + ctx, clientID, "323", nick, nil, + "End of /LIST", + ) + hdlr.broker.Notify(sessionID) + + return + } + + for _, channel := range channels { + memberIDs, _ := + hdlr.params.Database.GetChannelMemberIDs( + ctx, channel.ID, + ) + + count := strconv.Itoa(len(memberIDs)) + topic := channel.Topic + + if topic == "" { + topic = " " + } + + hdlr.enqueueNumeric( + ctx, clientID, "322", nick, + []string{channel.Name, count}, topic, + ) + } + + hdlr.enqueueNumeric( + ctx, clientID, "323", nick, nil, + "End of /LIST", + ) + hdlr.broker.Notify(sessionID) +} + +// handleWhoCmd sends RPL_WHOREPLY (352) for each member +// of the target channel, followed by RPL_ENDOFWHO (315). +func (hdlr *Handlers) handleWhoCmd( + request *http.Request, + clientID, sessionID int64, + nick, target string, +) { + ctx := request.Context() + + if target == "" { + hdlr.enqueueNumeric( + ctx, clientID, "461", nick, + []string{"WHO"}, "Not enough parameters", + ) + hdlr.broker.Notify(sessionID) + + return + } + + channel := target + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + chID, err := hdlr.params.Database.GetChannelByName( + ctx, channel, + ) + if err != nil { + hdlr.enqueueNumeric( + ctx, clientID, "315", nick, + []string{channel}, "End of /WHO list", + ) + hdlr.broker.Notify(sessionID) + + return + } + + members, err := hdlr.params.Database.ChannelMembers( + ctx, chID, + ) + if err == nil { + srvName := hdlr.serverName() + + for _, mem := range members { + hdlr.enqueueNumeric( + ctx, clientID, "352", nick, + []string{ + channel, mem.Nick, "neoirc", + srvName, mem.Nick, "H", + }, + "0 "+mem.Nick, + ) + } + } + + hdlr.enqueueNumeric( + ctx, clientID, "315", nick, + []string{channel}, "End of /WHO list", + ) + hdlr.broker.Notify(sessionID) +} + +// handleWhoisCmd sends WHOIS reply numerics (311, 312, +// 319, 318) for the target nick. +func (hdlr *Handlers) handleWhoisCmd( + request *http.Request, + clientID, sessionID int64, + nick, target string, + bodyLines func() []string, +) { + ctx := request.Context() + + whoisNick := target + if whoisNick == "" { + lines := bodyLines() + if len(lines) > 0 { + whoisNick = strings.TrimSpace(lines[0]) + } + } + + if whoisNick == "" { + hdlr.enqueueNumeric( + ctx, clientID, "461", nick, + []string{"WHOIS"}, "Not enough parameters", + ) + hdlr.broker.Notify(sessionID) + + return + } + + targetSID, err := + hdlr.params.Database.GetSessionByNick( + ctx, whoisNick, + ) + if err != nil { + hdlr.enqueueNumeric( + ctx, clientID, "401", nick, + []string{whoisNick}, + "No such nick/channel", + ) + hdlr.enqueueNumeric( + ctx, clientID, "318", nick, + []string{whoisNick}, + "End of /WHOIS list", + ) + hdlr.broker.Notify(sessionID) + + return + } + + hdlr.sendWhoisNumerics( + ctx, clientID, sessionID, nick, + whoisNick, targetSID, + ) +} + +// sendWhoisNumerics emits 311/312/319/318 for a +// resolved WHOIS target. +func (hdlr *Handlers) sendWhoisNumerics( + ctx context.Context, + clientID, sessionID int64, + nick, whoisNick string, + targetSID int64, +) { + srvName := hdlr.serverName() + + // 311 RPL_WHOISUSER + hdlr.enqueueNumeric( + ctx, clientID, "311", nick, + []string{whoisNick, whoisNick, "neoirc", "*"}, + whoisNick, + ) + + // 312 RPL_WHOISSERVER + hdlr.enqueueNumeric( + ctx, clientID, "312", nick, + []string{whoisNick, srvName}, + srvName, + ) + + // 319 RPL_WHOISCHANNELS + channels, _ := hdlr.params.Database.GetSessionChannels( + ctx, targetSID, + ) + + if len(channels) > 0 { + names := make([]string, 0, len(channels)) + + for _, chanInfo := range channels { + names = append(names, chanInfo.Name) + } + + hdlr.enqueueNumeric( + ctx, clientID, "319", nick, + []string{whoisNick}, + strings.Join(names, " "), + ) + } + + // 318 RPL_ENDOFWHOIS + hdlr.enqueueNumeric( + ctx, clientID, "318", nick, + []string{whoisNick}, + "End of /WHOIS list", + ) + hdlr.broker.Notify(sessionID) +} + func (hdlr *Handlers) handleQuit( writer http.ResponseWriter, request *http.Request, diff --git a/web/dist/app.js b/web/dist/app.js index 9480175..65d6ad9 100644 --- a/web/dist/app.js +++ b/web/dist/app.js @@ -1,2 +1,2 @@ -var ie,w,Ue,pt,K,Ee,He,Re,De,he,de,pe,mt,Z={},Le=[],ht=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,ce=Array.isArray;function W(e,t){for(var n in t)e[n]=t[n];return e}function ye(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function p(e,t,n){var o,a,s,c={};for(s in t)s=="key"?o=t[s]:s=="ref"?a=t[s]:c[s]=t[s];if(arguments.length>2&&(c.children=arguments.length>3?ie.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(s in e.defaultProps)c[s]===void 0&&(c[s]=e.defaultProps[s]);return oe(e,c,o,a,null)}function oe(e,t,n,o,a){var s={type:e,props:t,key:n,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:a??++Ue,__i:-1,__u:0};return a==null&&w.vnode!=null&&w.vnode(s),s}function _e(e){return e.children}function se(e,t){this.props=e,this.context=t}function Y(e,t){if(t==null)return e.__?Y(e.__,e.__i+1):null;for(var n;t_&&K.sort(Re),e=K.shift(),_=K.length,e.__d&&(n=void 0,o=void 0,a=(o=(t=e).__v).__e,s=[],c=[],t.__P&&((n=W({},o)).__v=o.__v+1,w.vnode&&w.vnode(n),ve(t.__P,n,o,t.__n,t.__P.namespaceURI,32&o.__u?[a]:null,s,a??Y(o),!!(32&o.__u),c),n.__v=o.__v,n.__.__k[n.__i]=n,Je(s,n,c),o.__e=o.__=null,n.__e!=a&&Fe(n)));ae.__r=0}function We(e,t,n,o,a,s,c,_,h,u,b){var i,m,y,C,M,I,k,g=o&&o.__k||Le,U=t.length;for(h=yt(n,t,g,h,U),i=0;i0?c=e.__k[s]=oe(c.type,c.props,c.key,c.ref?c.ref:null,c.__v):e.__k[s]=c,h=s+m,c.__=e,c.__b=e.__b+1,_=null,(u=c.__i=vt(c,n,h,i))!=-1&&(i--,(_=n[u])&&(_.__u|=2)),_==null||_.__v==null?(u==-1&&(a>b?m--:ah?m--:m++,c.__u|=4))):e.__k[s]=null;if(i)for(s=0;s(b?1:0)){for(a=n-1,s=n+1;a>=0||s=0?a--:s++])!=null&&(2&u.__u)==0&&_==u.key&&h==u.type)return c}return-1}function Oe(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||ht.test(t)?n:n+"px"}function re(e,t,n,o,a){var s,c;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof o=="string"&&(e.style.cssText=o=""),o)for(t in o)n&&t in n||Oe(e.style,t,"");if(n)for(t in n)o&&n[t]==o[t]||Oe(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")s=t!=(t=t.replace(De,"$1")),c=t.toLowerCase(),t=c in e||t=="onFocusOut"||t=="onFocusIn"?c.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+s]=n,n?o?n.u=o.u:(n.u=he,e.addEventListener(t,s?pe:de,s)):e.removeEventListener(t,s?pe:de,s);else{if(a=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function $e(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=he++;else if(t.t0?e:ce(e)?e.map(Ve):W({},e)}function bt(e,t,n,o,a,s,c,_,h){var u,b,i,m,y,C,M,I=n.props||Z,k=t.props,g=t.type;if(g=="svg"?a="http://www.w3.org/2000/svg":g=="math"?a="http://www.w3.org/1998/Math/MathML":a||(a="http://www.w3.org/1999/xhtml"),s!=null){for(u=0;u=n.__.length&&n.__.push({}),n.__[e]}function N(e){return te=1,gt(ot,e)}function gt(e,t,n){var o=Se(ee++,2);if(o.t=e,!o.__c&&(o.__=[n?n(t):ot(void 0,t),function(_){var h=o.__N?o.__N[0]:o.__[0],u=o.t(h,_);h!==u&&(o.__N=[u,o.__[1]],o.__c.setState({}))}],o.__c=x,!x.__f)){var a=function(_,h,u){if(!o.__c.__H)return!0;var b=o.__c.__H.__.filter(function(m){return!!m.__c});if(b.every(function(m){return!m.__N}))return!s||s.call(this,_,h,u);var i=o.__c.props!==_;return b.forEach(function(m){if(m.__N){var y=m.__[0];m.__=m.__N,m.__N=void 0,y!==m.__[0]&&(i=!0)}}),s&&s.call(this,_,h,u)||i};x.__f=!0;var s=x.shouldComponentUpdate,c=x.componentWillUpdate;x.componentWillUpdate=function(_,h,u){if(this.__e){var b=s;s=void 0,a(_,h,u),s=b}c&&c.call(this,_,h,u)},x.shouldComponentUpdate=a}return o.__N||o.__}function $(e,t){var n=Se(ee++,3);!A.__s&&rt(n.__H,t)&&(n.__=e,n.u=t,x.__H.__h.push(n))}function L(e){return te=5,nt(function(){return{current:e}},[])}function nt(e,t){var n=Se(ee++,7);return rt(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function X(e,t){return te=8,nt(function(){return e},t)}function St(){for(var e;e=tt.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(le),e.__H.__h.forEach(ge),e.__H.__h=[]}catch(t){e.__H.__h=[],A.__e(t,e.__v)}}A.__b=function(e){x=null,Ke&&Ke(e)},A.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Ze&&Ze(e,t)},A.__r=function(e){ze&&ze(e),ee=0;var t=(x=e.__c).__H;t&&(ke===x?(t.__h=[],x.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(le),t.__h.forEach(ge),t.__h=[],ee=0)),ke=x},A.diffed=function(e){Qe&&Qe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(tt.push(t)!==1&&Ge===A.requestAnimationFrame||((Ge=A.requestAnimationFrame)||wt)(St)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),ke=x=null},A.__c=function(e,t){t.some(function(n){try{n.__h.forEach(le),n.__h=n.__h.filter(function(o){return!o.__||ge(o)})}catch(o){t.some(function(a){a.__h&&(a.__h=[])}),t=[],A.__e(o,n.__v)}}),Ye&&Ye(e,t)},A.unmount=function(e){Xe&&Xe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(o){try{le(o)}catch(a){t=a}}),n.__H=void 0,t&&A.__e(t,n.__v))};var et=typeof requestAnimationFrame=="function";function wt(e){var t,n=function(){clearTimeout(o),et&&cancelAnimationFrame(t),setTimeout(e)},o=setTimeout(n,35);et&&(t=requestAnimationFrame(n))}function le(e){var t=x,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),x=t}function ge(e){var t=x;e.__c=e.__(),x=t}function rt(e,t){return!e||e.length!==t.length||t.some(function(n,o){return n!==e[o]})}function ot(e,t){return typeof t=="function"?t(e):t}var Ct="/api/v1",Tt=15,xt=3e3,It=1e4,Ce="ACTION ",Te="";function E(e,t={}){let n=localStorage.getItem("neoirc_token"),o={"Content-Type":"application/json",...t.headers||{}};n&&(o.Authorization=`Bearer ${n}`);let{signal:a,...s}=t;return fetch(Ct+e,{...s,headers:o,signal:a}).then(async c=>{let _=await c.json().catch(()=>null);if(!c.ok)throw{status:c.status,data:_};return _})}function At(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1})}function we(e){let t=0;for(let o=0;o{E("/server").then(m=>{m.name&&h(m.name),m.motd&&c(m.motd)}).catch(()=>{}),localStorage.getItem("neoirc_token")&&E("/state").then(m=>e(m.nick)).catch(()=>localStorage.removeItem("neoirc_token")),u.current?.focus()},[]),p("div",{class:"login-screen"},p("div",{class:"login-box"},p("h1",null,_),s&&p("pre",{class:"motd"},s),p("form",{onSubmit:async i=>{i.preventDefault(),a("");try{let m=await E("/session",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("neoirc_token",m.token),e(m.nick)}catch(m){a(m.data?.error||"Connection failed")}}},p("label",null,"Nickname:"),p("input",{ref:u,type:"text",placeholder:"Enter nickname",value:t,onInput:i=>n(i.target.value),maxLength:32,autoFocus:!0}),p("button",{type:"submit"},"Connect")),o&&p("div",{class:"error"},o)))}function Mt({msg:e,myNick:t}){let n=At(e.ts);return e.system?p("div",{class:"message system-message"},p("span",{class:"timestamp"},"[",n,"]"),p("span",{class:"system-text"}," * ",e.text)):e.isAction?p("div",{class:"message action-message"},p("span",{class:"timestamp"},"[",n,"]"),p("span",{class:"action-text"}," ","* ",p("span",{style:{color:we(e.from)}},e.from)," ",e.text)):p("div",{class:"message"},p("span",{class:"timestamp"},"[",n,"]")," ",p("span",{class:"nick",style:{color:we(e.from)}},"<",e.from,">")," ",p("span",{class:"content"},e.text))}function Ot({members:e,onNickClick:t}){let n=[],o=[],a=[];for(let _ of e){let h=_.mode||"";h==="o"?n.push(_):h==="v"?o.push(_):a.push(_)}let s=(_,h)=>_.nick.toLowerCase().localeCompare(h.nick.toLowerCase());n.sort(s),o.sort(s),a.sort(s);let c=(_,h)=>p("div",{class:"nick-entry",onClick:()=>t(_.nick),title:_.nick},p("span",{class:"nick-prefix"},h),p("span",{class:"nick-name",style:{color:we(_.nick)}},_.nick));return p("div",{class:"user-list"},p("div",{class:"user-list-header"},e.length," user",e.length!==1?"s":""),p("div",{class:"user-list-entries"},n.map(_=>c(_,"@")),o.map(_=>c(_,"+")),a.map(_=>c(_,""))))}function $t(){let[e,t]=N(!1),[n,o]=N(""),[a,s]=N([{type:"server",name:"Server"}]),[c,_]=N(0),[h,u]=N({Server:[]}),[b,i]=N({}),[m,y]=N({}),[C,M]=N({}),[I,k]=N(""),[g,U]=N(!0),[O,z]=N([]),[R,B]=N(-1),j=L(0),J=L(new Set),Q=L(null),P=L(a),F=L(c),ne=L(n),xe=L(),Ie=L();$(()=>{P.current=a},[a]),$(()=>{F.current=c},[c]),$(()=>{ne.current=n},[n]),$(()=>{let r=a.filter(l=>l.type==="channel").map(l=>l.name);localStorage.setItem("neoirc_channels",JSON.stringify(r))},[a]),$(()=>{let r=a[c];r&&M(l=>({...l,[r.name]:0}))},[c,a]);let D=X((r,l)=>{if(l.id&&J.current.has(l.id))return;l.id&&J.current.add(l.id),u(f=>({...f,[r]:[...f[r]||[],l]}));let d=P.current[F.current];(!d||d.name!==r)&&M(f=>({...f,[r]:(f[r]||0)+1}))},[]),T=X((r,l)=>{u(d=>({...d,[r]:[...d[r]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:l,system:!0}]}))},[]),q=X(r=>{let l=r.replace("#","");E(`/channels/${l}/members`).then(d=>{i(f=>({...f,[r]:d}))}).catch(()=>{})},[]),ue=X(r=>{let l=Array.isArray(r.body)?r.body.join(` -`):"",d={id:r.id,ts:r.ts,from:r.from,to:r.to,command:r.command};switch(r.command){case"PRIVMSG":case"NOTICE":{let f=l,v=!1;Pt(f)&&(f=Nt(f),v=!0);let S={...d,text:f,system:!1,isAction:v},G=r.to;if(G&&G.startsWith("#"))D(G,S);else{let H=r.from===ne.current?r.to:r.from;s(fe=>fe.find(Ne=>Ne.type==="dm"&&Ne.name===H)?fe:[...fe,{type:"dm",name:H}]),D(H,S)}break}case"JOIN":{let f=`${r.from} has joined ${r.to}`;r.to&&D(r.to,{...d,text:f,system:!0}),r.to&&r.to.startsWith("#")&&q(r.to);break}case"PART":{let f=l?" ("+l+")":"",v=`${r.from} has parted ${r.to}${f}`;r.to&&D(r.to,{...d,text:v,system:!0}),r.to&&r.to.startsWith("#")&&q(r.to);break}case"QUIT":{let f=l?" ("+l+")":"",v=`${r.from} has quit${f}`;P.current.forEach(S=>{S.type==="channel"&&D(S.name,{...d,text:v,system:!0})});break}case"NICK":{let f=Array.isArray(r.body)?r.body[0]:l,v=`${r.from} is now known as ${f}`;P.current.forEach(S=>{S.type==="channel"&&D(S.name,{...d,text:v,system:!0})}),r.from===ne.current&&f&&o(f),P.current.forEach(S=>{S.type==="channel"&&q(S.name)});break}case"TOPIC":{let f=`${r.from} has changed the topic to: ${l}`;r.to&&(D(r.to,{...d,text:f,system:!0}),y(v=>({...v,[r.to]:l})));break}case"353":{if(Array.isArray(r.params)&&r.params.length>=2&&r.body){let f=r.params[1],G=(Array.isArray(r.body)?r.body[0]:String(r.body)).split(/\s+/).filter(Boolean).map(H=>H.startsWith("@")?{nick:H.slice(1),mode:"o"}:H.startsWith("+")?{nick:H.slice(1),mode:"v"}:{nick:H,mode:""});i(H=>({...H,[f]:G}))}break}case"332":{if(Array.isArray(r.params)&&r.params.length>=1){let f=r.params[0],v=Array.isArray(r.body)?r.body[0]:l;v&&y(S=>({...S,[f]:v}))}break}case"375":case"372":case"376":D("Server",{...d,text:l,system:!0});break;default:l&&D("Server",{...d,text:l,system:!0})}},[D,q]);$(()=>{if(!e)return;let r=!0;return(async()=>{for(;r;)try{let d=new AbortController;Q.current=d;let f=await E(`/messages?after=${j.current}&timeout=${Tt}`,{signal:d.signal});if(!r)break;if(U(!0),f.messages)for(let v of f.messages)ue(v);f.last_id>j.current&&(j.current=f.last_id)}catch(d){if(!r)break;if(d.name==="AbortError")continue;U(!1),await new Promise(f=>setTimeout(f,xt))}})(),()=>{r=!1,Q.current?.abort()}},[e,ue]),$(()=>{if(!e)return;let r=a[c];if(!r||r.type!=="channel")return;q(r.name);let l=setInterval(()=>q(r.name),It);return()=>clearInterval(l)},[e,c,a,q]),$(()=>{xe.current?.scrollIntoView({behavior:"smooth"})},[h,c]),$(()=>{Ie.current?.focus()},[c]),$(()=>{if(!e)return;let r=a[c];!r||r.type!=="channel"||E("/channels").then(l=>{let d=l.find(f=>f.name===r.name);d&&d.topic&&y(f=>({...f,[r.name]:d.topic}))}).catch(()=>{})},[e,c,a]);let st=X(async r=>{o(r),t(!0),T("Server",`Connected as ${r}`);let l=JSON.parse(localStorage.getItem("neoirc_channels")||"[]");for(let d of l)try{await E("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:d})}),s(f=>f.find(v=>v.type==="channel"&&v.name===d)?f:[...f,{type:"channel",name:d}])}catch{}},[T]),at=async r=>{if(r){r=r.trim(),r.startsWith("#")||(r="#"+r);try{await E("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:r})}),s(d=>d.find(f=>f.type==="channel"&&f.name===r)?d:[...d,{type:"channel",name:r}]);let l=P.current.length;_(l);try{let d=await E(`/history?target=${encodeURIComponent(r)}&limit=50`);if(Array.isArray(d))for(let f of d)ue(f)}catch{}}catch(l){T("Server",`Failed to join ${r}: ${l.data?.error||"error"}`)}}},Ae=async(r,l)=>{try{await E("/messages",{method:"POST",body:JSON.stringify(l?{command:"PART",to:r,body:[l]}:{command:"PART",to:r})})}catch{}s(d=>d.filter(f=>!(f.type==="channel"&&f.name===r))),_(0)},it=r=>{let l=a[r];l.type==="channel"?Ae(l.name):l.type==="dm"&&(s(d=>d.filter((f,v)=>v!==r)),c>=r&&_(Math.max(0,c-1)))},Pe=r=>{if(r===ne.current)return;s(d=>d.find(f=>f.type==="dm"&&f.name===r)?d:[...d,{type:"dm",name:r}]);let l=a.findIndex(d=>d.type==="dm"&&d.name===r);_(l>=0?l:a.length)},ct=async r=>{let l=r.split(" "),d=l[0].toLowerCase(),f=a[c];switch(d){case"/join":{l[1]?at(l[1]):T("Server","Usage: /join #channel");break}case"/part":{if(f.type==="channel"){let v=l.slice(1).join(" ")||void 0;Ae(f.name,v)}else T("Server","You are not in a channel");break}case"/msg":{if(l[1]&&l.slice(2).join(" ")){let v=l[1],S=l.slice(2).join(" ");try{await E("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:v,body:[S]})}),Pe(v)}catch(G){T("Server",`Message failed: ${G.data?.error||"error"}`)}}else T("Server","Usage: /msg ");break}case"/me":{if(f.type==="server"){T("Server","Cannot use /me in server window");break}let v=l.slice(1).join(" ");if(v)try{await E("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:f.name,body:[Ce+v+Te]})})}catch(S){T(f.name,`Action failed: ${S.data?.error||"error"}`)}else T("Server","Usage: /me ");break}case"/nick":{if(l[1])try{await E("/messages",{method:"POST",body:JSON.stringify({command:"NICK",body:[l[1]]})})}catch(v){T("Server",`Nick change failed: ${v.data?.error||"error"}`)}else T("Server","Usage: /nick ");break}case"/topic":{if(f.type!=="channel"){T("Server","You are not in a channel");break}let v=l.slice(1).join(" ");if(v)try{await E("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:f.name,body:[v]})})}catch(S){T("Server",`Topic change failed: ${S.data?.error||"error"}`)}else T("Server",`Current topic for ${f.name}: ${m[f.name]||"(none)"}`);break}case"/mode":{if(f.type!=="channel"){T("Server","You are not in a channel");break}let v=l.slice(1);if(v.length>0)try{await E("/messages",{method:"POST",body:JSON.stringify({command:"MODE",to:f.name,params:v})})}catch(S){T("Server",`Mode change failed: ${S.data?.error||"error"}`)}else T("Server","Usage: /mode <+/-mode> [params]");break}case"/quit":{let v=l.slice(1).join(" ")||void 0;try{await E("/messages",{method:"POST",body:JSON.stringify(v?{command:"QUIT",body:[v]}:{command:"QUIT"})})}catch{}localStorage.removeItem("neoirc_token"),localStorage.removeItem("neoirc_channels"),window.location.reload();break}case"/help":{let v=["Available commands:"," /join #channel \u2014 Join a channel"," /part [reason] \u2014 Part the current channel"," /msg nick message \u2014 Send a private message"," /me action \u2014 Send an action"," /nick newnick \u2014 Change your nickname"," /topic [text] \u2014 View or set channel topic"," /mode +/-flags \u2014 Set channel modes"," /quit [reason] \u2014 Disconnect from server"," /help \u2014 Show this help"];for(let S of v)T("Server",S);break}default:T("Server",`Unknown command: ${d}`)}},_t=async()=>{let r=I.trim();if(!r)return;z(d=>{let f=[...d,r];return f.length>100&&f.shift(),f}),B(-1),k("");let l=a[c];if(l){if(r.startsWith("/")){await ct(r);return}if(l.type==="server"){T("Server","Cannot send messages to the server window. Use /join #channel first.");return}try{await E("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:l.name,body:[r]})})}catch(d){T(l.name,`Send failed: ${d.data?.error||"error"}`)}}},lt=r=>{if(r.key==="Enter")_t();else if(r.key==="ArrowUp"){if(r.preventDefault(),O.length>0){let l=R===-1?O.length-1:Math.max(0,R-1);B(l),k(O[l])}}else if(r.key==="ArrowDown"&&(r.preventDefault(),R>=0)){let l=R+1;l>=O.length?(B(-1),k("")):(B(l),k(O[l]))}};if(!e)return p(Et,{onLogin:st});let V=a[c]||a[0],ut=h[V.name]||[],ft=b[V.name]||[],dt=m[V.name]||"";return p("div",{class:"irc-app"},p("div",{class:"tab-bar"},p("div",{class:"tabs"},a.map((r,l)=>p("div",{class:`tab ${l===c?"active":""} ${C[r.name]>0&&l!==c?"has-unread":""}`,onClick:()=>_(l),key:r.name},p("span",{class:"tab-label"},(r.type==="dm",r.name)),C[r.name]>0&&l!==c&&p("span",{class:"unread-count"},"(",C[r.name],")"),r.type!=="server"&&p("span",{class:"tab-close",onClick:d=>{d.stopPropagation(),it(l)}},"\xD7")))),p("div",{class:"status-area"},!g&&p("span",{class:"status-warn"},"\u25CF Reconnecting"),p("span",{class:"status-nick"},n))),V.type==="channel"&&p("div",{class:"topic-bar"},p("span",{class:"topic-label"},"Topic:")," ",p("span",{class:"topic-text"},dt||"(no topic set)")),p("div",{class:"main-area"},p("div",{class:"messages-panel"},p("div",{class:"messages-scroll"},ut.map(r=>p(Mt,{msg:r,myNick:n,key:r.id})),p("div",{ref:xe}))),V.type==="channel"&&p(Ot,{members:ft,onNickClick:Pe})),p("div",{class:"input-line"},p("span",{class:"input-prompt"},"[",n,"]",V.type!=="server"?` ${V.name}`:""," >"),p("input",{ref:Ie,type:"text",value:I,onInput:r=>k(r.target.value),onKeyDown:lt,placeholder:V.type==="server"?"Type /help for commands":"",spellCheck:!1,autoComplete:"off"})))}qe(p($t,null),document.getElementById("root")); +var ie,T,He,pt,G,Pe,Ue,De,Re,ve,me,he,mt,ee={},Le=[],ht=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,_e=Array.isArray;function J(e,t){for(var n in t)e[n]=t[n];return e}function be(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function m(e,t,n){var o,s,a,_={};for(a in t)a=="key"?o=t[a]:a=="ref"?s=t[a]:_[a]=t[a];if(arguments.length>2&&(_.children=arguments.length>3?ie.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(a in e.defaultProps)_[a]===void 0&&(_[a]=e.defaultProps[a]);return ae(e,_,o,s,null)}function ae(e,t,n,o,s){var a={type:e,props:t,key:n,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s??++He,__i:-1,__u:0};return s==null&&T.vnode!=null&&T.vnode(a),a}function le(e){return e.children}function se(e,t){this.props=e,this.context=t}function Y(e,t){if(t==null)return e.__?Y(e.__,e.__i+1):null;for(var n;tu&&G.sort(De),e=G.shift(),u=G.length,e.__d&&(n=void 0,o=void 0,s=(o=(t=e).__v).__e,a=[],_=[],t.__P&&((n=J({},o)).__v=o.__v+1,T.vnode&&T.vnode(n),ke(t.__P,n,o,t.__n,t.__P.namespaceURI,32&o.__u?[s]:null,a,s??Y(o),!!(32&o.__u),_),n.__v=o.__v,n.__.__k[n.__i]=n,je(a,n,_),o.__e=o.__=null,n.__e!=s&&We(n)));ce.__r=0}function Fe(e,t,n,o,s,a,_,u,y,f,b){var c,h,v,C,E,A,g,w=o&&o.__k||Le,U=t.length;for(y=yt(n,t,w,y,U),c=0;c0?_=e.__k[a]=ae(_.type,_.props,_.key,_.ref?_.ref:null,_.__v):e.__k[a]=_,y=a+h,_.__=e,_.__b=e.__b+1,u=null,(f=_.__i=vt(_,n,y,c))!=-1&&(c--,(u=n[f])&&(u.__u|=2)),u==null||u.__v==null?(f==-1&&(s>b?h--:sy?h--:h++,_.__u|=4))):e.__k[a]=null;if(c)for(a=0;a(b?1:0)){for(s=n-1,a=n+1;s>=0||a=0?s--:a++])!=null&&(2&f.__u)==0&&u==f.key&&y==f.type)return _}return-1}function Ee(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||ht.test(t)?n:n+"px"}function oe(e,t,n,o,s){var a,_;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof o=="string"&&(e.style.cssText=o=""),o)for(t in o)n&&t in n||Ee(e.style,t,"");if(n)for(t in n)o&&n[t]==o[t]||Ee(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")a=t!=(t=t.replace(Re,"$1")),_=t.toLowerCase(),t=_ in e||t=="onFocusOut"||t=="onFocusIn"?_.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+a]=n,n?o?n.u=o.u:(n.u=ve,e.addEventListener(t,a?he:me,a)):e.removeEventListener(t,a?he:me,a);else{if(s=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function Me(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=ve++;else if(t.t0?e:_e(e)?e.map(Ve):J({},e)}function bt(e,t,n,o,s,a,_,u,y){var f,b,c,h,v,C,E,A=n.props||ee,g=t.props,w=t.type;if(w=="svg"?s="http://www.w3.org/2000/svg":w=="math"?s="http://www.w3.org/1998/Math/MathML":s||(s="http://www.w3.org/1999/xhtml"),a!=null){for(f=0;f=n.__.length&&n.__.push({}),n.__[e]}function $(e){return ne=1,gt(ot,e)}function gt(e,t,n){var o=Te(te++,2);if(o.t=e,!o.__c&&(o.__=[n?n(t):ot(void 0,t),function(u){var y=o.__N?o.__N[0]:o.__[0],f=o.t(y,u);y!==f&&(o.__N=[f,o.__[1]],o.__c.setState({}))}],o.__c=I,!I.__f)){var s=function(u,y,f){if(!o.__c.__H)return!0;var b=o.__c.__H.__.filter(function(h){return!!h.__c});if(b.every(function(h){return!h.__N}))return!a||a.call(this,u,y,f);var c=o.__c.props!==u;return b.forEach(function(h){if(h.__N){var v=h.__[0];h.__=h.__N,h.__N=void 0,v!==h.__[0]&&(c=!0)}}),a&&a.call(this,u,y,f)||c};I.__f=!0;var a=I.shouldComponentUpdate,_=I.componentWillUpdate;I.componentWillUpdate=function(u,y,f){if(this.__e){var b=a;a=void 0,s(u,y,f),a=b}_&&_.call(this,u,y,f)},I.shouldComponentUpdate=s}return o.__N||o.__}function H(e,t){var n=Te(te++,3);!O.__s&&rt(n.__H,t)&&(n.__=e,n.u=t,I.__H.__h.push(n))}function L(e){return ne=5,nt(function(){return{current:e}},[])}function nt(e,t){var n=Te(te++,7);return rt(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function X(e,t){return ne=8,nt(function(){return e},t)}function St(){for(var e;e=tt.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(ue),e.__H.__h.forEach(we),e.__H.__h=[]}catch(t){e.__H.__h=[],O.__e(t,e.__v)}}O.__b=function(e){I=null,Ge&&Ge(e)},O.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Ze&&Ze(e,t)},O.__r=function(e){ze&&ze(e),te=0;var t=(I=e.__c).__H;t&&(Se===I?(t.__h=[],I.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(ue),t.__h.forEach(we),t.__h=[],te=0)),Se=I},O.diffed=function(e){Qe&&Qe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(tt.push(t)!==1&&Ke===O.requestAnimationFrame||((Ke=O.requestAnimationFrame)||wt)(St)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),Se=I=null},O.__c=function(e,t){t.some(function(n){try{n.__h.forEach(ue),n.__h=n.__h.filter(function(o){return!o.__||we(o)})}catch(o){t.some(function(s){s.__h&&(s.__h=[])}),t=[],O.__e(o,n.__v)}}),Ye&&Ye(e,t)},O.unmount=function(e){Xe&&Xe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(o){try{ue(o)}catch(s){t=s}}),n.__H=void 0,t&&O.__e(t,n.__v))};var et=typeof requestAnimationFrame=="function";function wt(e){var t,n=function(){clearTimeout(o),et&&cancelAnimationFrame(t),setTimeout(e)},o=setTimeout(n,35);et&&(t=requestAnimationFrame(n))}function ue(e){var t=I,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),I=t}function we(e){var t=I;e.__c=e.__(),I=t}function rt(e,t){return!e||e.length!==t.length||t.some(function(n,o){return n!==e[o]})}function ot(e,t){return typeof t=="function"?t(e):t}var Tt="/api/v1",xt=15,Ct=3e3,It=1e4,Ce="ACTION ",Ie="";function x(e,t={}){let n=localStorage.getItem("neoirc_token"),o={"Content-Type":"application/json",...t.headers||{}};n&&(o.Authorization=`Bearer ${n}`);let{signal:s,...a}=t;return fetch(Tt+e,{...a,headers:o,signal:s}).then(async _=>{let u=await _.json().catch(()=>null);if(!_.ok)throw{status:_.status,data:u};return u})}function At(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1})}function xe(e){let t=0;for(let o=0;o{x("/server").then(h=>{h.name&&y(h.name),h.motd&&_(h.motd)}).catch(()=>{}),localStorage.getItem("neoirc_token")&&x("/state").then(h=>e(h.nick,!0)).catch(()=>localStorage.removeItem("neoirc_token")),f.current?.focus()},[]),m("div",{class:"login-screen"},m("div",{class:"login-box"},m("h1",null,u),a&&m("pre",{class:"motd"},a),m("form",{onSubmit:async c=>{c.preventDefault(),s("");try{let h=await x("/session",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("neoirc_token",h.token),e(h.nick)}catch(h){s(h.data?.error||"Connection failed")}}},m("label",null,"Nickname:"),m("input",{ref:f,type:"text",placeholder:"Enter nickname",value:t,onInput:c=>n(c.target.value),maxLength:32,autoFocus:!0}),m("button",{type:"submit"},"Connect")),o&&m("div",{class:"error"},o)))}function $t({msg:e,myNick:t}){let n=At(e.ts);return e.system?m("div",{class:"message system-message"},m("span",{class:"timestamp"},"[",n,"]"),m("span",{class:"system-text"}," * ",e.text)):e.isAction?m("div",{class:"message action-message"},m("span",{class:"timestamp"},"[",n,"]"),m("span",{class:"action-text"}," ","* ",m("span",{style:{color:xe(e.from)}},e.from)," ",e.text)):m("div",{class:"message"},m("span",{class:"timestamp"},"[",n,"]")," ",m("span",{class:"nick",style:{color:xe(e.from)}},"<",e.from,">")," ",m("span",{class:"content"},e.text))}function Et({members:e,onNickClick:t}){let n=[],o=[],s=[];for(let u of e){let y=u.mode||"";y==="o"?n.push(u):y==="v"?o.push(u):s.push(u)}let a=(u,y)=>u.nick.toLowerCase().localeCompare(y.nick.toLowerCase());n.sort(a),o.sort(a),s.sort(a);let _=(u,y)=>m("div",{class:"nick-entry",onClick:()=>t(u.nick),title:u.nick},m("span",{class:"nick-prefix"},y),m("span",{class:"nick-name",style:{color:xe(u.nick)}},u.nick));return m("div",{class:"user-list"},m("div",{class:"user-list-header"},e.length," user",e.length!==1?"s":""),m("div",{class:"user-list-entries"},n.map(u=>_(u,"@")),o.map(u=>_(u,"+")),s.map(u=>_(u,""))))}function Mt(){let[e,t]=$(!1),[n,o]=$(""),[s,a]=$([{type:"server",name:"Server"}]),[_,u]=$(0),[y,f]=$({Server:[]}),[b,c]=$({}),[h,v]=$({}),[C,E]=$({}),[A,g]=$(""),[w,U]=$(!0),[M,z]=$([]),[R,B]=$(-1),j=L(0),V=L(new Set),Q=L(null),P=L(s),W=L(_),re=L(n),Ae=L(),Z=L();H(()=>{P.current=s},[s]),H(()=>{W.current=_},[_]),H(()=>{re.current=n},[n]),H(()=>{let r=s.filter(i=>i.type==="channel").map(i=>i.name);localStorage.setItem("neoirc_channels",JSON.stringify(r))},[s]),H(()=>{let r=s[_];r&&E(i=>({...i,[r.name]:0}))},[_,s]);let N=X((r,i)=>{if(i.id&&V.current.has(i.id))return;i.id&&V.current.add(i.id),f(l=>({...l,[r]:[...l[r]||[],i]}));let d=P.current[W.current];(!d||d.name!==r)&&E(l=>({...l,[r]:(l[r]||0)+1}))},[]),S=X((r,i)=>{f(d=>({...d,[r]:[...d[r]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:i,system:!0}]}))},[]),K=X(r=>{let i=r.replace("#","");x(`/channels/${i}/members`).then(d=>{c(l=>({...l,[r]:d}))}).catch(()=>{})},[]),fe=X(r=>{let i=Array.isArray(r.body)?r.body.join(` +`):"",d={id:r.id,ts:r.ts,from:r.from,to:r.to,command:r.command};switch(r.command){case"PRIVMSG":case"NOTICE":{let l=i,p=!1;Ot(l)&&(l=Nt(l),p=!0);let k={...d,text:l,system:!1,isAction:p},F=r.to;if(F&&F.startsWith("#"))N(F,k);else{let D=r.from===re.current?r.to:r.from;a(pe=>pe.find(Ne=>Ne.type==="dm"&&Ne.name===D)?pe:[...pe,{type:"dm",name:D}]),N(D,k)}break}case"JOIN":{let l=`${r.from} has joined ${r.to}`;r.to&&N(r.to,{...d,text:l,system:!0}),r.to&&r.to.startsWith("#")&&K(r.to);break}case"PART":{let l=i?" ("+i+")":"",p=`${r.from} has parted ${r.to}${l}`;r.to&&N(r.to,{...d,text:p,system:!0}),r.to&&r.to.startsWith("#")&&K(r.to);break}case"QUIT":{let l=i?" ("+i+")":"",p=`${r.from} has quit${l}`;P.current.forEach(k=>{k.type==="channel"&&N(k.name,{...d,text:p,system:!0})});break}case"NICK":{let l=Array.isArray(r.body)?r.body[0]:i,p=`${r.from} is now known as ${l}`;P.current.forEach(k=>{k.type==="channel"&&N(k.name,{...d,text:p,system:!0})}),r.from===re.current&&l&&o(l),P.current.forEach(k=>{k.type==="channel"&&K(k.name)});break}case"TOPIC":{let l=`${r.from} has changed the topic to: ${i}`;r.to&&(N(r.to,{...d,text:l,system:!0}),v(p=>({...p,[r.to]:i})));break}case"353":{if(Array.isArray(r.params)&&r.params.length>=2&&r.body){let l=r.params[1],F=(Array.isArray(r.body)?r.body[0]:String(r.body)).split(/\s+/).filter(Boolean).map(D=>D.startsWith("@")?{nick:D.slice(1),mode:"o"}:D.startsWith("+")?{nick:D.slice(1),mode:"v"}:{nick:D,mode:""});c(D=>({...D,[l]:F}))}break}case"332":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0],p=Array.isArray(r.body)?r.body[0]:i;p&&v(k=>({...k,[l]:p}))}break}case"322":{if(Array.isArray(r.params)&&r.params.length>=2){let l=r.params[0],p=r.params[1];N("Server",{...d,text:`${l} (${p} users): ${(i||"").trim()}`,system:!0})}break}case"323":N("Server",{...d,text:i||"End of channel list",system:!0});break;case"352":{if(Array.isArray(r.params)&&r.params.length>=5){let l=r.params[0],p=r.params[4],k=r.params.length>5?r.params[5]:"";N("Server",{...d,text:`${l} ${p} ${k}`,system:!0})}break}case"315":N("Server",{...d,text:i||"End of /WHO list",system:!0});break;case"311":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0];N("Server",{...d,text:`${l} (${i})`,system:!0})}break}case"312":{if(Array.isArray(r.params)&&r.params.length>=2){let l=r.params[0],p=r.params[1];N("Server",{...d,text:`${l} on ${p}`,system:!0})}break}case"319":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0];N("Server",{...d,text:`${l} is on: ${i}`,system:!0})}break}case"318":N("Server",{...d,text:i||"End of /WHOIS list",system:!0});break;case"375":case"372":case"376":N("Server",{...d,text:i,system:!0});break;default:i&&N("Server",{...d,text:i,system:!0})}},[N,K]);H(()=>{if(!e)return;let r=!0;return(async()=>{for(;r;)try{let d=new AbortController;Q.current=d;let l=await x(`/messages?after=${j.current}&timeout=${xt}`,{signal:d.signal});if(!r)break;if(U(!0),l.messages)for(let p of l.messages)fe(p);l.last_id>j.current&&(j.current=l.last_id)}catch(d){if(!r)break;if(d.name==="AbortError")continue;U(!1),await new Promise(l=>setTimeout(l,Ct))}})(),()=>{r=!1,Q.current?.abort()}},[e,fe]),H(()=>{if(!e)return;let r=s[_];if(!r||r.type!=="channel")return;K(r.name);let i=setInterval(()=>K(r.name),It);return()=>clearInterval(i)},[e,_,s,K]),H(()=>{Ae.current?.scrollIntoView({behavior:"smooth"})},[y,_]),H(()=>{Z.current?.focus()},[_]),H(()=>{let r=i=>{i.key==="/"&&document.activeElement!==Z.current&&!i.ctrlKey&&!i.altKey&&!i.metaKey&&(i.preventDefault(),Z.current?.focus())};return document.addEventListener("keydown",r),Z.current?.focus(),()=>document.removeEventListener("keydown",r)},[]),H(()=>{if(!e)return;let r=s[_];!r||r.type!=="channel"||x("/channels").then(i=>{let d=i.find(l=>l.name===r.name);d&&d.topic&&v(l=>({...l,[r.name]:d.topic}))}).catch(()=>{})},[e,_,s]);let at=X(async(r,i)=>{if(o(r),t(!0),S("Server",`Connected as ${r}`),i)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MOTD"})})}catch{}let d=JSON.parse(localStorage.getItem("neoirc_channels")||"[]");for(let l of d)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:l})}),a(p=>p.find(k=>k.type==="channel"&&k.name===l)?p:[...p,{type:"channel",name:l}])}catch{}},[S]),st=async r=>{if(r){r=r.trim(),r.startsWith("#")||(r="#"+r);try{await x("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:r})}),a(d=>d.find(l=>l.type==="channel"&&l.name===r)?d:[...d,{type:"channel",name:r}]);let i=P.current.length;u(i);try{let d=await x(`/history?target=${encodeURIComponent(r)}&limit=50`);if(Array.isArray(d))for(let l of d)fe(l)}catch{}}catch(i){S("Server",`Failed to join ${r}: ${i.data?.error||"error"}`)}}},Oe=async(r,i)=>{try{await x("/messages",{method:"POST",body:JSON.stringify(i?{command:"PART",to:r,body:[i]}:{command:"PART",to:r})})}catch{}a(d=>d.filter(l=>!(l.type==="channel"&&l.name===r))),u(0)},ct=r=>{let i=s[r];i.type==="channel"?Oe(i.name):i.type==="dm"&&(a(d=>d.filter((l,p)=>p!==r)),_>=r&&u(Math.max(0,_-1)))},de=r=>{if(r===re.current)return;a(d=>d.find(l=>l.type==="dm"&&l.name===r)?d:[...d,{type:"dm",name:r}]);let i=s.findIndex(d=>d.type==="dm"&&d.name===r);u(i>=0?i:s.length)},it=async r=>{let i=r.split(" "),d=i[0].toLowerCase(),l=s[_];switch(d){case"/join":{i[1]?st(i[1]):S("Server","Usage: /join #channel");break}case"/part":{if(l.type==="channel"){let p=i.slice(1).join(" ")||void 0;Oe(l.name,p)}else S("Server","You are not in a channel");break}case"/msg":{if(i[1]&&i.slice(2).join(" ")){let p=i[1],k=i.slice(2).join(" ");try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:p,body:[k]})}),de(p)}catch(F){S("Server",`Message failed: ${F.data?.error||"error"}`)}}else S("Server","Usage: /msg ");break}case"/me":{if(l.type==="server"){S("Server","Cannot use /me in server window");break}let p=i.slice(1).join(" ");if(p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:l.name,body:[Ce+p+Ie]})})}catch(k){S(l.name,`Action failed: ${k.data?.error||"error"}`)}else S("Server","Usage: /me ");break}case"/nick":{if(i[1])try{await x("/messages",{method:"POST",body:JSON.stringify({command:"NICK",body:[i[1]]})})}catch(p){S("Server",`Nick change failed: ${p.data?.error||"error"}`)}else S("Server","Usage: /nick ");break}case"/topic":{if(l.type!=="channel"){S("Server","You are not in a channel");break}let p=i.slice(1).join(" ");if(p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:l.name,body:[p]})})}catch(k){S("Server",`Topic change failed: ${k.data?.error||"error"}`)}else S("Server",`Current topic for ${l.name}: ${h[l.name]||"(none)"}`);break}case"/mode":{if(l.type!=="channel"){S("Server","You are not in a channel");break}let p=i.slice(1);if(p.length>0)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MODE",to:l.name,params:p})})}catch(k){S("Server",`Mode change failed: ${k.data?.error||"error"}`)}else S("Server","Usage: /mode <+/-mode> [params]");break}case"/quit":{let p=i.slice(1).join(" ")||void 0;try{await x("/messages",{method:"POST",body:JSON.stringify(p?{command:"QUIT",body:[p]}:{command:"QUIT"})})}catch{}localStorage.removeItem("neoirc_token"),localStorage.removeItem("neoirc_channels"),window.location.reload();break}case"/motd":{try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MOTD"})})}catch(p){S("Server",`Failed to request MOTD: ${p.data?.error||"error"}`)}break}case"/query":{if(i[1]){let p=i[1];de(p);let k=i.slice(2).join(" ");if(k)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:p,body:[k]})})}catch(F){S("Server",`Message failed: ${F.data?.error||"error"}`)}}else S("Server","Usage: /query [message]");break}case"/list":{try{await x("/messages",{method:"POST",body:JSON.stringify({command:"LIST"})})}catch(p){S("Server",`Failed to list channels: ${p.data?.error||"error"}`)}break}case"/who":{let p=i[1]||(l.type==="channel"?l.name:"");if(p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"WHO",to:p})})}catch(k){S("Server",`WHO failed: ${k.data?.error||"error"}`)}else S("Server","Usage: /who #channel");break}case"/whois":{if(i[1])try{await x("/messages",{method:"POST",body:JSON.stringify({command:"WHOIS",to:i[1]})})}catch(p){S("Server",`WHOIS failed: ${p.data?.error||"error"}`)}else S("Server","Usage: /whois ");break}case"/clear":{let p=l.name;f(k=>({...k,[p]:[]}));break}case"/help":{let p=["Available commands:"," /join #channel \u2014 Join a channel"," /part [reason] \u2014 Part the current channel"," /msg nick message \u2014 Send a private message"," /query nick [message] \u2014 Open a DM tab (optionally send a message)"," /me action \u2014 Send an action"," /nick newnick \u2014 Change your nickname"," /topic [text] \u2014 View or set channel topic"," /mode +/-flags \u2014 Set channel modes"," /motd \u2014 Display the message of the day"," /list \u2014 List all channels"," /who [#channel] \u2014 List users in a channel"," /whois nick \u2014 Show info about a user"," /clear \u2014 Clear messages in the current tab"," /quit [reason] \u2014 Disconnect from server"," /help \u2014 Show this help"];for(let k of p)S("Server",k);break}default:S("Server",`Unknown command: ${d}`)}},_t=async()=>{let r=A.trim();if(!r)return;z(d=>{let l=[...d,r];return l.length>100&&l.shift(),l}),B(-1),g("");let i=s[_];if(i){if(r.startsWith("/")){await it(r);return}if(i.type==="server"){S("Server","Cannot send messages to the server window. Use /join #channel first.");return}try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:i.name,body:[r]})})}catch(d){S(i.name,`Send failed: ${d.data?.error||"error"}`)}}},lt=r=>{if(r.key==="Enter")_t();else if(r.key==="ArrowUp"){if(r.preventDefault(),M.length>0){let i=R===-1?M.length-1:Math.max(0,R-1);B(i),g(M[i])}}else if(r.key==="ArrowDown"&&(r.preventDefault(),R>=0)){let i=R+1;i>=M.length?(B(-1),g("")):(B(i),g(M[i]))}};if(!e)return m(Pt,{onLogin:at});let q=s[_]||s[0],ut=y[q.name]||[],ft=b[q.name]||[],dt=h[q.name]||"";return m("div",{class:"irc-app"},m("div",{class:"tab-bar"},m("div",{class:"tabs"},s.map((r,i)=>m("div",{class:`tab ${i===_?"active":""} ${C[r.name]>0&&i!==_?"has-unread":""}`,onClick:()=>u(i),key:r.name},m("span",{class:"tab-label"},(r.type==="dm",r.name)),C[r.name]>0&&i!==_&&m("span",{class:"unread-count"},"(",C[r.name],")"),r.type!=="server"&&m("span",{class:"tab-close",onClick:d=>{d.stopPropagation(),ct(i)}},"\xD7")))),m("div",{class:"status-area"},!w&&m("span",{class:"status-warn"},"\u25CF Reconnecting"),m("span",{class:"status-nick"},n))),q.type==="channel"&&m("div",{class:"topic-bar"},m("span",{class:"topic-label"},"Topic:")," ",m("span",{class:"topic-text"},dt||"(no topic set)")),m("div",{class:"main-area"},m("div",{class:"messages-panel"},m("div",{class:"messages-scroll"},ut.map(r=>m($t,{msg:r,myNick:n,key:r.id})),m("div",{ref:Ae}))),q.type==="channel"&&m(Et,{members:ft,onNickClick:de})),m("div",{class:"input-line"},m("span",{class:"input-prompt"},"[",n,"]",q.type!=="server"?` ${q.name}`:""," >"),m("input",{ref:Z,type:"text",value:A,onInput:r=>g(r.target.value),onKeyDown:lt,placeholder:q.type==="server"?"Type /help for commands":"",spellCheck:!1,autoComplete:"off"})))}Be(m(Mt,null),document.getElementById("root")); diff --git a/web/dist/style.css b/web/dist/style.css index 0fcd97f..f8b3784 100644 --- a/web/dist/style.css +++ b/web/dist/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 { diff --git a/web/src/app.jsx b/web/src/app.jsx index 8f95398..ea8f357 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -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": @@ -873,6 +967,60 @@ function App() { 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 "); + } + + break; + } case "/clear": { const clearTarget = tab.name; setMessages((prev) => ({ ...prev, [clearTarget]: [] })); @@ -891,6 +1039,9 @@ function App() { " /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", -- 2.49.1