diff --git a/.gitignore b/.gitignore index 2adb002..745e210 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ node_modules/ *.key # Build artifacts +web/dist/ /neoircd /bin/ *.exe diff --git a/Dockerfile b/Dockerfile index 4fd5b15..273f53f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,13 @@ +# Web build stage — compile SPA from source +# node:22-alpine, 2026-03-09 +FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/src/ src/ +COPY web/build.sh build.sh +RUN sh build.sh + # Lint stage — fast feedback on formatting and lint issues # golangci/golangci-lint:v2.1.6, 2026-03-02 FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint @@ -5,6 +15,9 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . +# Create placeholder files so //go:embed dist/* in web/embed.go resolves +# without depending on the web-builder stage (lint should fail fast) +RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js RUN make fmt-check RUN make lint @@ -21,6 +34,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . +COPY --from=web-builder /web/dist/ web/dist/ RUN make test diff --git a/README.md b/README.md index 46b9c7d..146dad6 100644 --- a/README.md +++ b/README.md @@ -1032,6 +1032,12 @@ Return the current user's session state. **Request:** No body. Requires auth. +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|--------|---------|-------------| +| `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 { @@ -1064,6 +1070,12 @@ curl -s http://localhost:8080/api/v1/state \ -H "Authorization: Bearer $TOKEN" | jq . ``` +**Reconnect with channel state initialization:** +```bash +curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + ### GET /api/v1/messages — Poll Messages (Long-Poll) Retrieve messages from the client's delivery queue. This is the primary @@ -1840,26 +1852,16 @@ docker run -p 8080:8080 \ neoirc ``` -The Dockerfile is a multi-stage build: -1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify +The Dockerfile is a four-stage build: +1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX → + bundled JS via esbuild) into `web/dist/` +2. **lint**: Runs formatting checks and golangci-lint against the Go source + (uses empty placeholder files for `web/dist/` so it runs independently of + web-builder for fast feedback) +3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli` + binaries with the real SPA assets from web-builder (CLI built to verify compilation, not included in final image) -2. **Final stage**: Alpine Linux + `neoircd` binary only - -```dockerfile -FROM golang:1.24-alpine AS builder -WORKDIR /src -RUN apk add --no-cache make -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN go build -o /neoircd ./cmd/neoircd/ -RUN go build -o /neoirc-cli ./cmd/neoirc-cli/ - -FROM alpine:latest -COPY --from=builder /neoircd /usr/local/bin/neoircd -EXPOSE 8080 -CMD ["neoircd"] -``` +4. **final**: Minimal Alpine image with only the `neoircd` binary ### Binary @@ -2308,10 +2310,14 @@ neoirc/ │ └── http.go # HTTP timeouts ├── web/ │ ├── embed.go # go:embed directive for SPA -│ └── dist/ # Built SPA (vanilla JS, no build step) -│ ├── index.html -│ ├── style.css -│ └── app.js +│ ├── build.sh # SPA build script (esbuild, runs in Docker) +│ ├── package.json # Node dependencies (preact, esbuild) +│ ├── package-lock.json +│ ├── src/ # SPA source files (JSX + HTML + CSS) +│ │ ├── app.jsx +│ │ ├── index.html +│ │ └── style.css +│ └── dist/ # Generated at Docker build time (not committed) ├── schema/ # JSON Schema definitions (planned) ├── go.mod ├── go.sum diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index 5f8e062..8478553 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-02-22 +last_modified: 2026-03-09 --- This document covers repository structure, tooling, and workflow standards. Code @@ -98,6 +98,13 @@ style conventions are in separate documents: `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up a new repo. +- **No build artifacts in version control.** Code-derived data (compiled + bundles, minified output, generated assets) must never be committed to the + repository if it can be avoided. The build process (e.g. Dockerfile, Makefile) + should generate these at build time. Notable exception: Go protobuf generated + files (`.pb.go`) ARE committed because repos need to work with `go get`, which + downloads code but does not execute code generation. + - Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never force-push to `main`. @@ -144,8 +151,14 @@ style conventions are in separate documents: - Use SemVer. - Database migrations live in `internal/db/migrations/` and must be embedded in - the binary. Pre-1.0.0: modify existing migrations (no installed base assumed). - Post-1.0.0: add new migration files. + the binary. + - `000_migration.sql` — contains ONLY the creation of the migrations + tracking table itself. Nothing else. + - `001_schema.sql` — the full application schema. + - **Pre-1.0.0:** never add additional migration files (002, 003, etc.). + There is no installed base to migrate. Edit `001_schema.sql` directly. + - **Post-1.0.0:** add new numbered migration files for each schema change. + Never edit existing migrations after release. - All repos should have an `.editorconfig` enforcing the project's indentation settings. diff --git a/internal/handlers/api.go b/internal/handlers/api.go index b9bb651..b1c7c87 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -444,13 +444,17 @@ func (hdlr *Handlers) enqueueNumeric( } // HandleState returns the current session's info and -// channels. +// 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, request *http.Request, ) { - sessionID, _, nick, ok := + sessionID, clientID, nick, ok := hdlr.requireAuth(writer, request) if !ok { return @@ -472,6 +476,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc { return } + if request.URL.Query().Get("initChannelState") == "1" { + hdlr.initChannelState( + request, clientID, sessionID, nick, + ) + } + hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, "nick": nick, @@ -480,6 +490,52 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc { } } +// 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) initChannelState( + request *http.Request, + clientID, sessionID int64, + nick string, +) { + ctx := request.Context() + + channels, err := hdlr.params.Database. + GetSessionChannels(ctx, sessionID) + if err != nil || len(channels) == 0 { + return + } + + for _, chanInfo := range channels { + // Enqueue a synthetic JOIN (only to this client). + dbID, _, insErr := hdlr.params.Database. + InsertMessage( + ctx, "JOIN", nick, chanInfo.Name, + nil, nil, nil, + ) + if insErr != nil { + hdlr.log.Error( + "initChannelState: insert JOIN", + "error", insErr, + ) + + continue + } + + _ = hdlr.params.Database.EnqueueToClient( + ctx, clientID, dbID, + ) + + // Enqueue TOPIC + NAMES numerics. + hdlr.deliverJoinNumerics( + request, clientID, sessionID, + nick, chanInfo.Name, chanInfo.ID, + ) + } +} + // HandleListAllChannels returns all channels on the server. func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc { return func( diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index af4010c..ceb320f 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -182,6 +182,12 @@ func (hdlr *Handlers) handleLogin( request, clientID, sessionID, payload.Nick, ) + // Initialize channel state so the new client knows + // which channels the session already belongs to. + hdlr.initChannelState( + request, clientID, sessionID, payload.Nick, + ) + hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, "nick": payload.Nick, diff --git a/web/dist/app.js b/web/dist/app.js deleted file mode 100644 index 65d6ad9..0000000 --- a/web/dist/app.js +++ /dev/null @@ -1,2 +0,0 @@ -var ie,T,He,pt,G,Pe,Ue,De,Re,ve,me,he,mt,ee={},Le=[],ht=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,_e=Array.isArray;function J(e,t){for(var n in t)e[n]=t[n];return e}function be(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function m(e,t,n){var o,s,a,_={};for(a in t)a=="key"?o=t[a]:a=="ref"?s=t[a]:_[a]=t[a];if(arguments.length>2&&(_.children=arguments.length>3?ie.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(a in e.defaultProps)_[a]===void 0&&(_[a]=e.defaultProps[a]);return ae(e,_,o,s,null)}function ae(e,t,n,o,s){var a={type:e,props:t,key:n,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s??++He,__i:-1,__u:0};return s==null&&T.vnode!=null&&T.vnode(a),a}function le(e){return e.children}function se(e,t){this.props=e,this.context=t}function Y(e,t){if(t==null)return e.__?Y(e.__,e.__i+1):null;for(var n;tu&&G.sort(De),e=G.shift(),u=G.length,e.__d&&(n=void 0,o=void 0,s=(o=(t=e).__v).__e,a=[],_=[],t.__P&&((n=J({},o)).__v=o.__v+1,T.vnode&&T.vnode(n),ke(t.__P,n,o,t.__n,t.__P.namespaceURI,32&o.__u?[s]:null,a,s??Y(o),!!(32&o.__u),_),n.__v=o.__v,n.__.__k[n.__i]=n,je(a,n,_),o.__e=o.__=null,n.__e!=s&&We(n)));ce.__r=0}function Fe(e,t,n,o,s,a,_,u,y,f,b){var c,h,v,C,E,A,g,w=o&&o.__k||Le,U=t.length;for(y=yt(n,t,w,y,U),c=0;c0?_=e.__k[a]=ae(_.type,_.props,_.key,_.ref?_.ref:null,_.__v):e.__k[a]=_,y=a+h,_.__=e,_.__b=e.__b+1,u=null,(f=_.__i=vt(_,n,y,c))!=-1&&(c--,(u=n[f])&&(u.__u|=2)),u==null||u.__v==null?(f==-1&&(s>b?h--:sy?h--:h++,_.__u|=4))):e.__k[a]=null;if(c)for(a=0;a(b?1:0)){for(s=n-1,a=n+1;s>=0||a=0?s--:a++])!=null&&(2&f.__u)==0&&u==f.key&&y==f.type)return _}return-1}function Ee(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||ht.test(t)?n:n+"px"}function oe(e,t,n,o,s){var a,_;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof o=="string"&&(e.style.cssText=o=""),o)for(t in o)n&&t in n||Ee(e.style,t,"");if(n)for(t in n)o&&n[t]==o[t]||Ee(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")a=t!=(t=t.replace(Re,"$1")),_=t.toLowerCase(),t=_ in e||t=="onFocusOut"||t=="onFocusIn"?_.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+a]=n,n?o?n.u=o.u:(n.u=ve,e.addEventListener(t,a?he:me,a)):e.removeEventListener(t,a?he:me,a);else{if(s=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function Me(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=ve++;else if(t.t0?e:_e(e)?e.map(Ve):J({},e)}function bt(e,t,n,o,s,a,_,u,y){var f,b,c,h,v,C,E,A=n.props||ee,g=t.props,w=t.type;if(w=="svg"?s="http://www.w3.org/2000/svg":w=="math"?s="http://www.w3.org/1998/Math/MathML":s||(s="http://www.w3.org/1999/xhtml"),a!=null){for(f=0;f=n.__.length&&n.__.push({}),n.__[e]}function $(e){return ne=1,gt(ot,e)}function gt(e,t,n){var o=Te(te++,2);if(o.t=e,!o.__c&&(o.__=[n?n(t):ot(void 0,t),function(u){var y=o.__N?o.__N[0]:o.__[0],f=o.t(y,u);y!==f&&(o.__N=[f,o.__[1]],o.__c.setState({}))}],o.__c=I,!I.__f)){var s=function(u,y,f){if(!o.__c.__H)return!0;var b=o.__c.__H.__.filter(function(h){return!!h.__c});if(b.every(function(h){return!h.__N}))return!a||a.call(this,u,y,f);var c=o.__c.props!==u;return b.forEach(function(h){if(h.__N){var v=h.__[0];h.__=h.__N,h.__N=void 0,v!==h.__[0]&&(c=!0)}}),a&&a.call(this,u,y,f)||c};I.__f=!0;var a=I.shouldComponentUpdate,_=I.componentWillUpdate;I.componentWillUpdate=function(u,y,f){if(this.__e){var b=a;a=void 0,s(u,y,f),a=b}_&&_.call(this,u,y,f)},I.shouldComponentUpdate=s}return o.__N||o.__}function H(e,t){var n=Te(te++,3);!O.__s&&rt(n.__H,t)&&(n.__=e,n.u=t,I.__H.__h.push(n))}function L(e){return ne=5,nt(function(){return{current:e}},[])}function nt(e,t){var n=Te(te++,7);return rt(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function X(e,t){return ne=8,nt(function(){return e},t)}function St(){for(var e;e=tt.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(ue),e.__H.__h.forEach(we),e.__H.__h=[]}catch(t){e.__H.__h=[],O.__e(t,e.__v)}}O.__b=function(e){I=null,Ge&&Ge(e)},O.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Ze&&Ze(e,t)},O.__r=function(e){ze&&ze(e),te=0;var t=(I=e.__c).__H;t&&(Se===I?(t.__h=[],I.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(ue),t.__h.forEach(we),t.__h=[],te=0)),Se=I},O.diffed=function(e){Qe&&Qe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(tt.push(t)!==1&&Ke===O.requestAnimationFrame||((Ke=O.requestAnimationFrame)||wt)(St)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),Se=I=null},O.__c=function(e,t){t.some(function(n){try{n.__h.forEach(ue),n.__h=n.__h.filter(function(o){return!o.__||we(o)})}catch(o){t.some(function(s){s.__h&&(s.__h=[])}),t=[],O.__e(o,n.__v)}}),Ye&&Ye(e,t)},O.unmount=function(e){Xe&&Xe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(o){try{ue(o)}catch(s){t=s}}),n.__H=void 0,t&&O.__e(t,n.__v))};var et=typeof requestAnimationFrame=="function";function wt(e){var t,n=function(){clearTimeout(o),et&&cancelAnimationFrame(t),setTimeout(e)},o=setTimeout(n,35);et&&(t=requestAnimationFrame(n))}function ue(e){var t=I,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),I=t}function we(e){var t=I;e.__c=e.__(),I=t}function rt(e,t){return!e||e.length!==t.length||t.some(function(n,o){return n!==e[o]})}function ot(e,t){return typeof t=="function"?t(e):t}var Tt="/api/v1",xt=15,Ct=3e3,It=1e4,Ce="ACTION ",Ie="";function x(e,t={}){let n=localStorage.getItem("neoirc_token"),o={"Content-Type":"application/json",...t.headers||{}};n&&(o.Authorization=`Bearer ${n}`);let{signal:s,...a}=t;return fetch(Tt+e,{...a,headers:o,signal:s}).then(async _=>{let u=await _.json().catch(()=>null);if(!_.ok)throw{status:_.status,data:u};return u})}function At(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1})}function xe(e){let t=0;for(let o=0;o{x("/server").then(h=>{h.name&&y(h.name),h.motd&&_(h.motd)}).catch(()=>{}),localStorage.getItem("neoirc_token")&&x("/state").then(h=>e(h.nick,!0)).catch(()=>localStorage.removeItem("neoirc_token")),f.current?.focus()},[]),m("div",{class:"login-screen"},m("div",{class:"login-box"},m("h1",null,u),a&&m("pre",{class:"motd"},a),m("form",{onSubmit:async c=>{c.preventDefault(),s("");try{let h=await x("/session",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("neoirc_token",h.token),e(h.nick)}catch(h){s(h.data?.error||"Connection failed")}}},m("label",null,"Nickname:"),m("input",{ref:f,type:"text",placeholder:"Enter nickname",value:t,onInput:c=>n(c.target.value),maxLength:32,autoFocus:!0}),m("button",{type:"submit"},"Connect")),o&&m("div",{class:"error"},o)))}function $t({msg:e,myNick:t}){let n=At(e.ts);return e.system?m("div",{class:"message system-message"},m("span",{class:"timestamp"},"[",n,"]"),m("span",{class:"system-text"}," * ",e.text)):e.isAction?m("div",{class:"message action-message"},m("span",{class:"timestamp"},"[",n,"]"),m("span",{class:"action-text"}," ","* ",m("span",{style:{color:xe(e.from)}},e.from)," ",e.text)):m("div",{class:"message"},m("span",{class:"timestamp"},"[",n,"]")," ",m("span",{class:"nick",style:{color:xe(e.from)}},"<",e.from,">")," ",m("span",{class:"content"},e.text))}function Et({members:e,onNickClick:t}){let n=[],o=[],s=[];for(let u of e){let y=u.mode||"";y==="o"?n.push(u):y==="v"?o.push(u):s.push(u)}let a=(u,y)=>u.nick.toLowerCase().localeCompare(y.nick.toLowerCase());n.sort(a),o.sort(a),s.sort(a);let _=(u,y)=>m("div",{class:"nick-entry",onClick:()=>t(u.nick),title:u.nick},m("span",{class:"nick-prefix"},y),m("span",{class:"nick-name",style:{color:xe(u.nick)}},u.nick));return m("div",{class:"user-list"},m("div",{class:"user-list-header"},e.length," user",e.length!==1?"s":""),m("div",{class:"user-list-entries"},n.map(u=>_(u,"@")),o.map(u=>_(u,"+")),s.map(u=>_(u,""))))}function Mt(){let[e,t]=$(!1),[n,o]=$(""),[s,a]=$([{type:"server",name:"Server"}]),[_,u]=$(0),[y,f]=$({Server:[]}),[b,c]=$({}),[h,v]=$({}),[C,E]=$({}),[A,g]=$(""),[w,U]=$(!0),[M,z]=$([]),[R,B]=$(-1),j=L(0),V=L(new Set),Q=L(null),P=L(s),W=L(_),re=L(n),Ae=L(),Z=L();H(()=>{P.current=s},[s]),H(()=>{W.current=_},[_]),H(()=>{re.current=n},[n]),H(()=>{let r=s.filter(i=>i.type==="channel").map(i=>i.name);localStorage.setItem("neoirc_channels",JSON.stringify(r))},[s]),H(()=>{let r=s[_];r&&E(i=>({...i,[r.name]:0}))},[_,s]);let N=X((r,i)=>{if(i.id&&V.current.has(i.id))return;i.id&&V.current.add(i.id),f(l=>({...l,[r]:[...l[r]||[],i]}));let d=P.current[W.current];(!d||d.name!==r)&&E(l=>({...l,[r]:(l[r]||0)+1}))},[]),S=X((r,i)=>{f(d=>({...d,[r]:[...d[r]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:i,system:!0}]}))},[]),K=X(r=>{let i=r.replace("#","");x(`/channels/${i}/members`).then(d=>{c(l=>({...l,[r]:d}))}).catch(()=>{})},[]),fe=X(r=>{let i=Array.isArray(r.body)?r.body.join(` -`):"",d={id:r.id,ts:r.ts,from:r.from,to:r.to,command:r.command};switch(r.command){case"PRIVMSG":case"NOTICE":{let l=i,p=!1;Ot(l)&&(l=Nt(l),p=!0);let k={...d,text:l,system:!1,isAction:p},F=r.to;if(F&&F.startsWith("#"))N(F,k);else{let D=r.from===re.current?r.to:r.from;a(pe=>pe.find(Ne=>Ne.type==="dm"&&Ne.name===D)?pe:[...pe,{type:"dm",name:D}]),N(D,k)}break}case"JOIN":{let l=`${r.from} has joined ${r.to}`;r.to&&N(r.to,{...d,text:l,system:!0}),r.to&&r.to.startsWith("#")&&K(r.to);break}case"PART":{let l=i?" ("+i+")":"",p=`${r.from} has parted ${r.to}${l}`;r.to&&N(r.to,{...d,text:p,system:!0}),r.to&&r.to.startsWith("#")&&K(r.to);break}case"QUIT":{let l=i?" ("+i+")":"",p=`${r.from} has quit${l}`;P.current.forEach(k=>{k.type==="channel"&&N(k.name,{...d,text:p,system:!0})});break}case"NICK":{let l=Array.isArray(r.body)?r.body[0]:i,p=`${r.from} is now known as ${l}`;P.current.forEach(k=>{k.type==="channel"&&N(k.name,{...d,text:p,system:!0})}),r.from===re.current&&l&&o(l),P.current.forEach(k=>{k.type==="channel"&&K(k.name)});break}case"TOPIC":{let l=`${r.from} has changed the topic to: ${i}`;r.to&&(N(r.to,{...d,text:l,system:!0}),v(p=>({...p,[r.to]:i})));break}case"353":{if(Array.isArray(r.params)&&r.params.length>=2&&r.body){let l=r.params[1],F=(Array.isArray(r.body)?r.body[0]:String(r.body)).split(/\s+/).filter(Boolean).map(D=>D.startsWith("@")?{nick:D.slice(1),mode:"o"}:D.startsWith("+")?{nick:D.slice(1),mode:"v"}:{nick:D,mode:""});c(D=>({...D,[l]:F}))}break}case"332":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0],p=Array.isArray(r.body)?r.body[0]:i;p&&v(k=>({...k,[l]:p}))}break}case"322":{if(Array.isArray(r.params)&&r.params.length>=2){let l=r.params[0],p=r.params[1];N("Server",{...d,text:`${l} (${p} users): ${(i||"").trim()}`,system:!0})}break}case"323":N("Server",{...d,text:i||"End of channel list",system:!0});break;case"352":{if(Array.isArray(r.params)&&r.params.length>=5){let l=r.params[0],p=r.params[4],k=r.params.length>5?r.params[5]:"";N("Server",{...d,text:`${l} ${p} ${k}`,system:!0})}break}case"315":N("Server",{...d,text:i||"End of /WHO list",system:!0});break;case"311":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0];N("Server",{...d,text:`${l} (${i})`,system:!0})}break}case"312":{if(Array.isArray(r.params)&&r.params.length>=2){let l=r.params[0],p=r.params[1];N("Server",{...d,text:`${l} on ${p}`,system:!0})}break}case"319":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0];N("Server",{...d,text:`${l} is on: ${i}`,system:!0})}break}case"318":N("Server",{...d,text:i||"End of /WHOIS list",system:!0});break;case"375":case"372":case"376":N("Server",{...d,text:i,system:!0});break;default:i&&N("Server",{...d,text:i,system:!0})}},[N,K]);H(()=>{if(!e)return;let r=!0;return(async()=>{for(;r;)try{let d=new AbortController;Q.current=d;let l=await x(`/messages?after=${j.current}&timeout=${xt}`,{signal:d.signal});if(!r)break;if(U(!0),l.messages)for(let p of l.messages)fe(p);l.last_id>j.current&&(j.current=l.last_id)}catch(d){if(!r)break;if(d.name==="AbortError")continue;U(!1),await new Promise(l=>setTimeout(l,Ct))}})(),()=>{r=!1,Q.current?.abort()}},[e,fe]),H(()=>{if(!e)return;let r=s[_];if(!r||r.type!=="channel")return;K(r.name);let i=setInterval(()=>K(r.name),It);return()=>clearInterval(i)},[e,_,s,K]),H(()=>{Ae.current?.scrollIntoView({behavior:"smooth"})},[y,_]),H(()=>{Z.current?.focus()},[_]),H(()=>{let r=i=>{i.key==="/"&&document.activeElement!==Z.current&&!i.ctrlKey&&!i.altKey&&!i.metaKey&&(i.preventDefault(),Z.current?.focus())};return document.addEventListener("keydown",r),Z.current?.focus(),()=>document.removeEventListener("keydown",r)},[]),H(()=>{if(!e)return;let r=s[_];!r||r.type!=="channel"||x("/channels").then(i=>{let d=i.find(l=>l.name===r.name);d&&d.topic&&v(l=>({...l,[r.name]:d.topic}))}).catch(()=>{})},[e,_,s]);let at=X(async(r,i)=>{if(o(r),t(!0),S("Server",`Connected as ${r}`),i)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MOTD"})})}catch{}let d=JSON.parse(localStorage.getItem("neoirc_channels")||"[]");for(let l of d)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:l})}),a(p=>p.find(k=>k.type==="channel"&&k.name===l)?p:[...p,{type:"channel",name:l}])}catch{}},[S]),st=async r=>{if(r){r=r.trim(),r.startsWith("#")||(r="#"+r);try{await x("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:r})}),a(d=>d.find(l=>l.type==="channel"&&l.name===r)?d:[...d,{type:"channel",name:r}]);let i=P.current.length;u(i);try{let d=await x(`/history?target=${encodeURIComponent(r)}&limit=50`);if(Array.isArray(d))for(let l of d)fe(l)}catch{}}catch(i){S("Server",`Failed to join ${r}: ${i.data?.error||"error"}`)}}},Oe=async(r,i)=>{try{await x("/messages",{method:"POST",body:JSON.stringify(i?{command:"PART",to:r,body:[i]}:{command:"PART",to:r})})}catch{}a(d=>d.filter(l=>!(l.type==="channel"&&l.name===r))),u(0)},ct=r=>{let i=s[r];i.type==="channel"?Oe(i.name):i.type==="dm"&&(a(d=>d.filter((l,p)=>p!==r)),_>=r&&u(Math.max(0,_-1)))},de=r=>{if(r===re.current)return;a(d=>d.find(l=>l.type==="dm"&&l.name===r)?d:[...d,{type:"dm",name:r}]);let i=s.findIndex(d=>d.type==="dm"&&d.name===r);u(i>=0?i:s.length)},it=async r=>{let i=r.split(" "),d=i[0].toLowerCase(),l=s[_];switch(d){case"/join":{i[1]?st(i[1]):S("Server","Usage: /join #channel");break}case"/part":{if(l.type==="channel"){let p=i.slice(1).join(" ")||void 0;Oe(l.name,p)}else S("Server","You are not in a channel");break}case"/msg":{if(i[1]&&i.slice(2).join(" ")){let p=i[1],k=i.slice(2).join(" ");try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:p,body:[k]})}),de(p)}catch(F){S("Server",`Message failed: ${F.data?.error||"error"}`)}}else S("Server","Usage: /msg ");break}case"/me":{if(l.type==="server"){S("Server","Cannot use /me in server window");break}let p=i.slice(1).join(" ");if(p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:l.name,body:[Ce+p+Ie]})})}catch(k){S(l.name,`Action failed: ${k.data?.error||"error"}`)}else S("Server","Usage: /me ");break}case"/nick":{if(i[1])try{await x("/messages",{method:"POST",body:JSON.stringify({command:"NICK",body:[i[1]]})})}catch(p){S("Server",`Nick change failed: ${p.data?.error||"error"}`)}else S("Server","Usage: /nick ");break}case"/topic":{if(l.type!=="channel"){S("Server","You are not in a channel");break}let p=i.slice(1).join(" ");if(p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:l.name,body:[p]})})}catch(k){S("Server",`Topic change failed: ${k.data?.error||"error"}`)}else S("Server",`Current topic for ${l.name}: ${h[l.name]||"(none)"}`);break}case"/mode":{if(l.type!=="channel"){S("Server","You are not in a channel");break}let p=i.slice(1);if(p.length>0)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MODE",to:l.name,params:p})})}catch(k){S("Server",`Mode change failed: ${k.data?.error||"error"}`)}else S("Server","Usage: /mode <+/-mode> [params]");break}case"/quit":{let p=i.slice(1).join(" ")||void 0;try{await x("/messages",{method:"POST",body:JSON.stringify(p?{command:"QUIT",body:[p]}:{command:"QUIT"})})}catch{}localStorage.removeItem("neoirc_token"),localStorage.removeItem("neoirc_channels"),window.location.reload();break}case"/motd":{try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MOTD"})})}catch(p){S("Server",`Failed to request MOTD: ${p.data?.error||"error"}`)}break}case"/query":{if(i[1]){let p=i[1];de(p);let k=i.slice(2).join(" ");if(k)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:p,body:[k]})})}catch(F){S("Server",`Message failed: ${F.data?.error||"error"}`)}}else S("Server","Usage: /query [message]");break}case"/list":{try{await x("/messages",{method:"POST",body:JSON.stringify({command:"LIST"})})}catch(p){S("Server",`Failed to list channels: ${p.data?.error||"error"}`)}break}case"/who":{let p=i[1]||(l.type==="channel"?l.name:"");if(p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"WHO",to:p})})}catch(k){S("Server",`WHO failed: ${k.data?.error||"error"}`)}else S("Server","Usage: /who #channel");break}case"/whois":{if(i[1])try{await x("/messages",{method:"POST",body:JSON.stringify({command:"WHOIS",to:i[1]})})}catch(p){S("Server",`WHOIS failed: ${p.data?.error||"error"}`)}else S("Server","Usage: /whois ");break}case"/clear":{let p=l.name;f(k=>({...k,[p]:[]}));break}case"/help":{let p=["Available commands:"," /join #channel \u2014 Join a channel"," /part [reason] \u2014 Part the current channel"," /msg nick message \u2014 Send a private message"," /query nick [message] \u2014 Open a DM tab (optionally send a message)"," /me action \u2014 Send an action"," /nick newnick \u2014 Change your nickname"," /topic [text] \u2014 View or set channel topic"," /mode +/-flags \u2014 Set channel modes"," /motd \u2014 Display the message of the day"," /list \u2014 List all channels"," /who [#channel] \u2014 List users in a channel"," /whois nick \u2014 Show info about a user"," /clear \u2014 Clear messages in the current tab"," /quit [reason] \u2014 Disconnect from server"," /help \u2014 Show this help"];for(let k of p)S("Server",k);break}default:S("Server",`Unknown command: ${d}`)}},_t=async()=>{let r=A.trim();if(!r)return;z(d=>{let l=[...d,r];return l.length>100&&l.shift(),l}),B(-1),g("");let i=s[_];if(i){if(r.startsWith("/")){await it(r);return}if(i.type==="server"){S("Server","Cannot send messages to the server window. Use /join #channel first.");return}try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:i.name,body:[r]})})}catch(d){S(i.name,`Send failed: ${d.data?.error||"error"}`)}}},lt=r=>{if(r.key==="Enter")_t();else if(r.key==="ArrowUp"){if(r.preventDefault(),M.length>0){let i=R===-1?M.length-1:Math.max(0,R-1);B(i),g(M[i])}}else if(r.key==="ArrowDown"&&(r.preventDefault(),R>=0)){let i=R+1;i>=M.length?(B(-1),g("")):(B(i),g(M[i]))}};if(!e)return m(Pt,{onLogin:at});let q=s[_]||s[0],ut=y[q.name]||[],ft=b[q.name]||[],dt=h[q.name]||"";return m("div",{class:"irc-app"},m("div",{class:"tab-bar"},m("div",{class:"tabs"},s.map((r,i)=>m("div",{class:`tab ${i===_?"active":""} ${C[r.name]>0&&i!==_?"has-unread":""}`,onClick:()=>u(i),key:r.name},m("span",{class:"tab-label"},(r.type==="dm",r.name)),C[r.name]>0&&i!==_&&m("span",{class:"unread-count"},"(",C[r.name],")"),r.type!=="server"&&m("span",{class:"tab-close",onClick:d=>{d.stopPropagation(),ct(i)}},"\xD7")))),m("div",{class:"status-area"},!w&&m("span",{class:"status-warn"},"\u25CF Reconnecting"),m("span",{class:"status-nick"},n))),q.type==="channel"&&m("div",{class:"topic-bar"},m("span",{class:"topic-label"},"Topic:")," ",m("span",{class:"topic-text"},dt||"(no topic set)")),m("div",{class:"main-area"},m("div",{class:"messages-panel"},m("div",{class:"messages-scroll"},ut.map(r=>m($t,{msg:r,myNick:n,key:r.id})),m("div",{ref:Ae}))),q.type==="channel"&&m(Et,{members:ft,onNickClick:de})),m("div",{class:"input-line"},m("span",{class:"input-prompt"},"[",n,"]",q.type!=="server"?` ${q.name}`:""," >"),m("input",{ref:Z,type:"text",value:A,onInput:r=>g(r.target.value),onKeyDown:lt,placeholder:q.type==="server"?"Type /help for commands":"",spellCheck:!1,autoComplete:"off"})))}Be(m(Mt,null),document.getElementById("root")); diff --git a/web/dist/index.html b/web/dist/index.html deleted file mode 100644 index 13cfc93..0000000 --- a/web/dist/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - NeoIRC - - - -
- - - diff --git a/web/dist/style.css b/web/dist/style.css deleted file mode 100644 index f8b3784..0000000 --- a/web/dist/style.css +++ /dev/null @@ -1,466 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - --bg: #0a0e14; - --bg-panel: #0d1117; - --bg-input: #0d1117; - --bg-tab: #161b22; - --bg-tab-active: #0d1117; - --bg-topic: #0d1117; - --text: #c9d1d9; - --text-dim: #6e7681; - --text-bright: #e6edf3; - --accent: #58a6ff; - --accent-dim: #1f6feb; - --border: #21262d; - --system: #7d8590; - --action: #d2a8ff; - --warn: #d29922; - --error: #f85149; - --unread: #f0883e; - --nick-brackets: #6e7681; - --timestamp: #484f58; - --input-bg: #161b22; - --prompt: #3fb950; - --tab-indicator: #58a6ff; - --user-list-bg: #0d1117; - --user-list-header: #484f58; -} - -html, -body, -#root { - height: 100%; - font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono", - "Consolas", "Liberation Mono", "Courier New", monospace; - font-size: 13px; - background: var(--bg); - color: var(--text); - overflow: hidden; -} - -/* ============================================ - Login Screen - ============================================ */ - -.login-screen { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - background: var(--bg); -} - -.login-box { - text-align: center; - max-width: 360px; - width: 100%; - padding: 32px; -} - -.login-box h1 { - color: var(--accent); - font-size: 1.8em; - margin-bottom: 16px; - font-weight: 400; -} - -.login-box .motd { - color: var(--accent); - font-size: 11px; - margin-bottom: 20px; - text-align: left; - white-space: pre; - font-family: inherit; - line-height: 1.2; - overflow-x: auto; -} - -.login-box form { - display: flex; - flex-direction: column; - gap: 8px; - align-items: stretch; -} - -.login-box label { - color: var(--text-dim); - text-align: left; - font-size: 12px; -} - -.login-box input { - padding: 8px 12px; - font-family: inherit; - font-size: 14px; - background: var(--input-bg); - border: 1px solid var(--border); - color: var(--text-bright); - border-radius: 3px; - outline: none; -} - -.login-box input:focus { - border-color: var(--accent-dim); -} - -.login-box button { - padding: 8px 16px; - font-family: inherit; - font-size: 14px; - background: var(--accent-dim); - border: none; - color: var(--text-bright); - border-radius: 3px; - cursor: pointer; - margin-top: 4px; -} - -.login-box button:hover { - background: var(--accent); -} - -.login-box .error { - color: var(--error); - font-size: 12px; - margin-top: 8px; -} - -/* ============================================ - IRC App Layout - ============================================ */ - -.irc-app { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -/* ============================================ - Tab Bar - ============================================ */ - -.tab-bar { - display: flex; - background: var(--bg-tab); - border-bottom: 1px solid var(--border); - flex-shrink: 0; - height: 32px; - align-items: stretch; -} - -.tabs { - display: flex; - overflow-x: auto; - flex: 1; - scrollbar-width: none; -} - -.tabs::-webkit-scrollbar { - display: none; -} - -.tab { - display: flex; - align-items: center; - padding: 0 12px; - cursor: pointer; - color: var(--text-dim); - white-space: nowrap; - user-select: none; - border-right: 1px solid var(--border); - font-size: 12px; - gap: 4px; - position: relative; -} - -.tab:hover { - color: var(--text); - background: rgba(255, 255, 255, 0.03); -} - -.tab.active { - color: var(--text-bright); - background: var(--bg-tab-active); - border-bottom: 2px solid var(--tab-indicator); - margin-bottom: -1px; -} - -.tab.has-unread .tab-label { - color: var(--unread); - font-weight: bold; -} - -.tab .unread-count { - color: var(--unread); - font-size: 11px; - font-weight: bold; -} - -.tab-close { - color: var(--text-dim); - font-size: 14px; - line-height: 1; - margin-left: 2px; -} - -.tab-close:hover { - color: var(--error); -} - -.status-area { - display: flex; - align-items: center; - gap: 10px; - padding: 0 12px; - flex-shrink: 0; - font-size: 12px; -} - -.status-nick { - color: var(--accent); - font-weight: bold; -} - -.status-warn { - color: var(--warn); - animation: blink 1.5s ease-in-out infinite; -} - -@keyframes blink { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.4; - } -} - -/* ============================================ - Topic Bar - ============================================ */ - -.topic-bar { - padding: 4px 12px; - background: var(--bg-topic); - border-bottom: 1px solid var(--border); - font-size: 12px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex-shrink: 0; - line-height: 1.5; -} - -.topic-label { - color: var(--text-dim); -} - -.topic-text { - color: var(--text); -} - -/* ============================================ - Main Content Area - ============================================ */ - -.main-area { - display: flex; - flex: 1; - overflow: hidden; - min-height: 0; -} - -/* ============================================ - Messages Panel - ============================================ */ - -.messages-panel { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - min-width: 0; -} - -.messages-scroll { - flex: 1; - overflow-y: auto; - padding: 4px 8px; - scrollbar-width: thin; - scrollbar-color: var(--border) transparent; -} - -.messages-scroll::-webkit-scrollbar { - width: 8px; -} - -.messages-scroll::-webkit-scrollbar-track { - background: transparent; -} - -.messages-scroll::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -/* ============================================ - Message Lines - ============================================ */ - -.message { - padding: 1px 0; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; - font-size: 13px; -} - -.message .timestamp { - color: var(--timestamp); - font-size: 12px; -} - -.message .nick { - font-weight: bold; -} - -.message .content { - color: var(--text); -} - -/* System messages (joins, parts, quits, etc.) */ -.system-message { - color: var(--system); -} - -.system-message .system-text { - color: var(--system); -} - -/* /me action messages */ -.action-message .action-text { - color: var(--action); -} - -/* ============================================ - User List (Right Panel) - ============================================ */ - -.user-list { - width: 160px; - background: var(--user-list-bg); - border-left: 1px solid var(--border); - display: flex; - flex-direction: column; - flex-shrink: 0; - overflow: hidden; -} - -.user-list-header { - padding: 6px 10px; - color: var(--user-list-header); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.5px; - border-bottom: 1px solid var(--border); - flex-shrink: 0; -} - -.user-list-entries { - overflow-y: auto; - padding: 4px 0; - flex: 1; - scrollbar-width: thin; - scrollbar-color: var(--border) transparent; -} - -.nick-entry { - padding: 2px 10px; - font-size: 12px; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.5; -} - -.nick-entry:hover { - background: rgba(255, 255, 255, 0.04); -} - -.nick-prefix { - color: var(--text-dim); - display: inline-block; - width: 1ch; - text-align: right; - margin-right: 1px; -} - -.nick-name { - font-weight: normal; -} - -/* ============================================ - Input Line (Bottom) - ============================================ */ - -.input-line { - display: flex; - align-items: center; - background: var(--input-bg); - border-top: 1px solid var(--border); - flex-shrink: 0; - height: 36px; - padding: 0 8px; - gap: 6px; -} - -.input-prompt { - color: var(--prompt); - font-size: 13px; - flex-shrink: 0; - white-space: nowrap; -} - -.input-line input { - flex: 1; - padding: 4px 0; - font-family: inherit; - font-size: 13px; - background: transparent; - border: none; - color: var(--text-bright); - outline: none; - caret-color: var(--accent); -} - -.input-line input::placeholder { - color: var(--text-dim); - font-style: italic; -} - -/* ============================================ - Responsive - ============================================ */ - -@media (max-width: 600px) { - .user-list { - display: none; - } - - .tab { - padding: 0 8px; - font-size: 11px; - } - - .input-prompt { - font-size: 12px; - } -} diff --git a/web/src/app.jsx b/web/src/app.jsx index ea8f357..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") + api("/state?initChannelState=1") .then((u) => onLogin(u.nick, true)) .catch(() => localStorage.removeItem("neoirc_token")); } @@ -333,7 +333,24 @@ 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 && msg.to.startsWith("#")) refreshMembers(msg.to); + if (msg.to && msg.to.startsWith("#")) { + // Create a tab when the current user joins a channel + // (including JOINs from initChannelState on reconnect). + if (msg.from === nickRef.current) { + setTabs((prev) => { + if ( + prev.find( + (t) => t.type === "channel" && t.name === msg.to, + ) + ) + return prev; + + return [...prev, { type: "channel", name: msg.to }]; + }); + } + + refreshMembers(msg.to); + } break; } @@ -636,9 +653,13 @@ function App() { setLoggedIn(true); addSystemMessage("Server", `Connected as ${userNick}`); - // Request MOTD on resumed sessions (new sessions get - // it automatically from the server during creation). if (isResumed) { + // Request MOTD on resumed sessions (new sessions + // get it automatically from the server during + // 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", @@ -647,8 +668,11 @@ function App() { } catch (e) { // MOTD is non-critical. } + + return; } + // Fresh session — join any previously saved channels. const saved = JSON.parse( localStorage.getItem("neoirc_channels") || "[]", );