Compare commits
1 Commits
bc8df117ab
...
fix/29-spl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b42620749 |
@@ -1,8 +1,8 @@
|
||||
.git
|
||||
*.md
|
||||
!README.md
|
||||
neoircd
|
||||
neoirc-cli
|
||||
chatd
|
||||
chat-cli
|
||||
data.db
|
||||
data.db-wal
|
||||
data.db-shm
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,7 +21,7 @@ node_modules/
|
||||
*.key
|
||||
|
||||
# Build artifacts
|
||||
/neoircd
|
||||
/chatd
|
||||
/bin/
|
||||
*.exe
|
||||
*.dll
|
||||
@@ -34,5 +34,5 @@ vendor/
|
||||
# Project
|
||||
data.db
|
||||
debug.log
|
||||
/neoirc-cli
|
||||
/chat-cli
|
||||
web/node_modules/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
1. **Format**: `gofmt -s -w .` and `goimports -w .`
|
||||
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
|
||||
3. **Test**: `go test -race ./...` — all passing
|
||||
4. **Build**: `go build ./cmd/neoircd` — compiles clean
|
||||
4. **Build**: `go build ./cmd/chatd` — compiles clean
|
||||
|
||||
No commit lands on main with lint errors, test failures, or formatting issues.
|
||||
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
||||
# golangci/golangci-lint:v2.1.6
|
||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
@@ -8,13 +8,13 @@ COPY . .
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
|
||||
# Build stage
|
||||
# Build stage — tests and compilation
|
||||
# golang:1.24-alpine, 2026-02-26
|
||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git build-base make
|
||||
|
||||
# Force BuildKit to run the lint stage before proceeding
|
||||
# Force BuildKit to run the lint stage by creating a stage dependency
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
@@ -26,20 +26,17 @@ RUN make test
|
||||
|
||||
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-02-26
|
||||
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S neoirc && adduser -S neoirc -G neoirc \
|
||||
&& mkdir -p /var/lib/neoirc \
|
||||
&& chown neoirc:neoirc /var/lib/neoirc
|
||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||
&& addgroup -S chat && adduser -S chat -G chat
|
||||
COPY --from=builder /chatd /usr/local/bin/chatd
|
||||
|
||||
USER neoirc
|
||||
USER chat
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
|
||||
ENTRYPOINT ["neoircd"]
|
||||
ENTRYPOINT ["chatd"]
|
||||
|
||||
10
Makefile
10
Makefile
@@ -1,6 +1,6 @@
|
||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
||||
|
||||
BINARY := neoircd
|
||||
BINARY := chatd
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
BUILDARCH := $(shell go env GOARCH)
|
||||
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
@@ -8,7 +8,7 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
all: check build
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
|
||||
|
||||
lint:
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
@@ -27,7 +27,7 @@ test:
|
||||
# Used by CI and Docker build — fails if anything is wrong
|
||||
check: test lint fmt-check
|
||||
@echo "==> Building..."
|
||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd
|
||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
|
||||
@echo "==> All checks passed!"
|
||||
|
||||
run: build
|
||||
@@ -37,10 +37,10 @@ debug: build
|
||||
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
||||
|
||||
clean:
|
||||
rm -rf bin/ neoircd
|
||||
rm -rf bin/ chatd data.db
|
||||
|
||||
docker:
|
||||
docker build -t neoirc .
|
||||
docker build -t chat .
|
||||
|
||||
hooks:
|
||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
||||
|
||||
276
README.md
276
README.md
@@ -1,9 +1,9 @@
|
||||
# neoirc
|
||||
# chat
|
||||
|
||||
**IRC semantics, structured message metadata, cryptographic signing, and
|
||||
server-held session state with per-client delivery queues. All over HTTP+JSON.**
|
||||
|
||||
An IRC-inspired server written in Go that decouples session state from transport
|
||||
A chat server written in Go that decouples session state from transport
|
||||
connections, enabling mobile-friendly persistent sessions over plain HTTP.
|
||||
|
||||
The **HTTP API is the primary interface**. It's designed to be simple enough
|
||||
@@ -44,7 +44,7 @@ IRC is in decline because session state is tied to the TCP connection. In a
|
||||
mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer
|
||||
or pay for IRCCloud.
|
||||
|
||||
This project builds a server that:
|
||||
This project builds a chat server that:
|
||||
|
||||
- Holds session state server-side (message queues, presence, channel membership)
|
||||
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against
|
||||
@@ -132,7 +132,7 @@ makes signing consistent — you sign the same structure you send.
|
||||
|
||||
### Why not XMPP or Matrix?
|
||||
|
||||
XMPP is XML-based, overengineered for messaging, and the ecosystem is fragmented
|
||||
XMPP is XML-based, overengineered for chat, and the ecosystem is fragmented
|
||||
across incompatible extensions (XEPs). Matrix is a federated append-only event
|
||||
graph with a spec that runs to hundreds of pages. Both are fine protocols, but
|
||||
they're solving different problems at different scales.
|
||||
@@ -764,98 +764,21 @@ not pollute the message queue.
|
||||
|
||||
**IRC reference:** RFC 1459 §4.6.2, §4.6.3
|
||||
|
||||
#### MODE — Query Modes
|
||||
#### MODE — Set/Query Modes (Planned)
|
||||
|
||||
Query channel or user modes. Returns the current mode string and, for
|
||||
channels, the creation timestamp.
|
||||
Set channel or user modes.
|
||||
|
||||
**C2S:**
|
||||
```json
|
||||
{"command": "MODE", "to": "#general"}
|
||||
{"command": "MODE", "to": "alice"}
|
||||
{"command": "MODE", "to": "#general", "params": ["+m"]}
|
||||
{"command": "MODE", "to": "#general", "params": ["+o", "alice"]}
|
||||
```
|
||||
|
||||
**S2C (via message queue):**
|
||||
|
||||
For channels, the server sends RPL_CHANNELMODEIS (324) and
|
||||
RPL_CREATIONTIME (329):
|
||||
```json
|
||||
{"command": "324", "to": "alice", "params": ["#general", "+n"]}
|
||||
{"command": "329", "to": "alice", "params": ["#general", "1709251200"]}
|
||||
```
|
||||
|
||||
For users, the server sends RPL_UMODEIS (221):
|
||||
```json
|
||||
{"command": "221", "to": "alice", "body": ["+"]}
|
||||
```
|
||||
|
||||
**Note:** Mode changes (setting/unsetting modes) are not yet implemented.
|
||||
Currently only query is supported.
|
||||
**Status:** Not yet implemented. See [Channel Modes](#channel-modes) for the
|
||||
planned mode set.
|
||||
|
||||
**IRC reference:** RFC 1459 §4.2.3
|
||||
|
||||
#### NAMES — Channel Member List
|
||||
|
||||
Request the member list for a channel. Returns RPL_NAMREPLY (353) and
|
||||
RPL_ENDOFNAMES (366).
|
||||
|
||||
**C2S:**
|
||||
```json
|
||||
{"command": "NAMES", "to": "#general"}
|
||||
```
|
||||
|
||||
**IRC reference:** RFC 1459 §4.2.5
|
||||
|
||||
#### LIST — List Channels
|
||||
|
||||
Request a list of all channels with member counts. Returns RPL_LIST (322)
|
||||
for each channel followed by RPL_LISTEND (323).
|
||||
|
||||
**C2S:**
|
||||
```json
|
||||
{"command": "LIST"}
|
||||
```
|
||||
|
||||
**IRC reference:** RFC 1459 §4.2.6
|
||||
|
||||
#### WHOIS — User Information
|
||||
|
||||
Query information about a user. Returns RPL_WHOISUSER (311),
|
||||
RPL_WHOISSERVER (312), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
|
||||
|
||||
**C2S:**
|
||||
```json
|
||||
{"command": "WHOIS", "to": "alice"}
|
||||
```
|
||||
|
||||
**IRC reference:** RFC 1459 §4.5.2
|
||||
|
||||
#### WHO — Channel User List
|
||||
|
||||
Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed
|
||||
by RPL_ENDOFWHO (315).
|
||||
|
||||
**C2S:**
|
||||
```json
|
||||
{"command": "WHO", "to": "#general"}
|
||||
```
|
||||
|
||||
**IRC reference:** RFC 1459 §4.5.1
|
||||
|
||||
#### LUSERS — Server Statistics
|
||||
|
||||
Request server user/channel statistics. Returns RPL_LUSERCLIENT (251),
|
||||
RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255).
|
||||
|
||||
**C2S:**
|
||||
```json
|
||||
{"command": "LUSERS"}
|
||||
```
|
||||
|
||||
LUSERS replies are also sent automatically during connection registration.
|
||||
|
||||
**IRC reference:** RFC 1459 §4.3.2
|
||||
|
||||
#### KICK — Kick User (Planned)
|
||||
|
||||
Remove a user from a channel.
|
||||
@@ -905,46 +828,27 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
||||
| Code | Name | When Sent | Example |
|
||||
|------|----------------------|-----------|---------|
|
||||
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
|
||||
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
|
||||
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is chatserver, running version 0.1"]}` |
|
||||
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
|
||||
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","imnst"]}` |
|
||||
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","NETWORK=neoirc"],"body":["are supported by this server"]}` |
|
||||
| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` |
|
||||
| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
|
||||
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
|
||||
| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` |
|
||||
| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` |
|
||||
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bob","neoirc","*"],"body":["bob"]}` |
|
||||
| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
|
||||
| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` |
|
||||
| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` |
|
||||
| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` |
|
||||
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
|
||||
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["chatserver","0.1","","imnst"]}` |
|
||||
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General chat"]}` |
|
||||
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
|
||||
| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` |
|
||||
| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` |
|
||||
| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` |
|
||||
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
|
||||
| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bob","neoirc","neoirc","bob","H"],"body":["0 bob"]}` |
|
||||
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
|
||||
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
|
||||
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
|
||||
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` |
|
||||
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- chatserver Message of the Day -"]}` |
|
||||
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
|
||||
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
|
||||
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
|
||||
| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` |
|
||||
| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` |
|
||||
| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` |
|
||||
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
|
||||
| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` |
|
||||
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
|
||||
|
||||
**Note:** Numeric replies are now implemented. All IRC command responses
|
||||
(success and error) are delivered as numeric replies through the message queue.
|
||||
HTTP error codes are reserved for transport-level issues (auth failures,
|
||||
malformed requests, server errors). The `params` field in the message envelope
|
||||
carries IRC-style parameters (e.g., channel name, target nick).
|
||||
**Note:** Numeric replies are planned for full implementation. The current MVP
|
||||
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead
|
||||
of numeric replies for error conditions. Numeric replies in the message queue
|
||||
will be added post-MVP.
|
||||
|
||||
### Channel Modes
|
||||
|
||||
@@ -1150,78 +1054,27 @@ reference with all required and optional fields.
|
||||
|
||||
| Command | Required Fields | Optional | Response Status |
|
||||
|-----------|---------------------|---------------|-----------------|
|
||||
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
|
||||
| `NOTICE` | `to`, `body` | `meta` | 200 OK |
|
||||
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created |
|
||||
| `NOTICE` | `to`, `body` | `meta` | 201 Created |
|
||||
| `JOIN` | `to` | | 200 OK |
|
||||
| `PART` | `to` | `body` | 200 OK |
|
||||
| `NICK` | `body` | | 200 OK |
|
||||
| `TOPIC` | `to`, `body` | | 200 OK |
|
||||
| `MODE` | `to` | | 200 OK |
|
||||
| `NAMES` | `to` | | 200 OK |
|
||||
| `LIST` | | | 200 OK |
|
||||
| `WHOIS` | `to` or `body` | | 200 OK |
|
||||
| `WHO` | `to` | | 200 OK |
|
||||
| `LUSERS` | | | 200 OK |
|
||||
| `QUIT` | | `body` | 200 OK |
|
||||
| `PING` | | | 200 OK |
|
||||
|
||||
All IRC commands return HTTP 200 OK. IRC-level success and error responses
|
||||
are delivered as **numeric replies** through the message queue (see
|
||||
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
|
||||
reserved for transport-level problems: malformed JSON (400), missing/invalid
|
||||
auth tokens (401), and server errors (500).
|
||||
|
||||
**HTTP errors (transport-level only):**
|
||||
**Errors (all commands):**
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `invalid request` | Malformed JSON or empty command |
|
||||
| 400 | `invalid request` | Malformed JSON |
|
||||
| 400 | `to field required` | Missing `to` for commands that need it |
|
||||
| 400 | `body required` | Missing `body` for commands that need it |
|
||||
| 400 | `unknown command: X` | Unrecognized command |
|
||||
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||
| 500 | `internal error` | Server-side failure |
|
||||
|
||||
**IRC numeric error replies (delivered via message queue):**
|
||||
|
||||
| Numeric | Name | When |
|
||||
|---------|------|------|
|
||||
| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist |
|
||||
| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name |
|
||||
| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command |
|
||||
| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format |
|
||||
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
|
||||
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
|
||||
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
|
||||
|
||||
**IRC numeric success replies (delivered via message queue):**
|
||||
|
||||
| Numeric | Name | When |
|
||||
|---------|------|------|
|
||||
| 001 | RPL_WELCOME | Sent on session creation/login |
|
||||
| 002 | RPL_YOURHOST | Sent on session creation/login |
|
||||
| 003 | RPL_CREATED | Sent on session creation/login |
|
||||
| 004 | RPL_MYINFO | Sent on session creation/login |
|
||||
| 005 | RPL_ISUPPORT | Sent on session creation/login |
|
||||
| 221 | RPL_UMODEIS | In response to user MODE query |
|
||||
| 251 | RPL_LUSERCLIENT | On connect or LUSERS command |
|
||||
| 252 | RPL_LUSEROP | On connect or LUSERS command |
|
||||
| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command |
|
||||
| 255 | RPL_LUSERME | On connect or LUSERS command |
|
||||
| 311 | RPL_WHOISUSER | WHOIS user info |
|
||||
| 312 | RPL_WHOISSERVER | WHOIS server info |
|
||||
| 315 | RPL_ENDOFWHO | End of WHO list |
|
||||
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
|
||||
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
|
||||
| 322 | RPL_LIST | Channel in LIST response |
|
||||
| 323 | RPL_LISTEND | End of LIST |
|
||||
| 324 | RPL_CHANNELMODEIS | Channel mode query response |
|
||||
| 329 | RPL_CREATIONTIME | Channel creation timestamp |
|
||||
| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) |
|
||||
| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) |
|
||||
| 352 | RPL_WHOREPLY | User in WHO response |
|
||||
| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) |
|
||||
| 366 | RPL_ENDOFNAMES | End of NAMES list |
|
||||
| 375 | RPL_MOTDSTART | Start of MOTD |
|
||||
| 372 | RPL_MOTD | MOTD line |
|
||||
| 376 | RPL_ENDOFMOTD | End of MOTD |
|
||||
| 404 | `channel not found` | Target channel doesn't exist |
|
||||
| 404 | `user not found` | DM target nick doesn't exist |
|
||||
| 409 | `nick already in use` | NICK target is taken |
|
||||
|
||||
### GET /api/v1/history — Message History
|
||||
|
||||
@@ -1361,7 +1214,7 @@ Return server metadata. No authentication required.
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"name": "My NeoIRC Server",
|
||||
"name": "My Chat Server",
|
||||
"motd": "Welcome! Be nice.",
|
||||
"users": 42
|
||||
}
|
||||
@@ -1615,7 +1468,7 @@ authenticity.
|
||||
|
||||
## Federation (Server-to-Server)
|
||||
|
||||
Federation allows multiple neoirc servers to link together, forming a network
|
||||
Federation allows multiple chat servers to link together, forming a network
|
||||
where users on different servers can share channels — similar to IRC server
|
||||
linking.
|
||||
|
||||
@@ -1792,7 +1645,7 @@ directory is also loaded automatically via
|
||||
| Variable | Type | Default | Description |
|
||||
|--------------------|---------|--------------------------------------|-------------|
|
||||
| `PORT` | int | `8080` | HTTP listen port |
|
||||
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
|
||||
| `DBURL` | string | `file:./data.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:./path.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
|
||||
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
|
||||
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
|
||||
| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
|
||||
@@ -1811,10 +1664,10 @@ directory is also loaded automatically via
|
||||
|
||||
```bash
|
||||
PORT=8080
|
||||
SERVER_NAME=My NeoIRC Server
|
||||
SERVER_NAME=My Chat Server
|
||||
MOTD=Welcome! Be excellent to each other.
|
||||
DEBUG=false
|
||||
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||
DBURL=file:./data.db?_journal_mode=WAL
|
||||
SESSION_IDLE_TIMEOUT=24h
|
||||
```
|
||||
|
||||
@@ -1824,24 +1677,25 @@ SESSION_IDLE_TIMEOUT=24h
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
The Docker image contains a single static binary (`neoircd`) and nothing else.
|
||||
The Docker image contains a single static binary (`chatd`) and nothing else.
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t neoirc .
|
||||
docker build -t chat .
|
||||
|
||||
# Run
|
||||
docker run -p 8080:8080 \
|
||||
-v neoirc-data:/var/lib/neoirc \
|
||||
-v chat-data:/data \
|
||||
-e DBURL="file:/data/chat.db?_journal_mode=WAL" \
|
||||
-e SERVER_NAME="My Server" \
|
||||
-e MOTD="Welcome!" \
|
||||
neoirc
|
||||
chat
|
||||
```
|
||||
|
||||
The Dockerfile is a multi-stage build:
|
||||
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
|
||||
1. **Build stage**: Compiles `chatd` and `chat-cli` (CLI built to verify
|
||||
compilation, not included in final image)
|
||||
2. **Final stage**: Alpine Linux + `neoircd` binary only
|
||||
2. **Final stage**: Alpine Linux + `chatd` binary only
|
||||
|
||||
```dockerfile
|
||||
FROM golang:1.24-alpine AS builder
|
||||
@@ -1850,13 +1704,13 @@ 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/
|
||||
RUN go build -o /chatd ./cmd/chatd/
|
||||
RUN go build -o /chat-cli ./cmd/chat-cli/
|
||||
|
||||
FROM alpine:latest
|
||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||
COPY --from=builder /chatd /usr/local/bin/chatd
|
||||
EXPOSE 8080
|
||||
CMD ["neoircd"]
|
||||
CMD ["chatd"]
|
||||
```
|
||||
|
||||
### Binary
|
||||
@@ -1864,11 +1718,11 @@ CMD ["neoircd"]
|
||||
```bash
|
||||
# Build from source
|
||||
make build
|
||||
# Binary at ./bin/neoircd
|
||||
# Binary at ./bin/chatd
|
||||
|
||||
# Run
|
||||
./bin/neoircd
|
||||
# Listens on :8080, writes to /var/lib/neoirc/state.db
|
||||
./bin/chatd
|
||||
# Listens on :8080, creates ./data.db
|
||||
```
|
||||
|
||||
### Reverse Proxy (Production)
|
||||
@@ -1877,7 +1731,7 @@ For production, run behind a TLS-terminating reverse proxy.
|
||||
|
||||
**Caddy:**
|
||||
```
|
||||
neoirc.example.com {
|
||||
chat.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
@@ -1886,7 +1740,7 @@ neoirc.example.com {
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name neoirc.example.com;
|
||||
server_name chat.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
@@ -1909,15 +1763,15 @@ seconds to accommodate long-poll connections.
|
||||
string). This allows concurrent reads during writes.
|
||||
- **Single writer**: SQLite allows only one writer at a time. For high-traffic
|
||||
servers, Postgres support is planned.
|
||||
- **Backup**: The database is a single file. Back it up with `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file (safe with WAL mode).
|
||||
- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`.
|
||||
- **Backup**: The database is a single file. Back it up with `sqlite3 data.db ".backup backup.db"` or just copy the file (safe with WAL mode).
|
||||
- **Location**: By default, `data.db` is created in the working directory.
|
||||
Use the `DBURL` env var to place it elsewhere.
|
||||
|
||||
---
|
||||
|
||||
## Client Development Guide
|
||||
|
||||
This section explains how to write a client against the neoirc API. The API is
|
||||
This section explains how to write a client against the chat API. The API is
|
||||
designed to be simple enough that a basic client can be written in any language
|
||||
with an HTTP client library.
|
||||
|
||||
@@ -2207,7 +2061,7 @@ GET /api/v1/challenge
|
||||
- [x] NICK change with broadcast
|
||||
- [x] QUIT with broadcast and cleanup
|
||||
- [x] Embedded web SPA client
|
||||
- [x] CLI client (neoirc-cli)
|
||||
- [x] CLI client (chat-cli)
|
||||
- [x] SQLite storage with WAL mode
|
||||
- [x] Docker deployment
|
||||
- [x] Prometheus metrics endpoint
|
||||
@@ -2224,18 +2078,10 @@ GET /api/v1/challenge
|
||||
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
||||
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
|
||||
- [x] **MODE command** — query channel and user modes (set not yet implemented)
|
||||
- [x] **NAMES command** — query channel member list
|
||||
- [x] **LIST command** — list all channels with member counts
|
||||
- [x] **WHOIS command** — query user information and channel membership
|
||||
- [x] **WHO command** — query channel user list
|
||||
- [x] **LUSERS command** — query server statistics
|
||||
- [x] **Connection registration numerics** — 001-005 sent on session creation
|
||||
- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS
|
||||
- [ ] **MODE command** — set/query channel and user modes
|
||||
- [ ] **KICK command** — remove users from channels
|
||||
- [x] **Numeric replies** — send IRC numeric codes via the message queue
|
||||
(001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE,
|
||||
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
|
||||
- [ ] **Numeric replies** — send IRC numeric codes via the message queue
|
||||
(001 welcome, 353 NAMES, 332 TOPIC, etc.)
|
||||
- [ ] **Max message size enforcement** — reject oversized messages
|
||||
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
|
||||
- [ ] **Multi-client sessions** — add client to existing session
|
||||
@@ -2255,7 +2101,7 @@ GET /api/v1/challenge
|
||||
- [ ] **Push notifications** — optional webhook/push for mobile clients
|
||||
when messages arrive during disconnect
|
||||
- [ ] **Message search** — full-text search over channel history
|
||||
- [x] **User info command** — WHOIS for querying user info and channels
|
||||
- [ ] **User info command** — WHOIS-equivalent for querying user metadata
|
||||
- [ ] **Connection flood protection** — per-IP connection limits as a
|
||||
complement to hashcash
|
||||
- [ ] **Invite system** — `INVITE` command for `+i` channels
|
||||
@@ -2268,11 +2114,11 @@ GET /api/v1/challenge
|
||||
Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md):
|
||||
|
||||
```
|
||||
neoirc/
|
||||
chat/
|
||||
├── cmd/
|
||||
│ ├── neoircd/ # Server binary entry point
|
||||
│ ├── chatd/ # Server binary entry point
|
||||
│ │ └── main.go
|
||||
│ └── neoirc-cli/ # TUI client
|
||||
│ └── chat-cli/ # TUI client
|
||||
│ ├── main.go # Command handling, poll loop
|
||||
│ ├── ui.go # tview-based terminal UI
|
||||
│ └── api/
|
||||
@@ -2403,7 +2249,7 @@ neoirc/
|
||||
- IRC message envelope format with per-client queue fan-out
|
||||
- Long-polling with in-memory broker
|
||||
- Embedded web SPA client
|
||||
- TUI client (neoirc-cli)
|
||||
- TUI client (chat-cli)
|
||||
- Docker image
|
||||
- Prometheus metrics
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package neoircapi provides a client for the neoirc server API.
|
||||
package neoircapi
|
||||
// Package chatapi provides a client for the chat server API.
|
||||
package chatapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
|
||||
var errHTTP = errors.New("HTTP error")
|
||||
|
||||
// Client wraps HTTP calls to the neoirc server API.
|
||||
// Client wraps HTTP calls to the chat server API.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
@@ -1,4 +1,4 @@
|
||||
package neoircapi
|
||||
package chatapi
|
||||
|
||||
import "time"
|
||||
|
||||
@@ -21,7 +21,7 @@ type StateResponse struct {
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
// Message represents a neoirc message envelope.
|
||||
// Message represents a chat message envelope.
|
||||
type Message struct {
|
||||
Command string `json:"command"`
|
||||
From string `json:"from,omitempty"`
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package main is the entry point for the neoirc-cli client.
|
||||
// Package main is the entry point for the chat-cli client.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
||||
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -41,7 +41,7 @@ func main() {
|
||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||
|
||||
app.ui.AddStatus(
|
||||
"Welcome to neoirc-cli — an IRC-style client",
|
||||
"Welcome to chat-cli — an IRC-style client",
|
||||
)
|
||||
app.ui.AddStatus(
|
||||
"Type [yellow]/connect <server-url>" +
|
||||
@@ -138,29 +138,16 @@ func (a *App) dispatchCommand(cmd, args string) {
|
||||
a.cmdQuery(args)
|
||||
case "/topic":
|
||||
a.cmdTopic(args)
|
||||
case "/names":
|
||||
a.cmdNames()
|
||||
case "/list":
|
||||
a.cmdList()
|
||||
case "/window", "/w":
|
||||
a.cmdWindow(args)
|
||||
case "/quit":
|
||||
a.cmdQuit()
|
||||
case "/help":
|
||||
a.cmdHelp()
|
||||
default:
|
||||
a.dispatchInfoCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) dispatchInfoCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/names":
|
||||
a.cmdNames()
|
||||
case "/list":
|
||||
a.cmdList()
|
||||
case "/motd":
|
||||
a.cmdMotd()
|
||||
case "/who":
|
||||
a.cmdWho(args)
|
||||
case "/whois":
|
||||
a.cmdWhois(args)
|
||||
default:
|
||||
a.ui.AddStatus(
|
||||
"[red]Unknown command: " + cmd,
|
||||
@@ -523,96 +510,6 @@ func (a *App) cmdList() {
|
||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||
}
|
||||
|
||||
func (a *App) cmdMotd() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{Command: "MOTD"}, //nolint:exhaustruct
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]MOTD failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWho(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channel := args
|
||||
if channel == "" {
|
||||
channel = target
|
||||
}
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /who #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: "WHO", To: channel,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHO failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWhois(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /whois <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: "WHOIS", To: args,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHOIS failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWindow(args string) {
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
@@ -667,7 +564,7 @@ func (a *App) cmdQuit() {
|
||||
|
||||
func (a *App) cmdHelp() {
|
||||
help := []string{
|
||||
"[cyan]*** neoirc-cli commands:",
|
||||
"[cyan]*** chat-cli commands:",
|
||||
" /connect <url> — Connect to server",
|
||||
" /nick <name> — Change nickname",
|
||||
" /join #channel — Join channel",
|
||||
@@ -677,9 +574,6 @@ func (a *App) cmdHelp() {
|
||||
" /topic [text] — View/set topic",
|
||||
" /names — List channel members",
|
||||
" /list — List channels",
|
||||
" /who [#channel] — List users in channel",
|
||||
" /whois <nick> — Show user info",
|
||||
" /motd — Show message of the day",
|
||||
" /window <n> — Switch buffer",
|
||||
" /quit — Disconnect and exit",
|
||||
" /help — This help",
|
||||
@@ -1,21 +1,21 @@
|
||||
// Package main is the entry point for the neoircd server.
|
||||
// Package main is the entry point for the chatd server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/db"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/middleware"
|
||||
"git.eeqj.de/sneak/chat/internal/server"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
var (
|
||||
// Appname is the application name, set at build time.
|
||||
Appname = "neoirc" //nolint:gochecknoglobals
|
||||
Appname = "chat" //nolint:gochecknoglobals
|
||||
|
||||
// Version is the application version, set at build time.
|
||||
Version string //nolint:gochecknoglobals
|
||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
||||
module git.eeqj.de/sneak/neoirc
|
||||
module git.eeqj.de/sneak/chat
|
||||
|
||||
go 1.24.0
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/chat/internal/broker"
|
||||
)
|
||||
|
||||
func TestNewBroker(t *testing.T) {
|
||||
|
||||
@@ -5,22 +5,14 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||
)
|
||||
|
||||
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
|
||||
| '_ \ / _ \/ _ \ | | '__/ __|
|
||||
| | | | __/ (_) || | | | (__
|
||||
|_| |_|\___|\___/ |_|_| \___|
|
||||
|
||||
Welcome to NeoIRC — IRC semantics over HTTP.
|
||||
Type /help for available commands.`
|
||||
|
||||
// Params defines the dependencies for creating a Config.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -64,13 +56,13 @@ func New(
|
||||
viper.SetDefault("DEBUG", "false")
|
||||
viper.SetDefault("MAINTENANCE_MODE", "false")
|
||||
viper.SetDefault("PORT", "8080")
|
||||
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
|
||||
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
|
||||
viper.SetDefault("SENTRY_DSN", "")
|
||||
viper.SetDefault("METRICS_USERNAME", "")
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
viper.SetDefault("MAX_HISTORY", "10000")
|
||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||
viper.SetDefault("MOTD", defaultMOTD)
|
||||
viper.SetDefault("MOTD", "")
|
||||
viper.SetDefault("SERVER_NAME", "")
|
||||
viper.SetDefault("FEDERATION_KEY", "")
|
||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload" // .env
|
||||
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
|
||||
func (database *Database) connect(ctx context.Context) error {
|
||||
dbURL := database.params.Config.DBURL
|
||||
if dbURL == "" {
|
||||
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
|
||||
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000"
|
||||
}
|
||||
|
||||
database.log.Info(
|
||||
|
||||
@@ -35,7 +35,6 @@ type IRCMessage struct {
|
||||
Command string `json:"command"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
TS string `json:"ts"`
|
||||
Meta json.RawMessage `json:"meta,omitempty"`
|
||||
@@ -492,17 +491,12 @@ func (database *Database) GetSessionChannelIDs(
|
||||
func (database *Database) InsertMessage(
|
||||
ctx context.Context,
|
||||
command, from, target string,
|
||||
params json.RawMessage,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
) (int64, string, error) {
|
||||
msgUUID := uuid.New().String()
|
||||
now := time.Now().UTC()
|
||||
|
||||
if params == nil {
|
||||
params = json.RawMessage("[]")
|
||||
}
|
||||
|
||||
if body == nil {
|
||||
body = json.RawMessage("[]")
|
||||
}
|
||||
@@ -514,10 +508,10 @@ func (database *Database) InsertMessage(
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
`INSERT INTO messages
|
||||
(uuid, command, msg_from, msg_to,
|
||||
params, body, meta, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
body, meta, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
msgUUID, command, from, target,
|
||||
string(params), string(body), string(meta), now)
|
||||
string(body), string(meta), now)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf(
|
||||
"insert message: %w", err,
|
||||
@@ -584,7 +578,7 @@ func (database *Database) PollMessages(
|
||||
rows, err := database.conn.QueryContext(ctx,
|
||||
`SELECT cq.id, m.uuid, m.command,
|
||||
m.msg_from, m.msg_to,
|
||||
m.params, m.body, m.meta, m.created_at
|
||||
m.body, m.meta, m.created_at
|
||||
FROM client_queues cq
|
||||
INNER JOIN messages m
|
||||
ON m.id = cq.message_id
|
||||
@@ -648,7 +642,7 @@ func (database *Database) queryHistory(
|
||||
if beforeID > 0 {
|
||||
rows, err := database.conn.QueryContext(ctx,
|
||||
`SELECT id, uuid, command, msg_from,
|
||||
msg_to, params, body, meta, created_at
|
||||
msg_to, body, meta, created_at
|
||||
FROM messages
|
||||
WHERE msg_to = ? AND id < ?
|
||||
AND command = 'PRIVMSG'
|
||||
@@ -665,7 +659,7 @@ func (database *Database) queryHistory(
|
||||
|
||||
rows, err := database.conn.QueryContext(ctx,
|
||||
`SELECT id, uuid, command, msg_from,
|
||||
msg_to, params, body, meta, created_at
|
||||
msg_to, body, meta, created_at
|
||||
FROM messages
|
||||
WHERE msg_to = ?
|
||||
AND command = 'PRIVMSG'
|
||||
@@ -690,16 +684,16 @@ func scanMessages(
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
msg IRCMessage
|
||||
qID int64
|
||||
params, body, meta string
|
||||
createdAt time.Time
|
||||
msg IRCMessage
|
||||
qID int64
|
||||
body, meta string
|
||||
createdAt time.Time
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&qID, &msg.ID, &msg.Command,
|
||||
&msg.From, &msg.To,
|
||||
¶ms, &body, &meta, &createdAt,
|
||||
&body, &meta, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fallbackQID, fmt.Errorf(
|
||||
@@ -707,10 +701,6 @@ func scanMessages(
|
||||
)
|
||||
}
|
||||
|
||||
if params != "" && params != "[]" {
|
||||
msg.Params = json.RawMessage(params)
|
||||
}
|
||||
|
||||
msg.Body = json.RawMessage(body)
|
||||
msg.Meta = json.RawMessage(meta)
|
||||
msg.TS = createdAt.Format(time.RFC3339Nano)
|
||||
@@ -953,125 +943,3 @@ func (database *Database) GetSessionChannels(
|
||||
|
||||
return scanChannels(rows)
|
||||
}
|
||||
|
||||
// GetChannelCount returns the total number of channels.
|
||||
func (database *Database) GetChannelCount(
|
||||
ctx context.Context,
|
||||
) (int64, error) {
|
||||
var count int64
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT COUNT(*) FROM channels",
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"get channel count: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ChannelInfoFull contains extended channel information.
|
||||
type ChannelInfoFull struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Topic string `json:"topic"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
}
|
||||
|
||||
// ListAllChannelsWithCounts returns every channel
|
||||
// with its member count.
|
||||
func (database *Database) ListAllChannelsWithCounts(
|
||||
ctx context.Context,
|
||||
) ([]ChannelInfoFull, error) {
|
||||
rows, err := database.conn.QueryContext(ctx,
|
||||
`SELECT c.id, c.name, c.topic,
|
||||
COUNT(cm.session_id) AS member_count
|
||||
FROM channels c
|
||||
LEFT JOIN channel_members cm
|
||||
ON cm.channel_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"list channels with counts: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []ChannelInfoFull
|
||||
|
||||
for rows.Next() {
|
||||
var chanInfo ChannelInfoFull
|
||||
|
||||
err = rows.Scan(
|
||||
&chanInfo.ID, &chanInfo.Name,
|
||||
&chanInfo.Topic, &chanInfo.MemberCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"scan channel full: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
out = append(out, chanInfo)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
out = []ChannelInfoFull{}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetChannelCreatedAt returns the creation time of a
|
||||
// channel.
|
||||
func (database *Database) GetChannelCreatedAt(
|
||||
ctx context.Context,
|
||||
channelID int64,
|
||||
) (time.Time, error) {
|
||||
var createdAt time.Time
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT created_at FROM channels WHERE id = ?",
|
||||
channelID,
|
||||
).Scan(&createdAt)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"get channel created_at: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return createdAt, nil
|
||||
}
|
||||
|
||||
// GetSessionCreatedAt returns the creation time of a
|
||||
// session.
|
||||
func (database *Database) GetSessionCreatedAt(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (time.Time, error) {
|
||||
var createdAt time.Time
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT created_at FROM sessions WHERE id = ?",
|
||||
sessionID,
|
||||
).Scan(&createdAt)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"get session created_at: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return createdAt, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/chat/internal/db"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -383,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
|
||||
body := json.RawMessage(`["hello"]`)
|
||||
|
||||
dbID, msgUUID, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -417,7 +417,7 @@ func TestPollMessages(t *testing.T) {
|
||||
body := json.RawMessage(`["hello"]`)
|
||||
|
||||
dbID, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -475,7 +475,7 @@ func TestGetHistory(t *testing.T) {
|
||||
for range msgCount {
|
||||
_, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "user", "#hist",
|
||||
nil, json.RawMessage(`["msg"]`), nil,
|
||||
json.RawMessage(`["msg"]`), nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -627,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) {
|
||||
body := json.RawMessage(`["test"]`)
|
||||
|
||||
dbID, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
|
||||
ctx, "PRIVMSG", "sender", "#ch", body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -50,7 +50,6 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
command TEXT NOT NULL DEFAULT 'PRIVMSG',
|
||||
msg_from TEXT NOT NULL DEFAULT '',
|
||||
msg_to TEXT NOT NULL DEFAULT '',
|
||||
params TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT NOT NULL DEFAULT '[]',
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
package globals
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -17,18 +15,16 @@ var (
|
||||
|
||||
// Globals holds application-wide metadata.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
StartTime time.Time
|
||||
Appname string
|
||||
Version string
|
||||
}
|
||||
|
||||
// New creates a new Globals instance from the global state.
|
||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||
result := &Globals{
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
StartTime: time.Now(),
|
||||
n := &Globals{
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return n, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,14 +17,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/db"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/middleware"
|
||||
"git.eeqj.de/sneak/chat/internal/server"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
)
|
||||
@@ -115,9 +115,8 @@ func newTestServer(
|
||||
|
||||
func newTestGlobals() *globals.Globals {
|
||||
return &globals.Globals{
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
StartTime: time.Now(),
|
||||
Appname: "chat-test",
|
||||
Version: "test",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,19 +462,6 @@ func findMessage(
|
||||
return false
|
||||
}
|
||||
|
||||
func findNumeric(
|
||||
msgs []map[string]any,
|
||||
numeric string,
|
||||
) bool {
|
||||
for _, msg := range msgs {
|
||||
if msg[commandKey] == numeric {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestCreateSessionValid(t *testing.T) {
|
||||
@@ -487,47 +473,6 @@ func TestCreateSessionValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWelcomeNumeric(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("welcomer")
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, 0)
|
||||
|
||||
if !findNumeric(msgs, "001") {
|
||||
t.Fatalf(
|
||||
"expected RPL_WELCOME (001), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinNumerics(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("jnumtest")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#numtest",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "353") {
|
||||
t.Fatalf(
|
||||
"expected RPL_NAMREPLY (353), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
if !findNumeric(msgs, "366") {
|
||||
t.Fatalf(
|
||||
"expected RPL_ENDOFNAMES (366), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSessionDuplicate(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
tserver.createSession("alice")
|
||||
@@ -723,23 +668,11 @@ func TestJoinMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("joiner3")
|
||||
|
||||
// Drain initial MOTD/welcome numerics.
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: joinCmd},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,10 +682,10 @@ func TestChannelMessage(t *testing.T) {
|
||||
bobToken := tserver.createSession("bob_msg")
|
||||
|
||||
tserver.sendCommand(aliceToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#test",
|
||||
commandKey: joinCmd, toKey: "#chat",
|
||||
})
|
||||
tserver.sendCommand(bobToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#test",
|
||||
commandKey: joinCmd, toKey: "#chat",
|
||||
})
|
||||
|
||||
_, _ = tserver.pollMessages(aliceToken, 0)
|
||||
@@ -762,13 +695,13 @@ func TestChannelMessage(t *testing.T) {
|
||||
aliceToken,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#test",
|
||||
toKey: "#chat",
|
||||
bodyKey: []string{"hello world"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
"expected 201, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -792,25 +725,14 @@ func TestMessageMissingBody(t *testing.T) {
|
||||
token := tserver.createSession("nobody")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#test",
|
||||
commandKey: joinCmd, toKey: "#chat",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd, toKey: "#test",
|
||||
commandKey: privmsgCmd, toKey: "#chat",
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,23 +740,12 @@ func TestMessageMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("noto")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
bodyKey: []string{"hello"},
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,8 +759,6 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
commandKey: joinCmd, toKey: "#private",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(aliceToken, 0)
|
||||
|
||||
// Alice tries to send without joining.
|
||||
status, _ := tserver.sendCommand(
|
||||
aliceToken,
|
||||
@@ -859,17 +768,8 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
bodyKey: []string{"sneaky"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||
|
||||
if !findNumeric(msgs, "442") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,9 +786,9 @@ func TestDirectMessage(t *testing.T) {
|
||||
bodyKey: []string{"hey bob"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %v", status, result,
|
||||
"expected 201, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -918,24 +818,13 @@ func TestDMToNonexistentUser(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("dmsender")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "nobody",
|
||||
bodyKey: []string{"hello?"},
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "401") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOSUCHNICK (401), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,23 +871,12 @@ func TestNickCollision(t *testing.T) {
|
||||
|
||||
tserver.createSession("taken_nick")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "NICK",
|
||||
bodyKey: []string{"taken_nick"},
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "433") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NICKNAMEINUSE (433), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,23 +884,12 @@ func TestNickInvalid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("nickval")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "NICK",
|
||||
bodyKey: []string{"bad nick!"},
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "432") {
|
||||
t.Fatalf(
|
||||
"expected ERR_ERRONEUSNICKNAME (432), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,22 +897,11 @@ func TestNickEmptyBody(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("nicknobody")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: "NICK"},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1082,23 +938,12 @@ func TestTopicMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("topicnoto")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "TOPIC",
|
||||
bodyKey: []string{"topic"},
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,22 +955,11 @@ func TestTopicMissingBody(t *testing.T) {
|
||||
commandKey: joinCmd, toKey: "#topictest",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "TOPIC", toKey: "#topictest",
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1193,22 +1027,11 @@ func TestUnknownCommand(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("cmdtest")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: "BOGUS"},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "421") {
|
||||
t.Fatalf(
|
||||
"expected ERR_UNKNOWNCOMMAND (421), got %v",
|
||||
msgs,
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1455,18 +1278,12 @@ func TestLongPollTimeout(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("lp_timeout")
|
||||
|
||||
// Drain initial welcome/MOTD numerics.
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
resp, err := doRequestAuth(
|
||||
t,
|
||||
http.MethodGet,
|
||||
tserver.url(fmt.Sprintf(
|
||||
"%s?timeout=1&after=%d",
|
||||
apiMessages, lastID,
|
||||
)),
|
||||
tserver.url(apiMessages+"?timeout=1"),
|
||||
token,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||
hdlr.deliverMOTD(request, clientID, sessionID)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
@@ -162,7 +162,7 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
sessionID, _, token, err :=
|
||||
hdlr.params.Database.LoginUser(
|
||||
request.Context(),
|
||||
payload.Nick,
|
||||
@@ -178,10 +178,6 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.deliverMOTD(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package handlers provides HTTP request handlers for the neoirc server.
|
||||
// Package handlers provides HTTP request handlers for the chat server.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/broker"
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/db"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/db"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package middleware provides HTTP middleware for the neoirc server.
|
||||
// Package middleware provides HTTP middleware for the chat server.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
chimw "github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/web"
|
||||
"git.eeqj.de/sneak/chat/web"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package server implements the main HTTP server for the neoirc application.
|
||||
// Package server implements the main HTTP server for the chat application.
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/middleware"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Message Schemas
|
||||
|
||||
JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
|
||||
JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use
|
||||
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
||||
over HTTP.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json",
|
||||
"title": "JOIN",
|
||||
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json",
|
||||
"title": "KICK",
|
||||
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json",
|
||||
"title": "MODE",
|
||||
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json",
|
||||
"title": "NICK",
|
||||
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json",
|
||||
"title": "NOTICE",
|
||||
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json",
|
||||
"title": "PART",
|
||||
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json",
|
||||
"title": "PING",
|
||||
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json",
|
||||
"title": "PONG",
|
||||
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json",
|
||||
"title": "PRIVMSG",
|
||||
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json",
|
||||
"title": "PUBKEY",
|
||||
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json",
|
||||
"title": "QUIT",
|
||||
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json",
|
||||
"title": "TOPIC",
|
||||
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
|
||||
"$ref": "../message.json",
|
||||
@@ -17,6 +17,6 @@
|
||||
},
|
||||
"required": ["command", "to"],
|
||||
"examples": [
|
||||
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
|
||||
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/message.json",
|
||||
"title": "IRC Message Envelope",
|
||||
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
||||
"type": "object",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json",
|
||||
"title": "001 RPL_WELCOME",
|
||||
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json",
|
||||
"title": "002 RPL_YOURHOST",
|
||||
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
@@ -29,7 +29,7 @@
|
||||
"command": "002",
|
||||
"to": "alice",
|
||||
"body": [
|
||||
"Your host is neoirc.example.com, running version 0.1.0"
|
||||
"Your host is chat.example.com, running version 0.1.0"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json",
|
||||
"title": "003 RPL_CREATED",
|
||||
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json",
|
||||
"title": "004 RPL_MYINFO",
|
||||
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
@@ -29,7 +29,7 @@
|
||||
"command": "004",
|
||||
"to": "alice",
|
||||
"params": [
|
||||
"neoirc.example.com",
|
||||
"chat.example.com",
|
||||
"0.1.0",
|
||||
"o",
|
||||
"imnst+ov"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json",
|
||||
"title": "322 RPL_LIST",
|
||||
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json",
|
||||
"title": "323 RPL_LISTEND",
|
||||
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json",
|
||||
"title": "332 RPL_TOPIC",
|
||||
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
@@ -40,7 +40,7 @@
|
||||
"#general"
|
||||
],
|
||||
"body": [
|
||||
"Welcome to the channel"
|
||||
"Welcome to the chat"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/353.json",
|
||||
"title": "353 RPL_NAMREPLY",
|
||||
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json",
|
||||
"title": "366 RPL_ENDOFNAMES",
|
||||
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json",
|
||||
"title": "372 RPL_MOTD",
|
||||
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json",
|
||||
"title": "375 RPL_MOTDSTART",
|
||||
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json",
|
||||
"title": "376 RPL_ENDOFMOTD",
|
||||
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json",
|
||||
"title": "401 ERR_NOSUCHNICK",
|
||||
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json",
|
||||
"title": "403 ERR_NOSUCHCHANNEL",
|
||||
"description": "No such channel. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json",
|
||||
"title": "433 ERR_NICKNAMEINUSE",
|
||||
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json",
|
||||
"title": "442 ERR_NOTONCHANNEL",
|
||||
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json",
|
||||
"title": "482 ERR_CHANOPRIVSNEEDED",
|
||||
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
12
web/build.sh
12
web/build.sh
@@ -16,11 +16,19 @@ fi
|
||||
|
||||
mkdir -p dist
|
||||
|
||||
# Build JS bundle — preact must be bundled (no CDN/external loader)
|
||||
# Build JS bundle
|
||||
${NPX:+$NPX} esbuild src/app.jsx \
|
||||
--bundle \
|
||||
--minify \
|
||||
--jsx-factory=h \
|
||||
--jsx-fragment=Fragment \
|
||||
--define:process.env.NODE_ENV=\"production\" \
|
||||
--external:preact \
|
||||
--outfile=dist/app.js \
|
||||
2>/dev/null || \
|
||||
${NPX:+$NPX} esbuild src/app.jsx \
|
||||
--bundle \
|
||||
--minify \
|
||||
--format=esm \
|
||||
--jsx-factory=h \
|
||||
--jsx-fragment=Fragment \
|
||||
--define:process.env.NODE_ENV=\"production\" \
|
||||
|
||||
4
web/dist/app.js
vendored
4
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
4
web/dist/index.html
vendored
4
web/dist/index.html
vendored
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NeoIRC</title>
|
||||
<title>Chat</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
575
web/dist/style.css
vendored
575
web/dist/style.css
vendored
@@ -1,466 +1,317 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { 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;
|
||||
--bg: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-input: #0f3460;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--accent: #e94560;
|
||||
--accent2: #0f3460;
|
||||
--border: #2a2a4a;
|
||||
--nick: #53a8b6;
|
||||
--timestamp: #666;
|
||||
--tab-active: #e94560;
|
||||
--tab-bg: #16213e;
|
||||
--tab-hover: #1a1a3e;
|
||||
--topic-bg: #121a30;
|
||||
--unread-bg: #e94560;
|
||||
--warn: #f0ad4e;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
||||
"Consolas", "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Login Screen
|
||||
============================================ */
|
||||
|
||||
/* Login screen */
|
||||
.login-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
.login-screen h1 {
|
||||
color: var(--accent);
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 400;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.login-box .motd {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
.login-screen input {
|
||||
padding: 10px 16px;
|
||||
font-size: 16px;
|
||||
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);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-bright);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.login-box input:focus {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.login-box button {
|
||||
padding: 8px 16px;
|
||||
.login-screen button {
|
||||
padding: 10px 24px;
|
||||
font-size: 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);
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-box .error {
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
.login-screen .error {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
IRC App Layout
|
||||
============================================ */
|
||||
.login-screen .motd {
|
||||
color: var(--text-muted);
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.irc-app {
|
||||
/* Main layout */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tab Bar
|
||||
============================================ */
|
||||
|
||||
/* Tab bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: var(--bg-tab);
|
||||
background: var(--bg-secondary);
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
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);
|
||||
background: var(--tab-hover);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text-bright);
|
||||
background: var(--bg-tab-active);
|
||||
border-bottom: 2px solid var(--tab-indicator);
|
||||
margin-bottom: -1px;
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--tab-active);
|
||||
}
|
||||
|
||||
.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;
|
||||
.tab .close-btn {
|
||||
margin-left: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-nick {
|
||||
.tab .close-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tab .unread-badge {
|
||||
display: inline-block;
|
||||
background: var(--unread-bg);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
margin-left: 6px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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 {
|
||||
/* Connection status */
|
||||
.connection-status {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-topic);
|
||||
background: var(--warn);
|
||||
color: #1a1a2e;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Topic bar */
|
||||
.topic-bar {
|
||||
padding: 6px 12px;
|
||||
background: var(--topic-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
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 {
|
||||
/* Content area */
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Messages Panel
|
||||
============================================ */
|
||||
|
||||
.messages-panel {
|
||||
/* Messages */
|
||||
.messages-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages-scroll {
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 2px 0;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message .timestamp {
|
||||
color: var(--timestamp);
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.message .nick {
|
||||
color: var(--nick);
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.message .content {
|
||||
color: var(--text);
|
||||
}
|
||||
.message .nick::before { content: '<'; }
|
||||
.message .nick::after { content: '>'; }
|
||||
|
||||
/* 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);
|
||||
.message.system {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.user-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.input-prompt {
|
||||
font-size: 12px;
|
||||
}
|
||||
.message.system .nick {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message.system .nick::before,
|
||||
.message.system .nick::after { content: ''; }
|
||||
|
||||
/* Input */
|
||||
.input-bar {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-bar input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--bg-input);
|
||||
border: none;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar button {
|
||||
padding: 10px 16px;
|
||||
font-family: inherit;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* User list */
|
||||
.user-list {
|
||||
width: 160px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-list h3 {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.user-list .user {
|
||||
padding: 3px 4px;
|
||||
color: var(--nick);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-list .user:hover {
|
||||
background: var(--tab-hover);
|
||||
}
|
||||
|
||||
/* Server tab */
|
||||
.server-messages {
|
||||
color: var(--text-muted);
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Channel join dialog */
|
||||
.join-dialog {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.join-dialog input {
|
||||
padding: 6px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 3px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.join-dialog button {
|
||||
padding: 6px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--accent2);
|
||||
border: none;
|
||||
color: var(--text);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.user-list { display: none; }
|
||||
.tab { padding: 6px 10px; font-size: 13px; }
|
||||
}
|
||||
|
||||
1309
web/src/app.jsx
1309
web/src/app.jsx
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NeoIRC</title>
|
||||
<title>Chat</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,466 +1,317 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { 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;
|
||||
--bg: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-input: #0f3460;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--accent: #e94560;
|
||||
--accent2: #0f3460;
|
||||
--border: #2a2a4a;
|
||||
--nick: #53a8b6;
|
||||
--timestamp: #666;
|
||||
--tab-active: #e94560;
|
||||
--tab-bg: #16213e;
|
||||
--tab-hover: #1a1a3e;
|
||||
--topic-bg: #121a30;
|
||||
--unread-bg: #e94560;
|
||||
--warn: #f0ad4e;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
||||
"Consolas", "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Login Screen
|
||||
============================================ */
|
||||
|
||||
/* Login screen */
|
||||
.login-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
.login-screen h1 {
|
||||
color: var(--accent);
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 400;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.login-box .motd {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
.login-screen input {
|
||||
padding: 10px 16px;
|
||||
font-size: 16px;
|
||||
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);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-bright);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.login-box input:focus {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.login-box button {
|
||||
padding: 8px 16px;
|
||||
.login-screen button {
|
||||
padding: 10px 24px;
|
||||
font-size: 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);
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-box .error {
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
.login-screen .error {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
IRC App Layout
|
||||
============================================ */
|
||||
.login-screen .motd {
|
||||
color: var(--text-muted);
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.irc-app {
|
||||
/* Main layout */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tab Bar
|
||||
============================================ */
|
||||
|
||||
/* Tab bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: var(--bg-tab);
|
||||
background: var(--bg-secondary);
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
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);
|
||||
background: var(--tab-hover);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text-bright);
|
||||
background: var(--bg-tab-active);
|
||||
border-bottom: 2px solid var(--tab-indicator);
|
||||
margin-bottom: -1px;
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--tab-active);
|
||||
}
|
||||
|
||||
.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;
|
||||
.tab .close-btn {
|
||||
margin-left: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-nick {
|
||||
.tab .close-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tab .unread-badge {
|
||||
display: inline-block;
|
||||
background: var(--unread-bg);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
margin-left: 6px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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 {
|
||||
/* Connection status */
|
||||
.connection-status {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-topic);
|
||||
background: var(--warn);
|
||||
color: #1a1a2e;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Topic bar */
|
||||
.topic-bar {
|
||||
padding: 6px 12px;
|
||||
background: var(--topic-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
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 {
|
||||
/* Content area */
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Messages Panel
|
||||
============================================ */
|
||||
|
||||
.messages-panel {
|
||||
/* Messages */
|
||||
.messages-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages-scroll {
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 2px 0;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message .timestamp {
|
||||
color: var(--timestamp);
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.message .nick {
|
||||
color: var(--nick);
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.message .content {
|
||||
color: var(--text);
|
||||
}
|
||||
.message .nick::before { content: '<'; }
|
||||
.message .nick::after { content: '>'; }
|
||||
|
||||
/* 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);
|
||||
.message.system {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.user-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.input-prompt {
|
||||
font-size: 12px;
|
||||
}
|
||||
.message.system .nick {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message.system .nick::before,
|
||||
.message.system .nick::after { content: ''; }
|
||||
|
||||
/* Input */
|
||||
.input-bar {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-bar input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--bg-input);
|
||||
border: none;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar button {
|
||||
padding: 10px 16px;
|
||||
font-family: inherit;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* User list */
|
||||
.user-list {
|
||||
width: 160px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-list h3 {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.user-list .user {
|
||||
padding: 3px 4px;
|
||||
color: var(--nick);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-list .user:hover {
|
||||
background: var(--tab-hover);
|
||||
}
|
||||
|
||||
/* Server tab */
|
||||
.server-messages {
|
||||
color: var(--text-muted);
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Channel join dialog */
|
||||
.join-dialog {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.join-dialog input {
|
||||
padding: 6px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 3px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.join-dialog button {
|
||||
padding: 6px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--accent2);
|
||||
border: none;
|
||||
color: var(--text);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.user-list { display: none; }
|
||||
.tab { padding: 6px 10px; font-size: 13px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user