From 78d657111b2ef04312bbc4a882b2cb71344d459c Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Mar 2026 17:00:52 -0700 Subject: [PATCH] =?UTF-8?q?Rename=20replay=20=E2=86=92=20initChannelState?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the query parameter, function, and all related comments from 'replay' to 'initChannelState' to better reflect the semantics: the server initializes channel state for the reconnecting client rather than replaying past events. --- README.md | 6 +++--- internal/handlers/api.go | 19 ++++++++++--------- internal/handlers/auth.go | 6 +++--- web/dist/app.js | 2 +- web/src/app.jsx | 11 ++++++----- 5 files changed, 23 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8febc1b..9ff2502 100644 --- a/README.md +++ b/README.md @@ -1036,7 +1036,7 @@ Return the current user's session state. | Parameter | Type | Default | Description | |-----------|--------|---------|-------------| -| `replay` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. | +| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. | **Response:** `200 OK` ```json @@ -1070,9 +1070,9 @@ curl -s http://localhost:8080/api/v1/state \ -H "Authorization: Bearer $TOKEN" | jq . ``` -**Reconnect with channel replay:** +**Reconnect with channel state initialization:** ```bash -curl -s "http://localhost:8080/api/v1/state?replay=1" \ +curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \ -H "Authorization: Bearer $TOKEN" | jq . ``` diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 3ff92cf..3be16f3 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -444,10 +444,11 @@ func (hdlr *Handlers) enqueueNumeric( } // HandleState returns the current session's info and -// channels. When called with ?replay=1, it also enqueues -// synthetic JOIN + TOPIC + NAMES messages for every channel -// the session belongs to so that a reconnecting client can -// rebuild its channel tabs from the message stream. +// channels. When called with ?initChannelState=1, it also +// enqueues synthetic JOIN + TOPIC + NAMES messages for +// every channel the session belongs to so that a +// reconnecting client can rebuild its channel tabs from +// the message stream. func (hdlr *Handlers) HandleState() http.HandlerFunc { return func( writer http.ResponseWriter, @@ -475,8 +476,8 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc { return } - if request.URL.Query().Get("replay") == "1" { - hdlr.replayChannelState( + if request.URL.Query().Get("initChannelState") == "1" { + hdlr.initChannelState( request, clientID, sessionID, nick, ) } @@ -489,12 +490,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc { } } -// replayChannelState enqueues synthetic JOIN messages and +// initChannelState enqueues synthetic JOIN messages and // join-numerics (TOPIC, NAMES) for every channel the // session belongs to. Messages are enqueued only to the // specified client so other clients/sessions are not // affected. -func (hdlr *Handlers) replayChannelState( +func (hdlr *Handlers) initChannelState( request *http.Request, clientID, sessionID int64, nick string, @@ -516,7 +517,7 @@ func (hdlr *Handlers) replayChannelState( ) if insErr != nil { hdlr.log.Error( - "replay: insert JOIN", + "initChannelState: insert JOIN", "error", insErr, ) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 4b887ea..ceb320f 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -182,9 +182,9 @@ func (hdlr *Handlers) handleLogin( request, clientID, sessionID, payload.Nick, ) - // Replay channel state so the new client knows which - // channels the session already belongs to. - hdlr.replayChannelState( + // Initialize channel state so the new client knows + // which channels the session already belongs to. + hdlr.initChannelState( request, clientID, sessionID, payload.Nick, ) diff --git a/web/dist/app.js b/web/dist/app.js index 37e83c3..318fae0 100644 --- a/web/dist/app.js +++ b/web/dist/app.js @@ -1,2 +1,2 @@ -var ie,T,De,pt,G,$e,He,Re,Le,ve,me,he,mt,ae={},se=[],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 a,s,o,_={};for(o in t)o=="key"?a=t[o]:o=="ref"?s=t[o]:_[o]=t[o];if(arguments.length>2&&(_.children=arguments.length>3?ie.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(o in e.defaultProps)_[o]===void 0&&(_[o]=e.defaultProps[o]);return re(e,_,a,s,null)}function re(e,t,n,a,s){var o={type:e,props:t,key:n,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s??++De,__i:-1,__u:0};return s==null&&T.vnode!=null&&T.vnode(o),o}function le(e){return e.children}function oe(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;tt&&G.sort(Re),e=G.shift(),t=G.length,yt(e);ce.__r=0}function Fe(e,t,n,a,s,o,_,u,y,f,k){var c,h,v,C,$,A,g,w=a&&a.__k||se,D=t.length;for(y=vt(n,t,w,y,D),c=0;c0?_=e.__k[o]=re(_.type,_.props,_.key,_.ref?_.ref:null,_.__v):e.__k[o]=_,y=o+h,_.__=e,_.__b=e.__b+1,u=null,(f=_.__i=bt(_,n,y,c))!=-1&&(c--,(u=n[f])&&(u.__u|=2)),u==null||u.__v==null?(f==-1&&(s>k?h--:sy?h--:h++,_.__u|=4))):e.__k[o]=null;if(c)for(o=0;o(k?1:0)){for(s=n-1,o=n+1;s>=0||o=0?s--:o++])!=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 ne(e,t,n,a,s){var o,_;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof a=="string"&&(e.style.cssText=a=""),a)for(t in a)n&&t in n||Ee(e.style,t,"");if(n)for(t in n)a&&n[t]==a[t]||Ee(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")o=t!=(t=t.replace(Le,"$1")),_=t.toLowerCase(),t=_ in e||t=="onFocusOut"||t=="onFocusIn"?_.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+o]=n,n?a?n.u=a.u:(n.u=ve,e.addEventListener(t,o?he:me,o)):e.removeEventListener(t,o?he:me,o);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 Ue(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 kt(e,t,n,a,s,o,_,u,y){var f,k,c,h,v,C,$,A=n.props||ae,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"),o!=null){for(f=0;f=n.__.length&&n.__.push({}),n.__[e]}function P(e){return te=1,St(ot,e)}function St(e,t,n){var a=Te(ee++,2);if(a.t=e,!a.__c&&(a.__=[n?n(t):ot(void 0,t),function(u){var y=a.__N?a.__N[0]:a.__[0],f=a.t(y,u);y!==f&&(a.__N=[f,a.__[1]],a.__c.setState({}))}],a.__c=I,!I.__f)){var s=function(u,y,f){if(!a.__c.__H)return!0;var k=a.__c.__H.__.filter(function(h){return h.__c});if(k.every(function(h){return!h.__N}))return!o||o.call(this,u,y,f);var c=a.__c.props!==u;return k.some(function(h){if(h.__N){var v=h.__[0];h.__=h.__N,h.__N=void 0,v!==h.__[0]&&(c=!0)}}),o&&o.call(this,u,y,f)||c};I.__f=!0;var o=I.shouldComponentUpdate,_=I.componentWillUpdate;I.componentWillUpdate=function(u,y,f){if(this.__e){var k=o;o=void 0,s(u,y,f),o=k}_&&_.call(this,u,y,f)},I.shouldComponentUpdate=s}return a.__N||a.__}function U(e,t){var n=Te(ee++,3);!O.__s&&rt(n.__H,t)&&(n.__=e,n.u=t,I.__H.__h.push(n))}function R(e){return te=5,nt(function(){return{current:e}},[])}function nt(e,t){var n=Te(ee++,7);return rt(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function Y(e,t){return te=8,nt(function(){return e},t)}function wt(){for(var e;e=tt.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(ue),t.__h.some(we),t.__h=[]}catch(n){t.__h=[],O.__e(n,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),ee=0;var t=(I=e.__c).__H;t&&(Se===I?(t.__h=[],I.__h=[],t.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.some(ue),t.__h.some(we),t.__h=[],ee=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)||Tt)(wt)),t.__H.__.some(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.some(ue),n.__h=n.__h.filter(function(a){return!a.__||we(a)})}catch(a){t.some(function(s){s.__h&&(s.__h=[])}),t=[],O.__e(a,n.__v)}}),Ye&&Ye(e,t)},O.unmount=function(e){Xe&&Xe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.some(function(a){try{ue(a)}catch(s){t=s}}),n.__H=void 0,t&&O.__e(t,n.__v))};var et=typeof requestAnimationFrame=="function";function Tt(e){var t,n=function(){clearTimeout(a),et&&cancelAnimationFrame(t),setTimeout(e)},a=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,a){return n!==e[a]})}function ot(e,t){return typeof t=="function"?t(e):t}var xt="/api/v1",Ct=15,It=3e3,At=1e4,Ce="ACTION ",Ie="";function x(e,t={}){let n=localStorage.getItem("neoirc_token"),a={"Content-Type":"application/json",...t.headers||{}};n&&(a.Authorization=`Bearer ${n}`);let{signal:s,...o}=t;return fetch(xt+e,{...o,headers:a,signal:s}).then(async _=>{let u=await _.json().catch(()=>null);if(!_.ok)throw{status:_.status,data:u};return u})}function Ot(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 a=0;a{x("/server").then(h=>{h.name&&y(h.name),h.motd&&_(h.motd)}).catch(()=>{}),localStorage.getItem("neoirc_token")&&x("/state?replay=1").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),o&&m("pre",{class:"motd"},o),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")),a&&m("div",{class:"error"},a)))}function Mt({msg:e,myNick:t}){let n=Ot(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=[],a=[],s=[];for(let u of e){let y=u.mode||"";y==="o"?n.push(u):y==="v"?a.push(u):s.push(u)}let o=(u,y)=>u.nick.toLowerCase().localeCompare(y.nick.toLowerCase());n.sort(o),a.sort(o),s.sort(o);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,"@")),a.map(u=>_(u,"+")),s.map(u=>_(u,""))))}function Ut(){let[e,t]=P(!1),[n,a]=P(""),[s,o]=P([{type:"server",name:"Server"}]),[_,u]=P(0),[y,f]=P({Server:[]}),[k,c]=P({}),[h,v]=P({}),[C,$]=P({}),[A,g]=P(""),[w,D]=P(!0),[M,V]=P([]),[q,L]=P(-1),B=R(0),E=R(new Set),W=R(null),z=R(s),Ae=R(_),X=R(n),Oe=R(),Z=R();U(()=>{z.current=s},[s]),U(()=>{Ae.current=_},[_]),U(()=>{X.current=n},[n]),U(()=>{let r=s.filter(i=>i.type==="channel").map(i=>i.name);localStorage.setItem("neoirc_channels",JSON.stringify(r))},[s]),U(()=>{let r=s[_];r&&$(i=>({...i,[r.name]:0}))},[_,s]);let N=Y((r,i)=>{if(i.id&&E.current.has(i.id))return;i.id&&E.current.add(i.id),f(l=>({...l,[r]:[...l[r]||[],i]}));let p=z.current[Ae.current];(!p||p.name!==r)&&$(l=>({...l,[r]:(l[r]||0)+1}))},[]),S=Y((r,i)=>{f(p=>({...p,[r]:[...p[r]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:i,system:!0}]}))},[]),K=Y(r=>{let i=r.replace("#","");x(`/channels/${i}/members`).then(p=>{c(l=>({...l,[r]:p}))}).catch(()=>{})},[]),fe=Y(r=>{let i=Array.isArray(r.body)?r.body.join(` +var ie,T,De,pt,G,$e,He,Re,Le,ve,me,he,mt,ae={},se=[],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 a,s,o,_={};for(o in t)o=="key"?a=t[o]:o=="ref"?s=t[o]:_[o]=t[o];if(arguments.length>2&&(_.children=arguments.length>3?ie.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(o in e.defaultProps)_[o]===void 0&&(_[o]=e.defaultProps[o]);return re(e,_,a,s,null)}function re(e,t,n,a,s){var o={type:e,props:t,key:n,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s??++De,__i:-1,__u:0};return s==null&&T.vnode!=null&&T.vnode(o),o}function le(e){return e.children}function oe(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;tt&&G.sort(Re),e=G.shift(),t=G.length,yt(e);ce.__r=0}function Fe(e,t,n,a,s,o,_,u,y,f,k){var c,h,v,C,$,A,g,w=a&&a.__k||se,D=t.length;for(y=vt(n,t,w,y,D),c=0;c0?_=e.__k[o]=re(_.type,_.props,_.key,_.ref?_.ref:null,_.__v):e.__k[o]=_,y=o+h,_.__=e,_.__b=e.__b+1,u=null,(f=_.__i=bt(_,n,y,c))!=-1&&(c--,(u=n[f])&&(u.__u|=2)),u==null||u.__v==null?(f==-1&&(s>k?h--:sy?h--:h++,_.__u|=4))):e.__k[o]=null;if(c)for(o=0;o(k?1:0)){for(s=n-1,o=n+1;s>=0||o=0?s--:o++])!=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 ne(e,t,n,a,s){var o,_;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof a=="string"&&(e.style.cssText=a=""),a)for(t in a)n&&t in n||Ee(e.style,t,"");if(n)for(t in n)a&&n[t]==a[t]||Ee(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")o=t!=(t=t.replace(Le,"$1")),_=t.toLowerCase(),t=_ in e||t=="onFocusOut"||t=="onFocusIn"?_.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+o]=n,n?a?n.u=a.u:(n.u=ve,e.addEventListener(t,o?he:me,o)):e.removeEventListener(t,o?he:me,o);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 Ue(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 kt(e,t,n,a,s,o,_,u,y){var f,k,c,h,v,C,$,A=n.props||ae,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"),o!=null){for(f=0;f=n.__.length&&n.__.push({}),n.__[e]}function P(e){return te=1,St(ot,e)}function St(e,t,n){var a=Te(ee++,2);if(a.t=e,!a.__c&&(a.__=[n?n(t):ot(void 0,t),function(u){var y=a.__N?a.__N[0]:a.__[0],f=a.t(y,u);y!==f&&(a.__N=[f,a.__[1]],a.__c.setState({}))}],a.__c=I,!I.__f)){var s=function(u,y,f){if(!a.__c.__H)return!0;var k=a.__c.__H.__.filter(function(h){return h.__c});if(k.every(function(h){return!h.__N}))return!o||o.call(this,u,y,f);var c=a.__c.props!==u;return k.some(function(h){if(h.__N){var v=h.__[0];h.__=h.__N,h.__N=void 0,v!==h.__[0]&&(c=!0)}}),o&&o.call(this,u,y,f)||c};I.__f=!0;var o=I.shouldComponentUpdate,_=I.componentWillUpdate;I.componentWillUpdate=function(u,y,f){if(this.__e){var k=o;o=void 0,s(u,y,f),o=k}_&&_.call(this,u,y,f)},I.shouldComponentUpdate=s}return a.__N||a.__}function U(e,t){var n=Te(ee++,3);!O.__s&&rt(n.__H,t)&&(n.__=e,n.u=t,I.__H.__h.push(n))}function R(e){return te=5,nt(function(){return{current:e}},[])}function nt(e,t){var n=Te(ee++,7);return rt(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function Y(e,t){return te=8,nt(function(){return e},t)}function wt(){for(var e;e=tt.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(ue),t.__h.some(we),t.__h=[]}catch(n){t.__h=[],O.__e(n,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),ee=0;var t=(I=e.__c).__H;t&&(Se===I?(t.__h=[],I.__h=[],t.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.some(ue),t.__h.some(we),t.__h=[],ee=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)||Tt)(wt)),t.__H.__.some(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.some(ue),n.__h=n.__h.filter(function(a){return!a.__||we(a)})}catch(a){t.some(function(s){s.__h&&(s.__h=[])}),t=[],O.__e(a,n.__v)}}),Ye&&Ye(e,t)},O.unmount=function(e){Xe&&Xe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.some(function(a){try{ue(a)}catch(s){t=s}}),n.__H=void 0,t&&O.__e(t,n.__v))};var et=typeof requestAnimationFrame=="function";function Tt(e){var t,n=function(){clearTimeout(a),et&&cancelAnimationFrame(t),setTimeout(e)},a=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,a){return n!==e[a]})}function ot(e,t){return typeof t=="function"?t(e):t}var xt="/api/v1",Ct=15,It=3e3,At=1e4,Ce="ACTION ",Ie="";function x(e,t={}){let n=localStorage.getItem("neoirc_token"),a={"Content-Type":"application/json",...t.headers||{}};n&&(a.Authorization=`Bearer ${n}`);let{signal:s,...o}=t;return fetch(xt+e,{...o,headers:a,signal:s}).then(async _=>{let u=await _.json().catch(()=>null);if(!_.ok)throw{status:_.status,data:u};return u})}function Ot(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 a=0;a{x("/server").then(h=>{h.name&&y(h.name),h.motd&&_(h.motd)}).catch(()=>{}),localStorage.getItem("neoirc_token")&&x("/state?initChannelState=1").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),o&&m("pre",{class:"motd"},o),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")),a&&m("div",{class:"error"},a)))}function Mt({msg:e,myNick:t}){let n=Ot(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=[],a=[],s=[];for(let u of e){let y=u.mode||"";y==="o"?n.push(u):y==="v"?a.push(u):s.push(u)}let o=(u,y)=>u.nick.toLowerCase().localeCompare(y.nick.toLowerCase());n.sort(o),a.sort(o),s.sort(o);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,"@")),a.map(u=>_(u,"+")),s.map(u=>_(u,""))))}function Ut(){let[e,t]=P(!1),[n,a]=P(""),[s,o]=P([{type:"server",name:"Server"}]),[_,u]=P(0),[y,f]=P({Server:[]}),[k,c]=P({}),[h,v]=P({}),[C,$]=P({}),[A,g]=P(""),[w,D]=P(!0),[M,V]=P([]),[q,L]=P(-1),B=R(0),E=R(new Set),W=R(null),z=R(s),Ae=R(_),X=R(n),Oe=R(),Z=R();U(()=>{z.current=s},[s]),U(()=>{Ae.current=_},[_]),U(()=>{X.current=n},[n]),U(()=>{let r=s.filter(i=>i.type==="channel").map(i=>i.name);localStorage.setItem("neoirc_channels",JSON.stringify(r))},[s]),U(()=>{let r=s[_];r&&$(i=>({...i,[r.name]:0}))},[_,s]);let N=Y((r,i)=>{if(i.id&&E.current.has(i.id))return;i.id&&E.current.add(i.id),f(l=>({...l,[r]:[...l[r]||[],i]}));let p=z.current[Ae.current];(!p||p.name!==r)&&$(l=>({...l,[r]:(l[r]||0)+1}))},[]),S=Y((r,i)=>{f(p=>({...p,[r]:[...p[r]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:i,system:!0}]}))},[]),K=Y(r=>{let i=r.replace("#","");x(`/channels/${i}/members`).then(p=>{c(l=>({...l,[r]:p}))}).catch(()=>{})},[]),fe=Y(r=>{let i=Array.isArray(r.body)?r.body.join(` `):"",p={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,d=!1;Nt(l)&&(l=Pt(l),d=!0);let b={...p,text:l,system:!1,isAction:d},F=r.to;if(F&&F.startsWith("#"))N(F,b);else{let H=r.from===X.current?r.to:r.from;o(pe=>pe.find(Pe=>Pe.type==="dm"&&Pe.name===H)?pe:[...pe,{type:"dm",name:H}]),N(H,b)}break}case"JOIN":{let l=`${r.from} has joined ${r.to}`;r.to&&N(r.to,{...p,text:l,system:!0}),r.to&&r.to.startsWith("#")&&(r.from===X.current&&o(d=>d.find(b=>b.type==="channel"&&b.name===r.to)?d:[...d,{type:"channel",name:r.to}]),K(r.to));break}case"PART":{let l=i?" ("+i+")":"",d=`${r.from} has parted ${r.to}${l}`;r.to&&N(r.to,{...p,text:d,system:!0}),r.to&&r.to.startsWith("#")&&K(r.to);break}case"QUIT":{let l=i?" ("+i+")":"",d=`${r.from} has quit${l}`;z.current.forEach(b=>{b.type==="channel"&&N(b.name,{...p,text:d,system:!0})});break}case"NICK":{let l=Array.isArray(r.body)?r.body[0]:i,d=`${r.from} is now known as ${l}`;z.current.forEach(b=>{b.type==="channel"&&N(b.name,{...p,text:d,system:!0})}),r.from===X.current&&l&&a(l),z.current.forEach(b=>{b.type==="channel"&&K(b.name)});break}case"TOPIC":{let l=`${r.from} has changed the topic to: ${i}`;r.to&&(N(r.to,{...p,text:l,system:!0}),v(d=>({...d,[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(H=>H.startsWith("@")?{nick:H.slice(1),mode:"o"}:H.startsWith("+")?{nick:H.slice(1),mode:"v"}:{nick:H,mode:""});c(H=>({...H,[l]:F}))}break}case"332":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0],d=Array.isArray(r.body)?r.body[0]:i;d&&v(b=>({...b,[l]:d}))}break}case"322":{if(Array.isArray(r.params)&&r.params.length>=2){let l=r.params[0],d=r.params[1];N("Server",{...p,text:`${l} (${d} users): ${(i||"").trim()}`,system:!0})}break}case"323":N("Server",{...p,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],d=r.params[4],b=r.params.length>5?r.params[5]:"";N("Server",{...p,text:`${l} ${d} ${b}`,system:!0})}break}case"315":N("Server",{...p,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",{...p,text:`${l} (${i})`,system:!0})}break}case"312":{if(Array.isArray(r.params)&&r.params.length>=2){let l=r.params[0],d=r.params[1];N("Server",{...p,text:`${l} on ${d}`,system:!0})}break}case"319":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0];N("Server",{...p,text:`${l} is on: ${i}`,system:!0})}break}case"318":N("Server",{...p,text:i||"End of /WHOIS list",system:!0});break;case"375":case"372":case"376":N("Server",{...p,text:i,system:!0});break;default:i&&N("Server",{...p,text:i,system:!0})}},[N,K]);U(()=>{if(!e)return;let r=!0;return(async()=>{for(;r;)try{let p=new AbortController;W.current=p;let l=await x(`/messages?after=${B.current}&timeout=${Ct}`,{signal:p.signal});if(!r)break;if(D(!0),l.messages)for(let d of l.messages)fe(d);l.last_id>B.current&&(B.current=l.last_id)}catch(p){if(!r)break;if(p.name==="AbortError")continue;D(!1),await new Promise(l=>setTimeout(l,It))}})(),()=>{r=!1,W.current?.abort()}},[e,fe]),U(()=>{if(!e)return;let r=s[_];if(!r||r.type!=="channel")return;K(r.name);let i=setInterval(()=>K(r.name),At);return()=>clearInterval(i)},[e,_,s,K]),U(()=>{Oe.current?.scrollIntoView({behavior:"smooth"})},[y,_]),U(()=>{Z.current?.focus()},[_]),U(()=>{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)},[]),U(()=>{if(!e)return;let r=s[_];!r||r.type!=="channel"||x("/channels").then(i=>{let p=i.find(l=>l.name===r.name);p&&p.topic&&v(l=>({...l,[r.name]:p.topic}))}).catch(()=>{})},[e,_,s]);let at=Y(async(r,i)=>{if(a(r),t(!0),S("Server",`Connected as ${r}`),i){try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MOTD"})})}catch{}return}let p=JSON.parse(localStorage.getItem("neoirc_channels")||"[]");for(let l of p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:l})}),o(d=>d.find(b=>b.type==="channel"&&b.name===l)?d:[...d,{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})}),o(p=>p.find(l=>l.type==="channel"&&l.name===r)?p:[...p,{type:"channel",name:r}]);let i=z.current.length;u(i);try{let p=await x(`/history?target=${encodeURIComponent(r)}&limit=50`);if(Array.isArray(p))for(let l of p)fe(l)}catch{}}catch(i){S("Server",`Failed to join ${r}: ${i.data?.error||"error"}`)}}},Ne=async(r,i)=>{try{await x("/messages",{method:"POST",body:JSON.stringify(i?{command:"PART",to:r,body:[i]}:{command:"PART",to:r})})}catch{}o(p=>p.filter(l=>!(l.type==="channel"&&l.name===r))),u(0)},ct=r=>{let i=s[r];i.type==="channel"?Ne(i.name):i.type==="dm"&&(o(p=>p.filter((l,d)=>d!==r)),_>=r&&u(Math.max(0,_-1)))},de=r=>{if(r===X.current)return;o(p=>p.find(l=>l.type==="dm"&&l.name===r)?p:[...p,{type:"dm",name:r}]);let i=s.findIndex(p=>p.type==="dm"&&p.name===r);u(i>=0?i:s.length)},it=async r=>{let i=r.split(" "),p=i[0].toLowerCase(),l=s[_];switch(p){case"/join":{i[1]?st(i[1]):S("Server","Usage: /join #channel");break}case"/part":{if(l.type==="channel"){let d=i.slice(1).join(" ")||void 0;Ne(l.name,d)}else S("Server","You are not in a channel");break}case"/msg":{if(i[1]&&i.slice(2).join(" ")){let d=i[1],b=i.slice(2).join(" ");try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:d,body:[b]})}),de(d)}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 d=i.slice(1).join(" ");if(d)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:l.name,body:[Ce+d+Ie]})})}catch(b){S(l.name,`Action failed: ${b.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(d){S("Server",`Nick change failed: ${d.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 d=i.slice(1).join(" ");if(d)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:l.name,body:[d]})})}catch(b){S("Server",`Topic change failed: ${b.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 d=i.slice(1);if(d.length>0)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MODE",to:l.name,params:d})})}catch(b){S("Server",`Mode change failed: ${b.data?.error||"error"}`)}else S("Server","Usage: /mode <+/-mode> [params]");break}case"/quit":{let d=i.slice(1).join(" ")||void 0;try{await x("/messages",{method:"POST",body:JSON.stringify(d?{command:"QUIT",body:[d]}:{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(d){S("Server",`Failed to request MOTD: ${d.data?.error||"error"}`)}break}case"/query":{if(i[1]){let d=i[1];de(d);let b=i.slice(2).join(" ");if(b)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:d,body:[b]})})}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(d){S("Server",`Failed to list channels: ${d.data?.error||"error"}`)}break}case"/who":{let d=i[1]||(l.type==="channel"?l.name:"");if(d)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"WHO",to:d})})}catch(b){S("Server",`WHO failed: ${b.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(d){S("Server",`WHOIS failed: ${d.data?.error||"error"}`)}else S("Server","Usage: /whois ");break}case"/clear":{let d=l.name;f(b=>({...b,[d]:[]}));break}case"/help":{let d=["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 b of d)S("Server",b);break}default:S("Server",`Unknown command: ${p}`)}},_t=async()=>{let r=A.trim();if(!r)return;V(p=>{let l=[...p,r];return l.length>100&&l.shift(),l}),L(-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(p){S(i.name,`Send failed: ${p.data?.error||"error"}`)}}},lt=r=>{if(r.key==="Enter")_t();else if(r.key==="ArrowUp"){if(r.preventDefault(),M.length>0){let i=q===-1?M.length-1:Math.max(0,q-1);L(i),g(M[i])}}else if(r.key==="ArrowDown"&&(r.preventDefault(),q>=0)){let i=q+1;i>=M.length?(L(-1),g("")):(L(i),g(M[i]))}};if(!e)return m($t,{onLogin:at});let j=s[_]||s[0],ut=y[j.name]||[],ft=k[j.name]||[],dt=h[j.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:p=>{p.stopPropagation(),ct(i)}},"\xD7")))),m("div",{class:"status-area"},!w&&m("span",{class:"status-warn"},"\u25CF Reconnecting"),m("span",{class:"status-nick"},n))),j.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(Mt,{msg:r,myNick:n,key:r.id})),m("div",{ref:Oe}))),j.type==="channel"&&m(Et,{members:ft,onNickClick:de})),m("div",{class:"input-line"},m("span",{class:"input-prompt"},"[",n,"]",j.type!=="server"?` ${j.name}`:""," >"),m("input",{ref:Z,type:"text",value:A,onInput:r=>g(r.target.value),onKeyDown:lt,placeholder:j.type==="server"?"Type /help for commands":"",spellCheck:!1,autoComplete:"off"})))}Be(m(Ut,null),document.getElementById("root")); diff --git a/web/src/app.jsx b/web/src/app.jsx index 2f4b42b..b204951 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -70,7 +70,7 @@ function LoginScreen({ onLogin }) { .catch(() => {}); const saved = localStorage.getItem("neoirc_token"); if (saved) { - api("/state?replay=1") + api("/state?initChannelState=1") .then((u) => onLogin(u.nick, true)) .catch(() => localStorage.removeItem("neoirc_token")); } @@ -335,7 +335,7 @@ function App() { if (msg.to) addMessage(msg.to, { ...base, text, system: true }); if (msg.to && msg.to.startsWith("#")) { // Create a tab when the current user joins a channel - // (including replayed JOINs on reconnect). + // (including JOINs from initChannelState on reconnect). if (msg.from === nickRef.current) { setTabs((prev) => { if ( @@ -656,9 +656,10 @@ function App() { if (isResumed) { // Request MOTD on resumed sessions (new sessions // get it automatically from the server during - // creation). Channel state is replayed by the - // server via the message queue (?replay=1), so we - // do not need to re-JOIN channels here. + // creation). Channel state is initialized by the + // server via the message queue + // (?initChannelState=1), so we do not need to + // re-JOIN channels here. try { await api("/messages", { method: "POST",