Author SHA1 Message Date
user 8473520eb3 feat(web): overhaul SPA to look like a proper IRC client
check / check (push) Successful in 57s
Major UI overhaul of the embedded web SPA to match traditional IRC client
look and feel:

Layout changes:
- Input bar now spans full width at bottom of window (below user list)
- Removed inline join dialog from tab bar (use /join command instead)
- Nick prefix shown in input bar (e.g. 'alice>')
- Topic bar shows 'Topic:' label with accent color

IRC command support:
- Added /me command (action messages via meta.action flag)
- Added /mode command for channel/user mode changes
- Added /quit command for clean disconnect
- Added /help command listing all available commands
- /part now accepts optional reason message

User list improvements:
- Parse 353 RPL_NAMREPLY to extract user mode prefixes
- Display @nick for ops, +nick for voiced, plain for regular users
- Sort users by mode rank: ops first, then voiced, then regular
- Ops shown in orange, voiced in green, regular in default color
- Extracted UserList into dedicated component

Message display:
- Messages displayed inline with nick on same line (IRC style)
- Action messages (/me) shown as '* nick does something' in purple
- System messages prefixed with '***'
- Uses IRC vocabulary: 'has parted' instead of 'has left'
- Parse 332 RPL_TOPIC for channel topic on join

UX improvements:
- Command history with up/down arrow keys (100 entries)
- Input always visible including on Server tab
- Dark theme with monospace font (JetBrains Mono/Fira Code)
- Hover highlight on messages
- Custom scrollbar styling
- Tab type-based styling (server tab bold)

closes #50
2026-03-07 06:14:21 -08:00
4 changed files with 982 additions and 346 deletions

No files matched your search

+2 -2
View File
@@ -1,2 +1,2 @@
var _e,k,$e,it,q,Ee,Ae,He,Oe,he,fe,de,ct,X={},Re=[],at=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,se=Array.isArray;function F(e,t){for(var n in t)e[n]=t[n];return e}function me(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function y(e,t,n){var r,i,_,c={};for(_ in t)_=="key"?r=t[_]:_=="ref"?i=t[_]:c[_]=t[_];if(arguments.length>2&&(c.children=arguments.length>3?_e.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(_ in e.defaultProps)c[_]===void 0&&(c[_]=e.defaultProps[_]);return ne(e,c,r,i,null)}function ne(e,t,n,r,i){var _={type:e,props:t,key:n,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++$e,__i:-1,__u:0};return i==null&&k.vnode!=null&&k.vnode(_),_}function ie(e){return e.children}function oe(e,t){this.props=e,this.context=t}function z(e,t){if(t==null)return e.__?z(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])!=null&&n.__e!=null)return n.__e;return typeof e.type=="function"?z(e):null}function De(e){var t,n;if((e=e.__)!=null&&e.__c!=null){for(e.__e=e.__c.base=null,t=0;t<e.__k.length;t++)if((n=e.__k[t])!=null&&n.__e!=null){e.__e=e.__c.base=n.__e;break}return De(e)}}function Pe(e){(!e.__d&&(e.__d=!0)&&q.push(e)&&!re.__r++||Ee!=k.debounceRendering)&&((Ee=k.debounceRendering)||Ae)(re)}function re(){for(var e,t,n,r,i,_,c,u=1;q.length;)q.length>u&&q.sort(He),e=q.shift(),u=q.length,e.__d&&(n=void 0,r=void 0,i=(r=(t=e).__v).__e,_=[],c=[],t.__P&&((n=F({},r)).__v=r.__v+1,k.vnode&&k.vnode(n),ve(t.__P,n,r,t.__n,t.__P.namespaceURI,32&r.__u?[i]:null,_,i??z(r),!!(32&r.__u),c),n.__v=r.__v,n.__.__k[n.__i]=n,Fe(_,n,c),r.__e=r.__=null,n.__e!=i&&De(n)));re.__r=0}function Ue(e,t,n,r,i,_,c,u,m,a,v){var s,p,h,w,P,T,g,b=r&&r.__k||Re,A=t.length;for(m=lt(n,t,b,m,A),s=0;s<A;s++)(h=n.__k[s])!=null&&(p=h.__i==-1?X:b[h.__i]||X,h.__i=s,T=ve(e,h,p,i,_,c,u,m,a,v),w=h.__e,h.ref&&p.ref!=h.ref&&(p.ref&&ye(p.ref,null,h),v.push(h.ref,h.__c||w,h)),P==null&&w!=null&&(P=w),(g=!!(4&h.__u))||p.__k===h.__k?m=Le(h,m,e,g):typeof h.type=="function"&&T!==void 0?m=T:w&&(m=w.nextSibling),h.__u&=-7);return n.__e=P,m}function lt(e,t,n,r,i){var _,c,u,m,a,v=n.length,s=v,p=0;for(e.__k=new Array(i),_=0;_<i;_++)(c=t[_])!=null&&typeof c!="boolean"&&typeof c!="function"?(typeof c=="string"||typeof c=="number"||typeof c=="bigint"||c.constructor==String?c=e.__k[_]=ne(null,c,null,null,null):se(c)?c=e.__k[_]=ne(ie,{children:c},null,null,null):c.constructor===void 0&&c.__b>0?c=e.__k[_]=ne(c.type,c.props,c.key,c.ref?c.ref:null,c.__v):e.__k[_]=c,m=_+p,c.__=e,c.__b=e.__b+1,u=null,(a=c.__i=ut(c,n,m,s))!=-1&&(s--,(u=n[a])&&(u.__u|=2)),u==null||u.__v==null?(a==-1&&(i>v?p--:i<v&&p++),typeof c.type!="function"&&(c.__u|=4)):a!=m&&(a==m-1?p--:a==m+1?p++:(a>m?p--:p++,c.__u|=4))):e.__k[_]=null;if(s)for(_=0;_<v;_++)(u=n[_])!=null&&(2&u.__u)==0&&(u.__e==r&&(r=z(u)),je(u,u));return r}function Le(e,t,n,r){var i,_;if(typeof e.type=="function"){for(i=e.__k,_=0;i&&_<i.length;_++)i[_]&&(i[_].__=e,t=Le(i[_],t,n,r));return t}e.__e!=t&&(r&&(t&&e.type&&!t.parentNode&&(t=z(e)),n.insertBefore(e.__e,t||null)),t=e.__e);do t=t&&t.nextSibling;while(t!=null&&t.nodeType==8);return t}function ut(e,t,n,r){var i,_,c,u=e.key,m=e.type,a=t[n],v=a!=null&&(2&a.__u)==0;if(a===null&&u==null||v&&u==a.key&&m==a.type)return n;if(r>(v?1:0)){for(i=n-1,_=n+1;i>=0||_<t.length;)if((a=t[c=i>=0?i--:_++])!=null&&(2&a.__u)==0&&u==a.key&&m==a.type)return c}return-1}function Ne(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||at.test(t)?n:n+"px"}function te(e,t,n,r,i){var _,c;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof r=="string"&&(e.style.cssText=r=""),r)for(t in r)n&&t in n||Ne(e.style,t,"");if(n)for(t in n)r&&n[t]==r[t]||Ne(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")_=t!=(t=t.replace(Oe,"$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+_]=n,n?r?n.u=r.u:(n.u=he,e.addEventListener(t,_?de:fe,_)):e.removeEventListener(t,_?de:fe,_);else{if(i=="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=he++;else if(t.t<n.u)return;return n(k.event?k.event(t):t)}}}function ve(e,t,n,r,i,_,c,u,m,a){var v,s,p,h,w,P,T,g,b,A,D,J,U,Q,V,H,K,N=t.type;if(t.constructor!==void 0)return null;128&n.__u&&(m=!!(32&n.__u),_=[u=t.__e=n.__e]),(v=k.__b)&&v(t);e:if(typeof N=="function")try{if(g=t.props,b="prototype"in N&&N.prototype.render,A=(v=N.contextType)&&r[v.__c],D=v?A?A.props.value:v.__:r,n.__c?T=(s=t.__c=n.__c).__=s.__E:(b?t.__c=s=new N(g,D):(t.__c=s=new oe(g,D),s.constructor=N,s.render=dt),A&&A.sub(s),s.state||(s.state={}),s.__n=r,p=s.Line truncated
`):"",f={id:o.id,ts:o.ts,from:o.from,to:o.to,command:o.command};switch(o.command){case"PRIVMSG":case"NOTICE":{let d={...f,text:l,system:!1},S=o.to;if(S&&S.startsWith("#"))O(S,d);else{let x=o.from===N.current?o.to:o.from;_(Y=>Y.find(xe=>xe.type==="dm"&&xe.name===x)?Y:[...Y,{type:"dm",name:x}]),O(x,d)}break}case"JOIN":{let d=`${o.from} has joined ${o.to}`;o.to&&O(o.to,{...f,text:d,system:!0}),o.to&&o.to.startsWith("#")&&B(o.to);break}case"PART":{let d=l?": "+l:"",S=`${o.from} has left ${o.to}${d}`;o.to&&O(o.to,{...f,text:S,system:!0}),o.to&&o.to.startsWith("#")&&B(o.to);break}case"QUIT":{let d=l?": "+l:"",S=`${o.from} has quit${d}`;H.current.forEach(x=>{x.type==="channel"&&O(x.name,{...f,text:S,system:!0})});break}case"NICK":{let d=Array.isArray(o.body)?o.body[0]:l,S=`${o.from} is now known as ${d}`;H.current.forEach(x=>{x.type==="channel"&&O(x.name,{...f,text:S,system:!0})}),o.from===N.current&&d&&r(d),H.current.forEach(x=>{x.type==="channel"&&B(x.name)});break}case"TOPIC":{let d=`${o.from} set the topic: ${l}`;o.to&&(O(o.to,{...f,text:d,system:!0}),h(S=>({...S,[o.to]:l})));break}case"375":case"372":case"376":O("Server",{...f,text:l,system:!0});break;default:O("Server",{...f,text:l||o.command,system:!0})}},[O,B]);$(()=>{if(!e)return;let o=!0;return(async()=>{for(;o;)try{let f=new AbortController;V.current=f;let d=await M(`/messages?after=${U.current}&timeout=${yt}`,{signal:f.signal});if(!o)break;if(J(!0),d.messages)for(let S of d.messages)ae(S);d.last_id>U.current&&(U.current=d.last_id)}catch(f){if(!o)break;if(f.name==="AbortError")continue;J(!1),await new Promise(d=>setTimeout(d,bt))}})(),()=>{o=!1,V.current?.abort()}},[e,ae]),$(()=>{if(!e)return;let o=i[c];if(!o||o.type!=="channel")return;B(o.name);let l=setInterval(()=>B(o.name),gt);return()=>clearInterval(l)},[e,c,i,B]),$(()=>{L.current?.scrollIntoView({behavior:"smooth"})},[m,c]),$(()=>{Se.current?.focus()},[c]),$(()=>{if(!e)return;let o=i[c];!o||o.type!=="channel"||M("/channels").then(l=>{let f=l.find(d=>d.name===o.name);f&&f.topic&&h(d=>({...d,[o.name]:f.topic}))}).catch(()=>{})},[e,c,i]);let rt=G(async o=>{r(o),t(!0),W("Server",`Connected as ${o}`);let l=JSON.parse(localStorage.getItem("neoirc_channels")||"[]");for(let f of l)try{await M("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:f})}),_(d=>d.find(S=>S.type==="channel"&&S.name===f)?d:[...d,{type:"channel",name:f}])}catch{}},[W]),le=async o=>{if(o){o=o.trim(),o.startsWith("#")||(o="#"+o);try{await M("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:o})}),_(l=>l.find(f=>f.type==="channel"&&f.name===o)?l:[...l,{type:"channel",name:o}]),u(i.length);try{let l=await M(`/history?target=${encodeURIComponent(o)}&limit=50`);if(Array.isArray(l))for(let f of l)ae(f)}catch{}A("")}catch(l){W("Server",`Failed to join ${o}: ${l.data?.error||"error"}`)}}},we=async o=>{try{await M("/messages",{method:"POST",body:JSON.stringify({command:"PART",to:o})})}catch{}_(l=>l.filter(f=>!(f.type==="channel"&&f.name===o))),u(0)},_t=o=>{let l=i[o];l.type==="channel"?we(l.name):l.type==="dm"&&(_(f=>f.filter((d,S)=>S!==o)),c>=o&&u(Math.max(0,c-1)))},Ce=o=>{_(f=>f.find(d=>d.type==="dm"&&d.name===o)?f:[...f,{type:"dm",name:o}]);let l=i.findIndex(f=>f.type==="dm"&&f.name===o);u(l>=0?l:i.length)},Te=async()=>{let o=T.trim();if(!o)return;g("");let l=i[c];if(!(!l||l.type==="server")){if(o.startsWith("/")){let f=o.split(" "),d=f[0].toLowerCase();if(d==="/join"&&f[1]){le(f[1]);return}if(d==="/part"){l.type==="channel"&&we(l.name);return}if(d==="/msg"&&f[1]&&f.slice(2).join(" ")){let S=f[1],x=f.slice(2).join(" ");try{await M("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:S,body:[x]})}),Ce(S)}catch(Y){W("Server",`DM failed: ${Y.data?.error||"error"}`)}return}if(d==="/nick"&&f[1]){try{await M("/messages",{method:"POST",body:JSON.stringify({command:"NICK",body:[f[1]]})})}catch(S){W("Server",`Nick change failed: ${S.data?.error||"error"}`)}return}if(d==="/topic"&&l.type==="channel"){let S=f.slice(1).join(" ");try{await M("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:l.name,body:[S]})})}catch(x){W("Server",`Topic failed: ${x.data?.error||"error"}`)}return}W("Server",`Unknown command: ${d}`);return}try{await M("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:l.name,body:[o]})})}catch(f){W(l.name,`Send failed: ${f.data?.error||"error"}`)}}};if(!e)return y(kt,{onLogin:rt});let j=i[c]||i[0],st=m[j.name]||[],Ie=v[j.name]||[],ue=p[j.name]||"";return y("div",{class:"app"},y("div",{class:"tab-bar"},!D&&y("div",{class:"connection-status"},"\u26A0 Reconnecting..."),i.map((o,l)=>y("div",{class:`tab ${l===c?"active":""}`,onClick:()=>u(l)},o.type==="dm"?`\u2192${o.name}`:o.name,w[o.name]>0&&l!==c&&y("span",{class:"unread-badge"},w[o.name]),o.type!=="server"&&y("span",{class:"close-btn",onClick:f=>{f.stopPropagation(),_t(l)}},"\xD7"))),y("div",{class:"join-dialog"},y("input",{placeholder:"#channel",value:b,onInput:o=>A(o.target.value),onKeyDown:o=>o.key==="Ent Line truncated
var ae,S,He,ht,K,Ae,Re,De,Le,he,de,pe,yt,X={},je=[],vt=/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 ye(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function y(e,t,n){var r,a,s,c={};for(s in t)s=="key"?r=t[s]:s=="ref"?a=t[s]:c[s]=t[s];if(arguments.length>2&&(c.children=arguments.length>3?ae.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,r,a,null)}function oe(e,t,n,r,a){var s={type:e,props:t,key:n,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:a??++He,__i:-1,__u:0};return a==null&&S.vnode!=null&&S.vnode(s),s}function ce(e){return e.children}function re(e,t){this.props=e,this.context=t}function Q(e,t){if(t==null)return e.__?Q(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])!=null&&n.__e!=null)return n.__e;return typeof e.type=="function"?Q(e):null}function We(e){var t,n;if((e=e.__)!=null&&e.__c!=null){for(e.__e=e.__c.base=null,t=0;t<e.__k.length;t++)if((n=e.__k[t])!=null&&n.__e!=null){e.__e=e.__c.base=n.__e;break}return We(e)}}function Oe(e){(!e.__d&&(e.__d=!0)&&K.push(e)&&!se.__r++||Ae!=S.debounceRendering)&&((Ae=S.debounceRendering)||Re)(se)}function se(){for(var e,t,n,r,a,s,c,u=1;K.length;)K.length>u&&K.sort(De),e=K.shift(),u=K.length,e.__d&&(n=void 0,r=void 0,a=(r=(t=e).__v).__e,s=[],c=[],t.__P&&((n=J({},r)).__v=r.__v+1,S.vnode&&S.vnode(n),ve(t.__P,n,r,t.__n,t.__P.namespaceURI,32&r.__u?[a]:null,s,a??Q(r),!!(32&r.__u),c),n.__v=r.__v,n.__.__k[n.__i]=n,Be(s,n,c),r.__e=r.__=null,n.__e!=a&&We(n)));se.__r=0}function Fe(e,t,n,r,a,s,c,u,v,l,b){var _,m,h,T,A,x,g,k=r&&r.__k||je,O=t.length;for(v=bt(n,t,k,v,O),_=0;_<O;_++)(h=n.__k[_])!=null&&(m=h.__i==-1?X:k[h.__i]||X,h.__i=_,x=ve(e,h,m,a,s,c,u,v,l,b),T=h.__e,h.ref&&m.ref!=h.ref&&(m.ref&&be(m.ref,null,h),b.push(h.ref,h.__c||T,h)),A==null&&T!=null&&(A=T),(g=!!(4&h.__u))||m.__k===h.__k?v=Je(h,v,e,g):typeof h.type=="function"&&x!==void 0?v=x:T&&(v=T.nextSibling),h.__u&=-7);return n.__e=A,v}function bt(e,t,n,r,a){var s,c,u,v,l,b=n.length,_=b,m=0;for(e.__k=new Array(a),s=0;s<a;s++)(c=t[s])!=null&&typeof c!="boolean"&&typeof c!="function"?(typeof c=="string"||typeof c=="number"||typeof c=="bigint"||c.constructor==String?c=e.__k[s]=oe(null,c,null,null,null):_e(c)?c=e.__k[s]=oe(ce,{children:c},null,null,null):c.constructor===void 0&&c.__b>0?c=e.__k[s]=oe(c.type,c.props,c.key,c.ref?c.ref:null,c.__v):e.__k[s]=c,v=s+m,c.__=e,c.__b=e.__b+1,u=null,(l=c.__i=kt(c,n,v,_))!=-1&&(_--,(u=n[l])&&(u.__u|=2)),u==null||u.__v==null?(l==-1&&(a>b?m--:a<b&&m++),typeof c.type!="function"&&(c.__u|=4)):l!=v&&(l==v-1?m--:l==v+1?m++:(l>v?m--:m++,c.__u|=4))):e.__k[s]=null;if(_)for(s=0;s<b;s++)(u=n[s])!=null&&(2&u.__u)==0&&(u.__e==r&&(r=Q(u)),qe(u,u));return r}function Je(e,t,n,r){var a,s;if(typeof e.type=="function"){for(a=e.__k,s=0;a&&s<a.length;s++)a[s]&&(a[s].__=e,t=Je(a[s],t,n,r));return t}e.__e!=t&&(r&&(t&&e.type&&!t.parentNode&&(t=Q(e)),n.insertBefore(e.__e,t||null)),t=e.__e);do t=t&&t.nextSibling;while(t!=null&&t.nodeType==8);return t}function kt(e,t,n,r){var a,s,c,u=e.key,v=e.type,l=t[n],b=l!=null&&(2&l.__u)==0;if(l===null&&u==null||b&&u==l.key&&v==l.type)return n;if(r>(b?1:0)){for(a=n-1,s=n+1;a>=0||s<t.length;)if((l=t[c=a>=0?a--:s++])!=null&&(2&l.__u)==0&&u==l.key&&v==l.type)return c}return-1}function $e(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||vt.test(t)?n:n+"px"}function ne(e,t,n,r,a){var s,c;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof r=="string"&&(e.style.cssText=r=""),r)for(t in r)n&&t in n||$e(e.style,t,"");if(n)for(t in n)r&&n[t]==r[t]||$e(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")s=t!=(t=t.replace(Le,"$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?r?n.u=r.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 Ue(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=he++;else if(t.t<n.u)return;return n(S.event?S.event(t):t)}}}function ve(e,t,n,r,a,s,c,u,v,l){var b,_,m,h,T,A,x,g,k,O,j,V,H,te,B,R,q,$=t.type;if(t.constructor!==void 0)return null;128&n.__u&&(v=!!(32&n.__u),s=[u=t.__e=n.__e]),(b=S.__b)&&b(t);e:if(typeof $=="function")try{if(g=t.props,k="prototype"in $&&$.prototype.render,O=(b=$.contextType)&&r[b.__c],j=b?O?O.props.value:b.__:r,n.__c?x=(_=t.__c=n.__c).__=_.__E:(k?t.__c=_=new $(g,j):(t.__c=_=new re(g,j),_.constructor=$,_.render=St),O&&O.sub(_),_.state||(_.state={}),_.__n=r,m=_Line truncated
`):"",f={id:o.id,ts:o.ts,from:o.from,to:o.to,command:o.command},d=o.meta&&o.meta.action;switch(o.command){case"PRIVMSG":case"NOTICE":{let p={...f,text:i,system:!1,action:d},w=o.to;if(w&&w.startsWith("#"))D(w,p);else{let N=o.from===le.current?o.to:o.from;s(fe=>fe.find(Ne=>Ne.type==="dm"&&Ne.name===N)?fe:[...fe,{type:"dm",name:N}]),D(N,p)}break}case"JOIN":{let p=`${o.from} has joined ${o.to}`;o.to&&D(o.to,{...f,text:p,system:!0}),o.to&&o.to.startsWith("#")&&G(o.to);break}case"PART":{let p=i?": "+i:"",w=`${o.from} has parted ${o.to}${p}`;o.to&&D(o.to,{...f,text:w,system:!0}),o.to&&o.to.startsWith("#")&&G(o.to);break}case"QUIT":{let p=i?": "+i:"",w=`${o.from} has quit${p}`;Y.current.forEach(N=>{N.type==="channel"&&D(N.name,{...f,text:w,system:!0})});break}case"NICK":{let p=Array.isArray(o.body)?o.body[0]:i,w=`${o.from} is now known as ${p}`;Y.current.forEach(N=>{N.type==="channel"&&D(N.name,{...f,text:w,system:!0})}),o.from===le.current&&p&&r(p),Y.current.forEach(N=>{N.type==="channel"&&G(N.name)});break}case"TOPIC":{let p=`${o.from} set the topic: ${i}`;o.to&&(D(o.to,{...f,text:p,system:!0}),A(w=>({...w,[o.to]:i})));break}case"332":{let p=Array.isArray(o.params)&&o.params.length>0?o.params[0]:o.to;p&&A(w=>({...w,[p]:i}));break}case"353":{let p=Array.isArray(o.params)&&o.params.length>1?o.params[1]:o.to;p&&i&&Ie(p,i);break}case"366":break;case"375":case"372":case"376":D("Server",{...f,text:i,system:!0});break;default:D("Server",{...f,text:i||o.command,system:!0})}},[D,G,Ie]);U(()=>{if(!e)return;let o=!0;return(async()=>{for(;o;)try{let f=new AbortController;W.current=f;let d=await M(`/messages?after=${q.current}&timeout=${It}`,{signal:f.signal});if(!o)break;if(V(!0),d.messages)for(let p of d.messages)ue(p);d.last_id>q.current&&(q.current=d.last_id)}catch(f){if(!o)break;if(f.name==="AbortError")continue;V(!1),await new Promise(d=>setTimeout(d,Pt))}})(),()=>{o=!1,W.current?.abort()}},[e,ue]),U(()=>{if(!e)return;let o=a[c];if(!o||o.type!=="channel")return;G(o.name);let i=setInterval(()=>G(o.name),Et);return()=>clearInterval(i)},[e,c,a,G]),U(()=>{Te.current?.scrollIntoView({behavior:"smooth"})},[v,c]),U(()=>{xe.current?.focus()},[c]),U(()=>{if(!e)return;let o=a[c];!o||o.type!=="channel"||M("/channels").then(i=>{let f=i.find(d=>d.name===o.name);f&&f.topic&&A(d=>({...d,[o.name]:f.topic}))}).catch(()=>{})},[e,c,a]);let _t=z(async o=>{r(o),t(!0),P("Server",`Connected as ${o}`);let i=JSON.parse(localStorage.getItem("neoirc_channels")||"[]");for(let f of i)try{await M("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:f})}),s(d=>d.find(p=>p.type==="channel"&&p.name===f)?d:[...d,{type:"channel",name:f}])}catch{}},[P]),ct=async o=>{if(o){o=o.trim(),o.startsWith("#")||(o="#"+o);try{await M("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:o})}),s(i=>i.find(f=>f.type==="channel"&&f.name===o)?i:[...i,{type:"channel",name:o}]),u(a.length);try{let i=await M(`/history?target=${encodeURIComponent(o)}&limit=50`);if(Array.isArray(i))for(let f of i)ue(f)}catch{}}catch(i){P("Server",`Failed to join ${o}: ${i.data?.error||"error"}`)}}},Pe=async(o,i)=>{try{let f={command:"PART",to:o};i&&(f.body=[i]),await M("/messages",{method:"POST",body:JSON.stringify(f)})}catch{}s(f=>f.filter(d=>!(d.type==="channel"&&d.name===o))),u(0)},it=o=>{let i=a[o];i.type==="channel"?Pe(i.name):i.type==="dm"&&(s(f=>f.filter((d,p)=>p!==o)),c>=o&&u(Math.max(0,c-1)))},Ee=o=>{s(f=>f.find(d=>d.type==="dm"&&d.name===o)?f:[...f,{type:"dm",name:o}]);let i=a.findIndex(f=>f.type==="dm"&&f.name===o);u(i>=0?i:a.length)},lt=async o=>{let i=o.split(" "),f=i[0].toLowerCase(),d=a[c];switch(f){case"/join":i[1]?ct(i[1]):P(d?.name||"Server","Usage: /join #channel");break;case"/part":{if(d?.type==="channel"){let p=i.slice(1).join(" ")||"";Pe(d.name,p)}else P(d?.name||"Server","Not in a channel");break}case"/msg":if(i[1]&&i.slice(2).join(" ")){let p=i[1],w=i.slice(2).join(" ");try{await M("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:p,body:[w]})}),Ee(p)}catch(N){P("Server",`DM failed: ${N.data?.error||"error"}`)}}else P(d?.name||"Server","Usage: /msg <nick> <message>");break;case"/me":if(d&&d.type!=="server"){let p=i.slice(1).join(" ");if(p)try{await M("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:d.name,body:[p],meta:{action:!0}})})}catch(w){P(d.name,`Action failed: ${w.data?.error||"error"}`)}else P(d.name,"Usage: /me <action>")}break;case"/nick":if(i[1])try{await M("/messages",{method:"POST",body:JSON.stringify({command:"NICK",body:[i[1]]})})}catch(p){P("Server",`Nick change failed: ${p.data?.error||"error"}`)}else P(d?.name||"Server","Usage: /nick <newnick>");break;case"/topic":if(d?.type==="channel"){let p=i.slice(1).join(" ");try{await M("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:d.name,body:[p]})})}catch(w){P(d.name,`Topic failed: ${w.data?.error||"error"}`)}}else P(d?.name||"Server","Not in a channel");break;case"/mode":if(d?.type==="channel"&&i.length>=2){let p=i[1].star Line truncated
+216 -102
View File
@@ -1,28 +1,40 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #1a1a2e;
--bg-secondary: #16213e;
--bg-input: #0f3460;
--text: #e0e0e0;
--text-muted: #888;
--accent: #e94560;
--accent2: #0f3460;
--border: #2a2a4a;
--nick: #53a8b6;
--timestamp: #666;
--tab-active: #e94560;
--tab-bg: #16213e;
--tab-hover: #1a1a3e;
--topic-bg: #121a30;
--unread-bg: #e94560;
--warn: #f0ad4e;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
:root {
--bg: #0a0e14;
--bg-secondary: #0d1117;
--bg-input: #161b22;
--bg-highlight: #1a2030;
--text: #c9d1d9;
--text-muted: #6e7681;
--accent: #58a6ff;
--accent-dim: #1f6feb;
--border: #21262d;
--nick: #79c0ff;
--timestamp: #484f58;
--tab-active: #58a6ff;
--tab-bg: #0d1117;
--tab-hover: #161b22;
--topic-bg: #0d1117;
--unread-bg: #da3633;
--warn: #d29922;
--op-color: #f0883e;
--voice-color: #3fb950;
--action-color: #bc8cff;
--system-color: #484f58;
}
html,
body,
#root {
height: 100%;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New',
Courier, monospace;
font-size: 13px;
background: var(--bg);
color: var(--text);
}
@@ -57,15 +69,19 @@ html, body, #root {
padding: 10px 24px;
font-size: 16px;
font-family: inherit;
background: var(--accent);
background: var(--accent-dim);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
}
.login-screen button:hover {
background: var(--accent);
}
.login-screen .error {
color: var(--accent);
color: var(--unread-bg);
}
.login-screen .motd {
@@ -89,36 +105,63 @@ html, body, #root {
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0;
align-items: center;
align-items: stretch;
min-height: 32px;
}
.tab-bar::-webkit-scrollbar {
height: 2px;
}
.tab-bar::-webkit-scrollbar-thumb {
background: var(--border);
}
.tab {
padding: 8px 16px;
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
color: var(--text-muted);
user-select: none;
position: relative;
font-size: 12px;
gap: 6px;
transition:
background 0.1s,
color 0.1s;
}
.tab:hover {
background: var(--tab-hover);
color: var(--text);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--tab-active);
background: var(--bg-highlight);
}
.tab.server {
font-weight: bold;
}
.tab .tab-name {
overflow: hidden;
text-overflow: ellipsis;
}
.tab .close-btn {
margin-left: 8px;
color: var(--text-muted);
font-size: 12px;
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.tab .close-btn:hover {
color: var(--accent);
color: var(--unread-bg);
}
.tab .unread-badge {
@@ -127,19 +170,22 @@ html, body, #root {
color: white;
font-size: 10px;
font-weight: bold;
padding: 1px 5px;
padding: 0 5px;
border-radius: 8px;
margin-left: 6px;
min-width: 16px;
text-align: center;
line-height: 16px;
flex-shrink: 0;
}
/* Connection status */
.connection-status {
padding: 4px 12px;
display: flex;
align-items: center;
padding: 0 12px;
background: var(--warn);
color: #1a1a2e;
font-size: 12px;
color: var(--bg);
font-size: 11px;
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
@@ -147,7 +193,7 @@ html, body, #root {
/* Topic bar */
.topic-bar {
padding: 6px 12px;
padding: 4px 12px;
background: var(--topic-bg);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
@@ -158,6 +204,11 @@ html, body, #root {
flex-shrink: 0;
}
.topic-bar .topic-label {
color: var(--accent);
font-weight: bold;
}
/* Content area */
.content {
display: flex;
@@ -171,147 +222,210 @@ html, body, #root {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
padding: 4px 0;
}
.messages::-webkit-scrollbar {
width: 6px;
}
.messages::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.message {
padding: 2px 0;
line-height: 1.4;
padding: 1px 12px;
line-height: 1.5;
word-wrap: break-word;
}
.message:hover {
background: var(--bg-highlight);
}
.message .timestamp {
color: var(--timestamp);
font-size: 12px;
margin-right: 8px;
font-size: 11px;
margin-right: 6px;
}
.message .nick {
color: var(--nick);
font-weight: bold;
margin-right: 8px;
margin-right: 6px;
}
.message .nick::before { content: '<'; }
.message .nick::after { content: '>'; }
.message .nick::before {
content: '<';
color: var(--text-muted);
}
.message .nick::after {
content: '>';
color: var(--text-muted);
}
.message.system {
color: var(--text-muted);
color: var(--system-color);
font-style: italic;
}
.message.system .nick {
color: var(--text-muted);
.message.system .timestamp {
color: var(--timestamp);
}
.message.system .nick::before,
.message.system .nick::after { content: ''; }
.message.system .content::before {
content: '*** ';
}
/* Input */
.message.action {
color: var(--action-color);
}
.message.action .timestamp {
color: var(--timestamp);
}
.message.action .action-nick {
font-weight: bold;
}
/* Input bar — full width at bottom */
.input-bar {
display: flex;
align-items: center;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.input-bar .input-nick {
padding: 0 8px 0 12px;
color: var(--accent);
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
font-size: 13px;
}
.input-bar .input-nick::after {
content: '>';
color: var(--text-muted);
margin-left: 1px;
}
.input-bar input {
flex: 1;
padding: 10px 12px;
padding: 8px 8px;
font-family: inherit;
font-size: 14px;
background: var(--bg-input);
font-size: 13px;
background: transparent;
border: none;
color: var(--text);
outline: none;
}
.input-bar button {
padding: 10px 16px;
font-family: inherit;
background: var(--accent);
border: none;
color: white;
cursor: pointer;
.input-bar input::placeholder {
color: var(--text-muted);
}
/* User list */
.user-list {
width: 160px;
width: 170px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.user-list h3 {
.user-list-header {
padding: 6px 10px;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
margin-bottom: 8px;
letter-spacing: 1px;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
font-weight: bold;
flex-shrink: 0;
}
.user-list-entries {
overflow-y: auto;
flex: 1;
padding: 4px 0;
}
.user-list-entries::-webkit-scrollbar {
width: 4px;
}
.user-list-entries::-webkit-scrollbar-thumb {
background: var(--border);
}
.user-list .user {
padding: 3px 4px;
color: var(--nick);
font-size: 13px;
padding: 2px 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.user-list .user:hover {
background: var(--tab-hover);
}
/* Server tab */
.user-list .user.op {
color: var(--op-color);
}
.user-list .user.voice {
color: var(--voice-color);
}
/* Server tab messages */
.server-messages {
color: var(--text-muted);
padding: 12px;
padding: 8px 12px;
white-space: pre-wrap;
overflow-y: auto;
flex: 1;
}
/* Channel join dialog */
.join-dialog {
padding: 12px;
display: flex;
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
margin-left: auto;
.server-messages .message {
padding: 1px 0;
}
.join-dialog input {
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 3px;
width: 200px;
}
.join-dialog button {
padding: 6px 14px;
font-family: inherit;
font-size: 13px;
background: var(--accent2);
border: none;
color: var(--text);
border-radius: 3px;
cursor: pointer;
.server-messages .message:hover {
background: var(--bg-highlight);
}
/* Responsive */
@media (max-width: 600px) {
.user-list { display: none; }
.tab { padding: 6px 10px; font-size: 13px; }
.user-list {
display: none;
}
.tab {
padding: 5px 8px;
font-size: 11px;
}
.input-bar .input-nick {
padding-left: 8px;
font-size: 12px;
}
.input-bar input {
font-size: 12px;
}
}
+548 -140
View File
@@ -5,13 +5,17 @@ const API = '/api/v1';
const POLL_TIMEOUT = 15;
const RECONNECT_DELAY = 3000;
const MEMBER_REFRESH_INTERVAL = 10000;
const MAX_HISTORY = 100;
function api(path, opts = {}) {
const token = localStorage.getItem('neoirc_token');
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
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 => {
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
const data = await r.json().catch(() => null);
if (!r.ok) throw { status: r.status, data };
return data;
@@ -20,12 +24,17 @@ function api(path, opts = {}) {
function formatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return d.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function nickColor(nick) {
let h = 0;
for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h);
for (let i = 0; i < nick.length; i++)
h = nick.charCodeAt(i) + ((h << 5) - h);
const hue = Math.abs(h) % 360;
return `hsl(${hue}, 70%, 65%)`;
}
@@ -38,13 +47,17 @@ function LoginScreen({ onLogin }) {
const inputRef = useRef();
useEffect(() => {
api('/server').then(s => {
api('/server')
.then((s) => {
if (s.name) setServerName(s.name);
if (s.motd) setMotd(s.motd);
}).catch(() => {});
})
.catch(() => {});
const saved = localStorage.getItem('neoirc_token');
if (saved) {
api('/state').then(u => onLogin(u.nick)).catch(() => localStorage.removeItem('neoirc_token'));
api('/state')
.then((u) => onLogin(u.nick))
.catch(() => localStorage.removeItem('neoirc_token'));
}
inputRef.current?.focus();
}, []);
@@ -55,7 +68,7 @@ function LoginScreen({ onLogin }) {
try {
const res = await api('/session', {
method: 'POST',
body: JSON.stringify({ nick: nick.trim() })
body: JSON.stringify({ nick: nick.trim() }),
});
localStorage.setItem('neoirc_token', res.token);
onLogin(res.nick);
@@ -74,7 +87,7 @@ function LoginScreen({ onLogin }) {
type="text"
placeholder="Choose a nickname..."
value={nick}
onInput={e => setNick(e.target.value)}
onInput={(e) => setNick(e.target.value)}
maxLength={32}
autoFocus
/>
@@ -94,15 +107,64 @@ function Message({ msg }) {
</div>
);
}
if (msg.action) {
return (
<div class="message action">
<span class="timestamp">{formatTime(msg.ts)}</span>
<span class="content">
{'* '}
<span class="action-nick" style={{ color: nickColor(msg.from) }}>
{msg.from}
</span>{' '}
{msg.text}
</span>
</div>
);
}
return (
<div class="message">
<span class="timestamp">{formatTime(msg.ts)}</span>
<span class="nick" style={{ color: nickColor(msg.from) }}>{msg.from}</span>
<span class="nick" style={{ color: nickColor(msg.from) }}>
{msg.from}
</span>
<span class="content">{msg.text}</span>
</div>
);
}
function UserList({ members, userModes, onDM }) {
const sorted = [...members].sort((a, b) => {
const modeA = userModes[a.nick] || '';
const modeB = userModes[b.nick] || '';
const rankA = modeA === 'o' ? 0 : modeA === 'v' ? 1 : 2;
const rankB = modeB === 'o' ? 0 : modeB === 'v' ? 1 : 2;
if (rankA !== rankB) return rankA - rankB;
return a.nick.localeCompare(b.nick);
});
return (
<div class="user-list">
<div class="user-list-header">Users ({members.length})</div>
<div class="user-list-entries">
{sorted.map((u) => {
const mode = userModes[u.nick] || '';
const prefix = mode === 'o' ? '@' : mode === 'v' ? '+' : '';
return (
<div
class={`user ${mode === 'o' ? 'op' : mode === 'v' ? 'voice' : ''}`}
onClick={() => onDM(u.nick)}
title={u.nick}
>
{prefix}
{u.nick}
</div>
);
})}
</div>
</div>
);
}
function App() {
const [loggedIn, setLoggedIn] = useState(false);
const [nick, setNick] = useState('');
@@ -110,11 +172,13 @@ function App() {
const [activeTab, setActiveTab] = useState(0);
const [messages, setMessages] = useState({ Server: [] });
const [members, setMembers] = useState({});
const [userModes, setUserModes] = useState({});
const [topics, setTopics] = useState({});
const [unread, setUnread] = useState({});
const [input, setInput] = useState('');
const [joinInput, setJoinInput] = useState('');
const [connected, setConnected] = useState(true);
const [cmdHistory, setCmdHistory] = useState([]);
const [historyIdx, setHistoryIdx] = useState(-1);
const lastIdRef = useRef(0);
const seenIdsRef = useRef(new Set());
@@ -125,69 +189,115 @@ function App() {
const messagesEndRef = useRef();
const inputRef = useRef();
useEffect(() => { tabsRef.current = tabs; }, [tabs]);
useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]);
useEffect(() => { nickRef.current = nick; }, [nick]);
useEffect(() => {
tabsRef.current = tabs;
}, [tabs]);
useEffect(() => {
activeTabRef.current = activeTab;
}, [activeTab]);
useEffect(() => {
nickRef.current = nick;
}, [nick]);
// Persist joined channels
useEffect(() => {
const channels = tabs.filter(t => t.type === 'channel').map(t => t.name);
const channels = tabs.filter((t) => t.type === 'channel').map((t) => t.name);
localStorage.setItem('neoirc_channels', JSON.stringify(channels));
}, [tabs]);
// Clear unread on tab switch
useEffect(() => {
const tab = tabs[activeTab];
if (tab) setUnread(prev => ({ ...prev, [tab.name]: 0 }));
if (tab) setUnread((prev) => ({ ...prev, [tab.name]: 0 }));
}, [activeTab, tabs]);
const addMessage = useCallback((tabName, msg) => {
if (msg.id && seenIdsRef.current.has(msg.id)) return;
if (msg.id) seenIdsRef.current.add(msg.id);
setMessages(prev => ({
setMessages((prev) => ({
...prev,
[tabName]: [...(prev[tabName] || []), msg]
[tabName]: [...(prev[tabName] || []), msg],
}));
const currentTab = tabsRef.current[activeTabRef.current];
if (!currentTab || currentTab.name !== tabName) {
setUnread(prev => ({ ...prev, [tabName]: (prev[tabName] || 0) + 1 }));
setUnread((prev) => ({
...prev,
[tabName]: (prev[tabName] || 0) + 1,
}));
}
}, []);
const addSystemMessage = useCallback((tabName, text) => {
setMessages(prev => ({
setMessages((prev) => ({
...prev,
[tabName]: [...(prev[tabName] || []), {
[tabName]: [
...(prev[tabName] || []),
{
id: 'sys-' + Date.now() + '-' + Math.random(),
ts: new Date().toISOString(),
text,
system: true
}]
system: true,
},
],
}));
}, []);
const refreshMembers = useCallback((channel) => {
const chName = channel.replace('#', '');
api(`/channels/${chName}/members`).then(m => {
setMembers(prev => ({ ...prev, [channel]: m }));
}).catch(() => {});
api(`/channels/${chName}/members`)
.then((m) => {
setMembers((prev) => ({ ...prev, [channel]: m }));
})
.catch(() => {});
}, []);
const processMessage = useCallback((msg) => {
const parseNamesReply = useCallback((channel, namesStr) => {
// Parse 353 RPL_NAMREPLY body: "@op1 alice bob +voiced1"
const nicks = namesStr.trim().split(/\s+/);
const modes = {};
for (const entry of nicks) {
if (entry.startsWith('@')) {
modes[entry.slice(1)] = 'o';
} else if (entry.startsWith('+')) {
modes[entry.slice(1)] = 'v';
}
// plain nicks have no mode entry
}
setUserModes((prev) => ({
...prev,
[channel]: { ...(prev[channel] || {}), ...modes },
}));
}, []);
const processMessage = useCallback(
(msg) => {
const body = Array.isArray(msg.body) ? msg.body.join('\n') : '';
const base = { id: msg.id, ts: msg.ts, from: msg.from, to: msg.to, command: msg.command };
const base = {
id: msg.id,
ts: msg.ts,
from: msg.from,
to: msg.to,
command: msg.command,
};
const isAction = msg.meta && msg.meta.action;
switch (msg.command) {
case 'PRIVMSG':
case 'NOTICE': {
const parsed = { ...base, text: body, system: false };
const parsed = {
...base,
text: body,
system: false,
action: isAction,
};
const target = msg.to;
if (target && target.startsWith('#')) {
addMessage(target, parsed);
} else {
const dmPeer = msg.from === nickRef.current ? msg.to : msg.from;
setTabs(prev => {
if (!prev.find(t => t.type === 'dm' && t.name === dmPeer)) {
const dmPeer =
msg.from === nickRef.current ? msg.to : msg.from;
setTabs((prev) => {
if (!prev.find((t) => t.type === 'dm' && t.name === dmPeer)) {
return [...prev, { type: 'dm', name: dmPeer }];
}
return prev;
@@ -198,21 +308,23 @@ function App() {
}
case 'JOIN': {
const text = `${msg.from} has joined ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to)
addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to);
break;
}
case 'PART': {
const reason = body ? ': ' + body : '';
const text = `${msg.from} has left ${msg.to}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
const text = `${msg.from} has parted ${msg.to}${reason}`;
if (msg.to)
addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to);
break;
}
case 'QUIT': {
const reason = body ? ': ' + body : '';
const text = `${msg.from} has quit${reason}`;
tabsRef.current.forEach(tab => {
tabsRef.current.forEach((tab) => {
if (tab.type === 'channel') {
addMessage(tab.name, { ...base, text, system: true });
}
@@ -222,14 +334,13 @@ function App() {
case 'NICK': {
const newNick = Array.isArray(msg.body) ? msg.body[0] : body;
const text = `${msg.from} is now known as ${newNick}`;
tabsRef.current.forEach(tab => {
tabsRef.current.forEach((tab) => {
if (tab.type === 'channel') {
addMessage(tab.name, { ...base, text, system: true });
}
});
if (msg.from === nickRef.current && newNick) setNick(newNick);
// Refresh members in all channels
tabsRef.current.forEach(tab => {
tabsRef.current.forEach((tab) => {
if (tab.type === 'channel') refreshMembers(tab.name);
});
break;
@@ -238,19 +349,50 @@ function App() {
const text = `${msg.from} set the topic: ${body}`;
if (msg.to) {
addMessage(msg.to, { ...base, text, system: true });
setTopics(prev => ({ ...prev, [msg.to]: body }));
setTopics((prev) => ({ ...prev, [msg.to]: body }));
}
break;
}
case '332': {
// RPL_TOPIC — channel topic on join
const channel =
Array.isArray(msg.params) && msg.params.length > 0
? msg.params[0]
: msg.to;
if (channel) {
setTopics((prev) => ({ ...prev, [channel]: body }));
}
break;
}
case '353': {
// RPL_NAMREPLY — parse user modes from names list
const channel =
Array.isArray(msg.params) && msg.params.length > 1
? msg.params[1]
: msg.to;
if (channel && body) {
parseNamesReply(channel, body);
}
break;
}
case '366':
// RPL_ENDOFNAMES — ignore
break;
case '375':
case '372':
case '376':
addMessage('Server', { ...base, text: body, system: true });
break;
default:
addMessage('Server', { ...base, text: body || msg.command, system: true });
addMessage('Server', {
...base,
text: body || msg.command,
system: true,
});
}
}, [addMessage, refreshMembers]);
},
[addMessage, refreshMembers, parseNamesReply],
);
// Long-poll loop
useEffect(() => {
@@ -264,7 +406,7 @@ function App() {
pollAbortRef.current = controller;
const result = await api(
`/messages?after=${lastIdRef.current}&timeout=${POLL_TIMEOUT}`,
{ signal: controller.signal }
{ signal: controller.signal },
);
if (!alive) break;
setConnected(true);
@@ -278,13 +420,16 @@ function App() {
if (!alive) break;
if (err.name === 'AbortError') continue;
setConnected(false);
await new Promise(r => setTimeout(r, RECONNECT_DELAY));
await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
}
}
};
poll();
return () => { alive = false; pollAbortRef.current?.abort(); };
return () => {
alive = false;
pollAbortRef.current?.abort();
};
}, [loggedIn, processMessage]);
// Refresh members for active channel
@@ -293,7 +438,10 @@ function App() {
const tab = tabs[activeTab];
if (!tab || tab.type !== 'channel') return;
refreshMembers(tab.name);
const iv = setInterval(() => refreshMembers(tab.name), MEMBER_REFRESH_INTERVAL);
const iv = setInterval(
() => refreshMembers(tab.name),
MEMBER_REFRESH_INTERVAL,
);
return () => clearInterval(iv);
}, [loggedIn, activeTab, tabs, refreshMembers]);
@@ -303,71 +451,100 @@ function App() {
}, [messages, activeTab]);
// Focus input on tab change
useEffect(() => { inputRef.current?.focus(); }, [activeTab]);
useEffect(() => {
inputRef.current?.focus();
}, [activeTab]);
// Fetch topic for active channel
useEffect(() => {
if (!loggedIn) return;
const tab = tabs[activeTab];
if (!tab || tab.type !== 'channel') return;
api('/channels').then(channels => {
const ch = channels.find(c => c.name === tab.name);
if (ch && ch.topic) setTopics(prev => ({ ...prev, [tab.name]: ch.topic }));
}).catch(() => {});
api('/channels')
.then((channels) => {
const ch = channels.find((c) => c.name === tab.name);
if (ch && ch.topic)
setTopics((prev) => ({ ...prev, [tab.name]: ch.topic }));
})
.catch(() => {});
}, [loggedIn, activeTab, tabs]);
const onLogin = useCallback(async (userNick) => {
const onLogin = useCallback(
async (userNick) => {
setNick(userNick);
setLoggedIn(true);
addSystemMessage('Server', `Connected as ${userNick}`);
// Auto-rejoin saved channels
const saved = JSON.parse(localStorage.getItem('neoirc_channels') || '[]');
const saved = JSON.parse(
localStorage.getItem('neoirc_channels') || '[]',
);
for (const ch of saved) {
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) });
setTabs(prev => {
if (prev.find(t => t.type === 'channel' && t.name === ch)) return prev;
await api('/messages', {
method: 'POST',
body: JSON.stringify({ command: 'JOIN', to: ch }),
});
setTabs((prev) => {
if (prev.find((t) => t.type === 'channel' && t.name === ch))
return prev;
return [...prev, { type: 'channel', name: ch }];
});
} catch (e) {
// Channel may not exist anymore
}
}
}, [addSystemMessage]);
},
[addSystemMessage],
);
const joinChannel = async (name) => {
if (!name) return;
name = name.trim();
if (!name.startsWith('#')) name = '#' + name;
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: name }) });
setTabs(prev => {
if (prev.find(t => t.type === 'channel' && t.name === name)) return prev;
await api('/messages', {
method: 'POST',
body: JSON.stringify({ command: 'JOIN', to: name }),
});
setTabs((prev) => {
if (prev.find((t) => t.type === 'channel' && t.name === name))
return prev;
return [...prev, { type: 'channel', name }];
});
setActiveTab(tabs.length);
// Load history
try {
const hist = await api(`/history?target=${encodeURIComponent(name)}&limit=50`);
const hist = await api(
`/history?target=${encodeURIComponent(name)}&limit=50`,
);
if (Array.isArray(hist)) {
for (const m of hist) processMessage(m);
}
} catch (e) {
// History may be empty
}
setJoinInput('');
} catch (err) {
addSystemMessage('Server', `Failed to join ${name}: ${err.data?.error || 'error'}`);
addSystemMessage(
'Server',
`Failed to join ${name}: ${err.data?.error || 'error'}`,
);
}
};
const partChannel = async (name) => {
const partChannel = async (name, reason) => {
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PART', to: name }) });
const payload = { command: 'PART', to: name };
if (reason) payload.body = [reason];
await api('/messages', {
method: 'POST',
body: JSON.stringify(payload),
});
} catch (e) {
// Ignore
}
setTabs(prev => prev.filter(t => !(t.type === 'channel' && t.name === name)));
setTabs((prev) =>
prev.filter((t) => !(t.type === 'channel' && t.name === name)),
);
setActiveTab(0);
};
@@ -376,68 +553,284 @@ function App() {
if (tab.type === 'channel') {
partChannel(tab.name);
} else if (tab.type === 'dm') {
setTabs(prev => prev.filter((_, i) => i !== idx));
setTabs((prev) => prev.filter((_, i) => i !== idx));
if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1));
}
};
const openDM = (targetNick) => {
setTabs(prev => {
if (prev.find(t => t.type === 'dm' && t.name === targetNick)) return prev;
setTabs((prev) => {
if (prev.find((t) => t.type === 'dm' && t.name === targetNick))
return prev;
return [...prev, { type: 'dm', name: targetNick }];
});
const idx = tabs.findIndex(t => t.type === 'dm' && t.name === targetNick);
const idx = tabs.findIndex(
(t) => t.type === 'dm' && t.name === targetNick,
);
setActiveTab(idx >= 0 ? idx : tabs.length);
};
const handleCommand = async (text) => {
const parts = text.split(' ');
const cmd = parts[0].toLowerCase();
const tab = tabs[activeTab];
switch (cmd) {
case '/join':
if (parts[1]) joinChannel(parts[1]);
else
addSystemMessage(
tab?.name || 'Server',
'Usage: /join #channel',
);
break;
case '/part': {
if (tab?.type === 'channel') {
const reason = parts.slice(1).join(' ') || '';
partChannel(tab.name, reason);
} else {
addSystemMessage(
tab?.name || 'Server',
'Not in a channel',
);
}
break;
}
case '/msg':
if (parts[1] && parts.slice(2).join(' ')) {
const target = parts[1];
const msgBody = parts.slice(2).join(' ');
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'PRIVMSG',
to: target,
body: [msgBody],
}),
});
openDM(target);
} catch (err) {
addSystemMessage(
'Server',
`DM failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(
tab?.name || 'Server',
'Usage: /msg <nick> <message>',
);
}
break;
case '/me':
if (tab && tab.type !== 'server') {
const actionText = parts.slice(1).join(' ');
if (actionText) {
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'PRIVMSG',
to: tab.name,
body: [actionText],
meta: { action: true },
}),
});
} catch (err) {
addSystemMessage(
tab.name,
`Action failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(tab.name, 'Usage: /me <action>');
}
}
break;
case '/nick':
if (parts[1]) {
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'NICK',
body: [parts[1]],
}),
});
} catch (err) {
addSystemMessage(
'Server',
`Nick change failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(
tab?.name || 'Server',
'Usage: /nick <newnick>',
);
}
break;
case '/topic':
if (tab?.type === 'channel') {
const topicText = parts.slice(1).join(' ');
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'TOPIC',
to: tab.name,
body: [topicText],
}),
});
} catch (err) {
addSystemMessage(
tab.name,
`Topic failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(
tab?.name || 'Server',
'Not in a channel',
);
}
break;
case '/mode':
if (tab?.type === 'channel' && parts.length >= 2) {
const modeTarget = parts[1].startsWith('#')
? parts[1]
: tab.name;
const modeParams = parts[1].startsWith('#')
? parts.slice(2)
: parts.slice(1);
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'MODE',
to: modeTarget,
params: modeParams,
}),
});
} catch (err) {
addSystemMessage(
tab.name,
`Mode failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(
tab?.name || 'Server',
'Usage: /mode [#channel] <+/-mode> [nick]',
);
}
break;
case '/quit': {
const quitMsg = parts.slice(1).join(' ');
try {
const payload = { command: 'QUIT' };
if (quitMsg) payload.body = [quitMsg];
await api('/messages', {
method: 'POST',
body: JSON.stringify(payload),
});
} catch (e) {
// Ignore
}
localStorage.removeItem('neoirc_token');
window.location.reload();
break;
}
case '/help':
addSystemMessage(
tab?.name || 'Server',
'Commands: /join #channel, /part [reason], /msg nick message, ' +
'/me action, /nick newnick, /topic text, /mode [#channel] +/-mode [nick], ' +
'/quit [reason], /help',
);
break;
default:
addSystemMessage(
tab?.name || 'Server',
`Unknown command: ${cmd} — Type /help for available commands`,
);
}
};
const sendMessage = async () => {
const text = input.trim();
if (!text) return;
// Add to command history
setCmdHistory((prev) => {
const next = [text, ...prev];
return next.slice(0, MAX_HISTORY);
});
setHistoryIdx(-1);
setInput('');
const tab = tabs[activeTab];
if (!tab || tab.type === 'server') return;
if (text.startsWith('/')) {
const parts = text.split(' ');
const cmd = parts[0].toLowerCase();
if (cmd === '/join' && parts[1]) { joinChannel(parts[1]); return; }
if (cmd === '/part') { if (tab.type === 'channel') partChannel(tab.name); return; }
if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) {
const target = parts[1];
const body = parts.slice(2).join(' ');
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [body] }) });
openDM(target);
} catch (err) {
addSystemMessage('Server', `DM failed: ${err.data?.error || 'error'}`);
}
await handleCommand(text);
return;
}
if (cmd === '/nick' && parts[1]) {
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'NICK', body: [parts[1]] }) });
} catch (err) {
addSystemMessage('Server', `Nick change failed: ${err.data?.error || 'error'}`);
}
return;
}
if (cmd === '/topic' && tab.type === 'channel') {
const topicText = parts.slice(1).join(' ');
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'TOPIC', to: tab.name, body: [topicText] }) });
} catch (err) {
addSystemMessage('Server', `Topic failed: ${err.data?.error || 'error'}`);
}
return;
}
addSystemMessage('Server', `Unknown command: ${cmd}`);
if (!tab || tab.type === 'server') {
addSystemMessage(
'Server',
'Cannot send messages to the server tab. Join a channel with /join #channel',
);
return;
}
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: tab.name, body: [text] }) });
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'PRIVMSG',
to: tab.name,
body: [text],
}),
});
} catch (err) {
addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`);
addSystemMessage(
tab.name,
`Send failed: ${err.data?.error || 'error'}`,
);
}
};
const handleInputKeyDown = (e) => {
if (e.key === 'Enter') {
sendMessage();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (cmdHistory.length > 0) {
const nextIdx = Math.min(historyIdx + 1, cmdHistory.length - 1);
setHistoryIdx(nextIdx);
setInput(cmdHistory[nextIdx]);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIdx > 0) {
const nextIdx = historyIdx - 1;
setHistoryIdx(nextIdx);
setInput(cmdHistory[nextIdx]);
} else {
setHistoryIdx(-1);
setInput('');
}
}
};
@@ -446,72 +839,87 @@ function App() {
const currentTab = tabs[activeTab] || tabs[0];
const currentMessages = messages[currentTab.name] || [];
const currentMembers = members[currentTab.name] || [];
const currentUserModes = userModes[currentTab.name] || {};
const currentTopic = topics[currentTab.name] || '';
return (
<div class="app">
<div class="tab-bar">
{!connected && <div class="connection-status">⚠ Reconnecting...</div>}
{!connected && (
<div class="connection-status">⚠ Reconnecting...</div>
)}
{tabs.map((tab, i) => (
<div
class={`tab ${i === activeTab ? 'active' : ''}`}
class={`tab ${i === activeTab ? 'active' : ''} ${tab.type}`}
onClick={() => setActiveTab(i)}
>
<span class="tab-name">
{tab.type === 'dm' ? `→${tab.name}` : tab.name}
</span>
{unread[tab.name] > 0 && i !== activeTab && (
<span class="unread-badge">{unread[tab.name]}</span>
)}
{tab.type !== 'server' && (
<span class="close-btn" onClick={(e) => { e.stopPropagation(); closeTab(i); }}>×</span>
<span
class="close-btn"
onClick={(e) => {
e.stopPropagation();
closeTab(i);
}}
>
×
</span>
)}
</div>
))}
<div class="join-dialog">
<input
placeholder="#channel"
value={joinInput}
onInput={e => setJoinInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)}
/>
<button onClick={() => joinChannel(joinInput)}>Join</button>
</div>
</div>
{currentTab.type === 'channel' && currentTopic && (
<div class="topic-bar" title={currentTopic}>{currentTopic}</div>
{currentTab.type === 'channel' && (
<div class="topic-bar" title={currentTopic}>
<span class="topic-label">Topic:</span>{' '}
{currentTopic || '(no topic set)'}
</div>
)}
<div class="content">
<div class="messages-pane">
<div class={currentTab.type === 'server' ? 'server-messages' : 'messages'}>
{currentMessages.map(m => <Message msg={m} />)}
<div
class={
currentTab.type === 'server'
? 'server-messages'
: 'messages'
}
>
{currentMessages.map((m) => (
<Message msg={m} />
))}
<div ref={messagesEndRef} />
</div>
{currentTab.type !== 'server' && (
<div class="input-bar">
<input
ref={inputRef}
placeholder={`Message ${currentTab.name}...`}
value={input}
onInput={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
/>
<button onClick={sendMessage}>Send</button>
</div>
)}
</div>
{currentTab.type === 'channel' && (
<div class="user-list">
<h3>Users ({currentMembers.length})</h3>
{currentMembers.map(u => (
<div class="user" onClick={() => openDM(u.nick)} style={{ color: nickColor(u.nick) }}>
{u.nick}
</div>
))}
</div>
<UserList
members={currentMembers}
userModes={currentUserModes}
onDM={openDM}
/>
)}
</div>
<div class="input-bar">
<span class="input-nick">{nick}</span>
<input
ref={inputRef}
placeholder={
currentTab.type === 'server'
? 'Type /join #channel to get started...'
: `Message ${currentTab.name}...`
}
value={input}
onInput={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
</div>
</div>
);
}
+216 -102
View File
@@ -1,28 +1,40 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #1a1a2e;
--bg-secondary: #16213e;
--bg-input: #0f3460;
--text: #e0e0e0;
--text-muted: #888;
--accent: #e94560;
--accent2: #0f3460;
--border: #2a2a4a;
--nick: #53a8b6;
--timestamp: #666;
--tab-active: #e94560;
--tab-bg: #16213e;
--tab-hover: #1a1a3e;
--topic-bg: #121a30;
--unread-bg: #e94560;
--warn: #f0ad4e;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
:root {
--bg: #0a0e14;
--bg-secondary: #0d1117;
--bg-input: #161b22;
--bg-highlight: #1a2030;
--text: #c9d1d9;
--text-muted: #6e7681;
--accent: #58a6ff;
--accent-dim: #1f6feb;
--border: #21262d;
--nick: #79c0ff;
--timestamp: #484f58;
--tab-active: #58a6ff;
--tab-bg: #0d1117;
--tab-hover: #161b22;
--topic-bg: #0d1117;
--unread-bg: #da3633;
--warn: #d29922;
--op-color: #f0883e;
--voice-color: #3fb950;
--action-color: #bc8cff;
--system-color: #484f58;
}
html,
body,
#root {
height: 100%;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New',
Courier, monospace;
font-size: 13px;
background: var(--bg);
color: var(--text);
}
@@ -57,15 +69,19 @@ html, body, #root {
padding: 10px 24px;
font-size: 16px;
font-family: inherit;
background: var(--accent);
background: var(--accent-dim);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
}
.login-screen button:hover {
background: var(--accent);
}
.login-screen .error {
color: var(--accent);
color: var(--unread-bg);
}
.login-screen .motd {
@@ -89,36 +105,63 @@ html, body, #root {
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0;
align-items: center;
align-items: stretch;
min-height: 32px;
}
.tab-bar::-webkit-scrollbar {
height: 2px;
}
.tab-bar::-webkit-scrollbar-thumb {
background: var(--border);
}
.tab {
padding: 8px 16px;
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
color: var(--text-muted);
user-select: none;
position: relative;
font-size: 12px;
gap: 6px;
transition:
background 0.1s,
color 0.1s;
}
.tab:hover {
background: var(--tab-hover);
color: var(--text);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--tab-active);
background: var(--bg-highlight);
}
.tab.server {
font-weight: bold;
}
.tab .tab-name {
overflow: hidden;
text-overflow: ellipsis;
}
.tab .close-btn {
margin-left: 8px;
color: var(--text-muted);
font-size: 12px;
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.tab .close-btn:hover {
color: var(--accent);
color: var(--unread-bg);
}
.tab .unread-badge {
@@ -127,19 +170,22 @@ html, body, #root {
color: white;
font-size: 10px;
font-weight: bold;
padding: 1px 5px;
padding: 0 5px;
border-radius: 8px;
margin-left: 6px;
min-width: 16px;
text-align: center;
line-height: 16px;
flex-shrink: 0;
}
/* Connection status */
.connection-status {
padding: 4px 12px;
display: flex;
align-items: center;
padding: 0 12px;
background: var(--warn);
color: #1a1a2e;
font-size: 12px;
color: var(--bg);
font-size: 11px;
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
@@ -147,7 +193,7 @@ html, body, #root {
/* Topic bar */
.topic-bar {
padding: 6px 12px;
padding: 4px 12px;
background: var(--topic-bg);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
@@ -158,6 +204,11 @@ html, body, #root {
flex-shrink: 0;
}
.topic-bar .topic-label {
color: var(--accent);
font-weight: bold;
}
/* Content area */
.content {
display: flex;
@@ -171,147 +222,210 @@ html, body, #root {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
padding: 4px 0;
}
.messages::-webkit-scrollbar {
width: 6px;
}
.messages::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.message {
padding: 2px 0;
line-height: 1.4;
padding: 1px 12px;
line-height: 1.5;
word-wrap: break-word;
}
.message:hover {
background: var(--bg-highlight);
}
.message .timestamp {
color: var(--timestamp);
font-size: 12px;
margin-right: 8px;
font-size: 11px;
margin-right: 6px;
}
.message .nick {
color: var(--nick);
font-weight: bold;
margin-right: 8px;
margin-right: 6px;
}
.message .nick::before { content: '<'; }
.message .nick::after { content: '>'; }
.message .nick::before {
content: '<';
color: var(--text-muted);
}
.message .nick::after {
content: '>';
color: var(--text-muted);
}
.message.system {
color: var(--text-muted);
color: var(--system-color);
font-style: italic;
}
.message.system .nick {
color: var(--text-muted);
.message.system .timestamp {
color: var(--timestamp);
}
.message.system .nick::before,
.message.system .nick::after { content: ''; }
.message.system .content::before {
content: '*** ';
}
/* Input */
.message.action {
color: var(--action-color);
}
.message.action .timestamp {
color: var(--timestamp);
}
.message.action .action-nick {
font-weight: bold;
}
/* Input bar — full width at bottom */
.input-bar {
display: flex;
align-items: center;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.input-bar .input-nick {
padding: 0 8px 0 12px;
color: var(--accent);
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
font-size: 13px;
}
.input-bar .input-nick::after {
content: '>';
color: var(--text-muted);
margin-left: 1px;
}
.input-bar input {
flex: 1;
padding: 10px 12px;
padding: 8px 8px;
font-family: inherit;
font-size: 14px;
background: var(--bg-input);
font-size: 13px;
background: transparent;
border: none;
color: var(--text);
outline: none;
}
.input-bar button {
padding: 10px 16px;
font-family: inherit;
background: var(--accent);
border: none;
color: white;
cursor: pointer;
.input-bar input::placeholder {
color: var(--text-muted);
}
/* User list */
.user-list {
width: 160px;
width: 170px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.user-list h3 {
.user-list-header {
padding: 6px 10px;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
margin-bottom: 8px;
letter-spacing: 1px;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
font-weight: bold;
flex-shrink: 0;
}
.user-list-entries {
overflow-y: auto;
flex: 1;
padding: 4px 0;
}
.user-list-entries::-webkit-scrollbar {
width: 4px;
}
.user-list-entries::-webkit-scrollbar-thumb {
background: var(--border);
}
.user-list .user {
padding: 3px 4px;
color: var(--nick);
font-size: 13px;
padding: 2px 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.user-list .user:hover {
background: var(--tab-hover);
}
/* Server tab */
.user-list .user.op {
color: var(--op-color);
}
.user-list .user.voice {
color: var(--voice-color);
}
/* Server tab messages */
.server-messages {
color: var(--text-muted);
padding: 12px;
padding: 8px 12px;
white-space: pre-wrap;
overflow-y: auto;
flex: 1;
}
/* Channel join dialog */
.join-dialog {
padding: 12px;
display: flex;
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
margin-left: auto;
.server-messages .message {
padding: 1px 0;
}
.join-dialog input {
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 3px;
width: 200px;
}
.join-dialog button {
padding: 6px 14px;
font-family: inherit;
font-size: 13px;
background: var(--accent2);
border: none;
color: var(--text);
border-radius: 3px;
cursor: pointer;
.server-messages .message:hover {
background: var(--bg-highlight);
}
/* Responsive */
@media (max-width: 600px) {
.user-list { display: none; }
.tab { padding: 6px 10px; font-size: 13px; }
.user-list {
display: none;
}
.tab {
padding: 5px 8px;
font-size: 11px;
}
.input-bar .input-nick {
padding-left: 8px;
font-size: 12px;
}
.input-bar input {
font-size: 12px;
}
}