Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
706f5f6dcc | ||
|
|
f287fdf6d1 |
No files matched your search
@@ -21,6 +21,7 @@ node_modules/
|
||||
*.key
|
||||
|
||||
# Build artifacts
|
||||
web/dist/
|
||||
/neoircd
|
||||
/bin/
|
||||
*.exe
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-3
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,11 @@ import (
|
||||
|
||||
const routeTimeout = 60 * time.Second
|
||||
|
||||
// cspHeader is the Content-Security-Policy applied to the embedded web SPA.
|
||||
// The SPA loads external scripts and stylesheets from the same origin only;
|
||||
// all API communication uses same-origin fetch (no WebSockets).
|
||||
const cspHeader = "default-src 'self'; script-src 'self'; style-src 'self'"
|
||||
|
||||
// SetupRoutes configures the HTTP routes and middleware.
|
||||
func (srv *Server) SetupRoutes() {
|
||||
srv.router = chi.NewRouter()
|
||||
@@ -133,6 +138,11 @@ func (srv *Server) setupSPA() {
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
writer.Header().Set(
|
||||
"Content-Security-Policy",
|
||||
cspHeader,
|
||||
)
|
||||
|
||||
readFS, ok := distFS.(fs.ReadFileFS)
|
||||
if !ok {
|
||||
fileServer.ServeHTTP(writer, request)
|
||||
|
||||
Vendored
-2
@@ -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;t<e.__k.length;t++)if((n=e.__k[t])!=null&&n.__e!=null)return n.__e;return typeof e.type=="function"?Y(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 $e(e){(!e.__d&&(e.__d=!0)&&G.push(e)&&!ce.__r++||Pe!=T.debounceRendering)&&((Pe=T.debounceRendering)||Ue)(ce)}function ce(){for(var e,t,n,o,s,a,_,u=1;G.length;)G.length>u&&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;c<U;c++)(v=n.__k[c])!=null&&(h=v.__i==-1?ee:w[v.__i]||ee,v.__i=c,A=ke(e,v,h,s,a,_,u,y,f,b),C=v.__e,v.ref&&h.ref!=v.ref&&(h.ref&&ge(h.ref,null,v),b.push(v.ref,v.__c||C,v)),E==null&&C!=null&&(E=C),(g=!!(4&v.__u))||h.__k===v.__k?y=Je(v,y,e,g):typeof v.type=="function"&&A!==void 0?y=A:C&&(y=C.nextSibling),v.__u&=-7);return n.__e=E,y}function yt(e,t,n,o,s){var a,_,u,y,f,b=n.length,c=b,h=0;for(e.__k=new Array(s),a=0;a<s;a++)(_=t[a])!=null&&typeof _!="boolean"&&typeof _!="function"?(typeof _=="string"||typeof _=="number"||typeof _=="bigint"||_.constructor==String?_=e.__k[a]=ae(null,_,null,null,null):_e(_)?_=e.__k[a]=ae(le,{children:_},null,null,null):_.constructor===void 0&&_.__b>0?_=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--:s<b&&h++),typeof _.type!="function"&&(_.__u|=4)):f!=y&&(f==y-1?h--:f==y+1?h++:(f>y?h--:h++,_.__u|=4))):e.__k[a]=null;if(c)for(a=0;a<b;a++)(u=n[a])!=null&&(2&u.__u)==0&&(u.__e==o&&(o=Y(u)),qe(u,u));return o}function Je(e,t,n,o){var s,a;if(typeof e.type=="function"){for(s=e.__k,a=0;s&&a<s.length;a++)s[a]&&(s[a].__=e,t=Je(s[a],t,n,o));return t}e.__e!=t&&(o&&(t&&e.type&&!t.parentNode&&(t=Y(e)),n.insertBefore(e.__e,t||null)),t=e.__e);do t=t&&t.nextSibling;while(t!=null&&t.nodeType==8);return t}function vt(e,t,n,o){var s,a,_,u=e.key,y=e.type,f=t[n],b=f!=null&&(2&f.__u)==0;if(f===null&&u==null||b&&u==f.key&&y==f.type)return n;if(o>(b?1:0)){for(s=n-1,a=n+1;s>=0||a<t.length;)if((f=t[_=s>=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.t<n.u)return;return n(T.event?T.event(t):t)}}}function ke(e,t,n,o,s,a,_,u,y,f){var b,c,h,v,C,E,A,g,w,U,M,z,R,B,j,V,Q,P=t.type;if(t.constructor!==void 0)return null;128&n.__u&&(y=!!(32&n.__u),a=[u=t.__e=n.__e]),(b=T.__b)&&b(t);e:if(typeof P=="function")try{if(g=t.props,w="prototype"in P&&P.prototype.render,U=(b=P.contextType)&&o[b.__c],M=b?U?U.props.value:b.__:o,n.__c?A=(c=t.__c=n.__c).__=c.__E:(w?t.__c=c=new P(g,M):(t.__c=c=new se(g,M),c.constructor=P,c.render=kt),U&&U.sub(c),c.state||(c.state={}),c.__n=o,hLine truncated
|
||||
`):"",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
Line truncated
|
||||
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NeoIRC</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
-466
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
-4
@@ -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") || "[]",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user