1 Commits

Author SHA1 Message Date
clawbot
f9c145ad09 refactor: replace HTTP error codes with IRC numeric replies for all IRC commands
All checks were successful
check / check (push) Successful in 58s
IRC commands (PRIVMSG, JOIN, PART, NICK, TOPIC, etc.) now respond with
proper IRC numeric replies delivered through the message queue instead of
HTTP status codes. HTTP error codes are now reserved exclusively for
transport-level concerns: auth failures (401), malformed requests (400),
and server errors (500).

Changes:
- Add params column to messages table for IRC-style parameters
- Add Params field to IRCMessage struct and update all queries
- Add respondIRCError helper for consistent IRC error delivery
- Add RPL_WELCOME (001) on session creation and login
- Add RPL_TOPIC/RPL_NOTOPIC (332/331), RPL_NAMREPLY (353),
  RPL_ENDOFNAMES (366) on JOIN
- Add RPL_TOPIC (332) on TOPIC set
- Replace HTTP 404 with ERR_NOSUCHCHANNEL (403) and ERR_NOSUCHNICK (401)
- Replace HTTP 409 with ERR_NICKNAMEINUSE (433)
- Replace HTTP 403 with ERR_NOTONCHANNEL (442)
- Replace HTTP 400 with ERR_NEEDMOREPARAMS (461), ERR_ERRONEUSNICKNAME (432),
  and ERR_UNKNOWNCOMMAND (421) where appropriate
- Change PRIVMSG/NOTICE success from HTTP 201 to HTTP 200
- Update all tests to verify IRC numerics in message queue
- Add new tests for RPL_WELCOME and JOIN numerics
- Update README to document new numeric reply behavior

closes #54
2026-03-08 01:32:02 -08:00
11 changed files with 1048 additions and 1231 deletions

View File

@@ -845,10 +845,11 @@ the server to the client (never C2S) and use 3-digit string codes in the
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | | `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | | `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
**Note:** Numeric replies are planned for full implementation. The current MVP **Note:** Numeric replies are now implemented. All IRC command responses
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead (success and error) are delivered as numeric replies through the message queue.
of numeric replies for error conditions. Numeric replies in the message queue HTTP error codes are reserved for transport-level issues (auth failures,
will be added post-MVP. malformed requests, server errors). The `params` field in the message envelope
carries IRC-style parameters (e.g., channel name, target nick).
### Channel Modes ### Channel Modes
@@ -1054,8 +1055,8 @@ reference with all required and optional fields.
| Command | Required Fields | Optional | Response Status | | Command | Required Fields | Optional | Response Status |
|-----------|---------------------|---------------|-----------------| |-----------|---------------------|---------------|-----------------|
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created | | `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
| `NOTICE` | `to`, `body` | `meta` | 201 Created | | `NOTICE` | `to`, `body` | `meta` | 200 OK |
| `JOIN` | `to` | | 200 OK | | `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK | | `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK | | `NICK` | `body` | | 200 OK |
@@ -1063,18 +1064,44 @@ reference with all required and optional fields.
| `QUIT` | | `body` | 200 OK | | `QUIT` | | `body` | 200 OK |
| `PING` | | | 200 OK | | `PING` | | | 200 OK |
**Errors (all commands):** 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):**
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `invalid request` | Malformed JSON | | 400 | `invalid request` | Malformed JSON or empty command |
| 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 | | 401 | `unauthorized` | Missing or invalid auth token |
| 404 | `channel not found` | Target channel doesn't exist | | 500 | `internal error` | Server-side failure |
| 404 | `user not found` | DM target nick doesn't exist |
| 409 | `nick already in use` | NICK target is taken | **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 |
| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) |
| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) |
| 353 | RPL_NAMREPLY | Channel member list (on JOIN) |
| 366 | RPL_ENDOFNAMES | End of NAMES list (on JOIN) |
| 375 | RPL_MOTDSTART | Start of MOTD |
| 372 | RPL_MOTD | MOTD line |
| 376 | RPL_ENDOFMOTD | End of MOTD |
### GET /api/v1/history — Message History ### GET /api/v1/history — Message History

View File

@@ -35,6 +35,7 @@ type IRCMessage struct {
Command string `json:"command"` Command string `json:"command"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Body json.RawMessage `json:"body,omitempty"` Body json.RawMessage `json:"body,omitempty"`
TS string `json:"ts"` TS string `json:"ts"`
Meta json.RawMessage `json:"meta,omitempty"` Meta json.RawMessage `json:"meta,omitempty"`
@@ -491,12 +492,17 @@ func (database *Database) GetSessionChannelIDs(
func (database *Database) InsertMessage( func (database *Database) InsertMessage(
ctx context.Context, ctx context.Context,
command, from, target string, command, from, target string,
params json.RawMessage,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
) (int64, string, error) { ) (int64, string, error) {
msgUUID := uuid.New().String() msgUUID := uuid.New().String()
now := time.Now().UTC() now := time.Now().UTC()
if params == nil {
params = json.RawMessage("[]")
}
if body == nil { if body == nil {
body = json.RawMessage("[]") body = json.RawMessage("[]")
} }
@@ -508,10 +514,10 @@ func (database *Database) InsertMessage(
res, err := database.conn.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO messages `INSERT INTO messages
(uuid, command, msg_from, msg_to, (uuid, command, msg_from, msg_to,
body, meta, created_at) params, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
msgUUID, command, from, target, msgUUID, command, from, target,
string(body), string(meta), now) string(params), string(body), string(meta), now)
if err != nil { if err != nil {
return 0, "", fmt.Errorf( return 0, "", fmt.Errorf(
"insert message: %w", err, "insert message: %w", err,
@@ -578,7 +584,7 @@ func (database *Database) PollMessages(
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT cq.id, m.uuid, m.command, `SELECT cq.id, m.uuid, m.command,
m.msg_from, m.msg_to, m.msg_from, m.msg_to,
m.body, m.meta, m.created_at m.params, m.body, m.meta, m.created_at
FROM client_queues cq FROM client_queues cq
INNER JOIN messages m INNER JOIN messages m
ON m.id = cq.message_id ON m.id = cq.message_id
@@ -642,7 +648,7 @@ func (database *Database) queryHistory(
if beforeID > 0 { if beforeID > 0 {
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? AND id < ? WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -659,7 +665,7 @@ func (database *Database) queryHistory(
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? WHERE msg_to = ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -686,14 +692,14 @@ func scanMessages(
var ( var (
msg IRCMessage msg IRCMessage
qID int64 qID int64
body, meta string params, body, meta string
createdAt time.Time createdAt time.Time
) )
err := rows.Scan( err := rows.Scan(
&qID, &msg.ID, &msg.Command, &qID, &msg.ID, &msg.Command,
&msg.From, &msg.To, &msg.From, &msg.To,
&body, &meta, &createdAt, &params, &body, &meta, &createdAt,
) )
if err != nil { if err != nil {
return nil, fallbackQID, fmt.Errorf( return nil, fallbackQID, fmt.Errorf(
@@ -701,6 +707,10 @@ func scanMessages(
) )
} }
if params != "" && params != "[]" {
msg.Params = json.RawMessage(params)
}
msg.Body = json.RawMessage(body) msg.Body = json.RawMessage(body)
msg.Meta = json.RawMessage(meta) msg.Meta = json.RawMessage(meta)
msg.TS = createdAt.Format(time.RFC3339Nano) msg.TS = createdAt.Format(time.RFC3339Nano)

View File

@@ -383,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage( dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -417,7 +417,7 @@ func TestPollMessages(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -475,7 +475,7 @@ func TestGetHistory(t *testing.T) {
for range msgCount { for range msgCount {
_, _, err := database.InsertMessage( _, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist", ctx, "PRIVMSG", "user", "#hist",
json.RawMessage(`["msg"]`), nil, nil, json.RawMessage(`["msg"]`), nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -627,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) {
body := json.RawMessage(`["test"]`) body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", body, nil, ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -50,6 +50,7 @@ CREATE TABLE IF NOT EXISTS messages (
command TEXT NOT NULL DEFAULT 'PRIVMSG', command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '', msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '', msg_to TEXT NOT NULL DEFAULT '',
params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]', body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP

View File

@@ -91,7 +91,7 @@ func (hdlr *Handlers) fanOut(
sessionIDs []int64, sessionIDs []int64,
) (string, error) { ) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage( dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, body, nil, request.Context(), command, from, target, nil, body, nil,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("insert message: %w", err) return "", fmt.Errorf("insert message: %w", err)
@@ -185,7 +185,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
hdlr.deliverMOTD(request, clientID, sessionID) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
@@ -219,49 +219,76 @@ func (hdlr *Handlers) handleCreateSessionError(
) )
} }
// deliverWelcome sends the RPL_WELCOME (001) numeric to a
// new client.
func (hdlr *Handlers) deliverWelcome(
request *http.Request,
clientID int64,
nick string,
) {
ctx := request.Context()
hdlr.enqueueNumeric(
ctx, clientID, "001", nick, nil,
"Welcome to the network, "+nick,
)
}
// deliverMOTD sends the MOTD as IRC numeric messages to a // deliverMOTD sends the MOTD as IRC numeric messages to a
// new client. // new client.
func (hdlr *Handlers) deliverMOTD( func (hdlr *Handlers) deliverMOTD(
request *http.Request, request *http.Request,
clientID, sessionID int64, clientID, sessionID int64,
nick string,
) { ) {
motd := hdlr.params.Config.MOTD motd := hdlr.params.Config.MOTD
serverName := hdlr.params.Config.ServerName srvName := hdlr.serverName()
if serverName == "" {
serverName = "neoirc"
}
if motd == "" {
return
}
ctx := request.Context() ctx := request.Context()
hdlr.deliverWelcome(request, clientID, nick)
if motd == "" {
hdlr.broker.Notify(sessionID)
return
}
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, "375", serverName, ctx, clientID, "375", nick, nil,
"- "+serverName+" Message of the Day -", "- "+srvName+" Message of the Day -",
) )
for line := range strings.SplitSeq(motd, "\n") { for line := range strings.SplitSeq(motd, "\n") {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, "372", serverName, ctx, clientID, "372", nick, nil,
"- "+line, "- "+line,
) )
} }
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, "376", serverName, ctx, clientID, "376", nick, nil,
"End of /MOTD command.", "End of /MOTD command.",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
} }
func (hdlr *Handlers) serverName() string {
name := hdlr.params.Config.ServerName
if name == "" {
return "neoirc"
}
return name
}
func (hdlr *Handlers) enqueueNumeric( func (hdlr *Handlers) enqueueNumeric(
ctx context.Context, ctx context.Context,
clientID int64, clientID int64,
command, serverName, text string, command, nick string,
params []string,
text string,
) { ) {
body, err := json.Marshal([]string{text}) body, err := json.Marshal([]string{text})
if err != nil { if err != nil {
@@ -272,9 +299,22 @@ func (hdlr *Handlers) enqueueNumeric(
return return
} }
var paramsJSON json.RawMessage
if len(params) > 0 {
paramsJSON, err = json.Marshal(params)
if err != nil {
hdlr.log.Error(
"marshal numeric params", "error", err,
)
return
}
}
dbID, _, insertErr := hdlr.params.Database.InsertMessage( dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, serverName, "", ctx, command, hdlr.serverName(), nick,
json.RawMessage(body), nil, paramsJSON, json.RawMessage(body), nil,
) )
if insertErr != nil { if insertErr != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -532,7 +572,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
writer, request.Body, hdlr.maxBodySize(), writer, request.Body, hdlr.maxBodySize(),
) )
sessionID, _, nick, ok := sessionID, clientID, nick, ok :=
hdlr.requireAuth(writer, request) hdlr.requireAuth(writer, request)
if !ok { if !ok {
return return
@@ -582,7 +622,8 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
} }
hdlr.dispatchCommand( hdlr.dispatchCommand(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
payload.Command, payload.To, payload.Command, payload.To,
payload.Body, bodyLines, payload.Body, bodyLines,
) )
@@ -592,7 +633,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
func (hdlr *Handlers) dispatchCommand( func (hdlr *Handlers) dispatchCommand(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
@@ -600,24 +641,30 @@ func (hdlr *Handlers) dispatchCommand(
switch command { switch command {
case cmdPrivmsg, "NOTICE": case cmdPrivmsg, "NOTICE":
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
command, target, body, bodyLines, command, target, body, bodyLines,
) )
case "JOIN": case "JOIN":
hdlr.handleJoin( hdlr.handleJoin(
writer, request, sessionID, nick, target, writer, request,
sessionID, clientID, nick, target,
) )
case "PART": case "PART":
hdlr.handlePart( hdlr.handlePart(
writer, request, sessionID, nick, target, body, writer, request,
sessionID, clientID, nick, target, body,
) )
case "NICK": case "NICK":
hdlr.handleNick( hdlr.handleNick(
writer, request, sessionID, nick, bodyLines, writer, request,
sessionID, clientID, nick, bodyLines,
) )
case "TOPIC": case "TOPIC":
hdlr.handleTopic( hdlr.handleTopic(
writer, request, nick, target, body, bodyLines, writer, request,
sessionID, clientID, nick,
target, body, bodyLines,
) )
case "QUIT": case "QUIT":
hdlr.handleQuit( hdlr.handleQuit(
@@ -627,50 +674,63 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{ map[string]string{
"command": "PONG", "command": "PONG",
"from": hdlr.params.Config.ServerName, "from": hdlr.serverName(),
}, },
http.StatusOK) http.StatusOK)
default: default:
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"unknown command: "+command, "421", nick, []string{command},
http.StatusBadRequest, "Unknown command",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
} }
} }
func (hdlr *Handlers) handlePrivmsg( func (hdlr *Handlers) handlePrivmsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target == "" { if target == "" {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"to field required", "461", nick, []string{command},
http.StatusBadRequest, "Not enough parameters",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"body required", "461", nick, []string{command},
http.StatusBadRequest, "Not enough parameters",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg( hdlr.handleChannelMsg(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
command, target, body, command, target, body,
) )
@@ -678,15 +738,36 @@ func (hdlr *Handlers) handlePrivmsg(
} }
hdlr.handleDirectMsg( hdlr.handleDirectMsg(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
command, target, body, command, target, body,
) )
} }
// respondIRCError enqueues a numeric error reply, notifies
// the broker, and sends HTTP 200 with {"status":"error"}.
func (hdlr *Handlers) respondIRCError(
writer http.ResponseWriter,
request *http.Request,
clientID, sessionID int64,
numeric, nick string,
params []string,
text string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID,
numeric, nick, params, text,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
}
func (hdlr *Handlers) handleChannelMsg( func (hdlr *Handlers) handleChannelMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
) { ) {
@@ -694,10 +775,10 @@ func (hdlr *Handlers) handleChannelMsg(
request.Context(), target, request.Context(), target,
) )
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"channel not found", "403", nick, []string{target},
http.StatusNotFound, "No such channel",
) )
return return
@@ -720,15 +801,27 @@ func (hdlr *Handlers) handleChannelMsg(
} }
if !isMember { if !isMember {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"not a member of this channel", "442", nick, []string{target},
http.StatusForbidden, "You're not on that channel",
) )
return return
} }
hdlr.sendChannelMsg(
writer, request, command, nick, target, body, chID,
)
}
func (hdlr *Handlers) sendChannelMsg(
writer http.ResponseWriter,
request *http.Request,
command, nick, target string,
body json.RawMessage,
chID int64,
) {
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs( memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
request.Context(), chID, request.Context(), chID,
) )
@@ -761,13 +854,13 @@ func (hdlr *Handlers) handleChannelMsg(
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": msgUUID, "status": "sent"}, map[string]string{"id": msgUUID, "status": "sent"},
http.StatusCreated) http.StatusOK)
} }
func (hdlr *Handlers) handleDirectMsg( func (hdlr *Handlers) handleDirectMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
) { ) {
@@ -775,11 +868,15 @@ func (hdlr *Handlers) handleDirectMsg(
request.Context(), target, request.Context(), target,
) )
if err != nil { if err != nil {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"user not found", "401", nick, []string{target},
http.StatusNotFound, "No such nick/channel",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
@@ -805,20 +902,20 @@ func (hdlr *Handlers) handleDirectMsg(
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": msgUUID, "status": "sent"}, map[string]string{"id": msgUUID, "status": "sent"},
http.StatusCreated) http.StatusOK)
} }
func (hdlr *Handlers) handleJoin( func (hdlr *Handlers) handleJoin(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, target string, nick, target string,
) { ) {
if target == "" { if target == "" {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"to field required", "461", nick, []string{"JOIN"},
http.StatusBadRequest, "Not enough parameters",
) )
return return
@@ -830,15 +927,27 @@ func (hdlr *Handlers) handleJoin(
} }
if !validChannelRe.MatchString(channel) { if !validChannelRe.MatchString(channel) {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"invalid channel name", "403", nick, []string{channel},
http.StatusBadRequest, "No such channel",
) )
return return
} }
hdlr.executeJoin(
writer, request,
sessionID, clientID, nick, channel,
)
}
func (hdlr *Handlers) executeJoin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
) {
chID, err := hdlr.params.Database.GetOrCreateChannel( chID, err := hdlr.params.Database.GetOrCreateChannel(
request.Context(), channel, request.Context(), channel,
) )
@@ -879,6 +988,10 @@ func (hdlr *Handlers) handleJoin(
request, "JOIN", nick, channel, nil, memberIDs, request, "JOIN", nick, channel, nil, memberIDs,
) )
hdlr.deliverJoinNumerics(
request, clientID, sessionID, nick, channel, chID,
)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{ map[string]string{
"status": "joined", "status": "joined",
@@ -887,19 +1000,96 @@ func (hdlr *Handlers) handleJoin(
http.StatusOK) http.StatusOK)
} }
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
func (hdlr *Handlers) deliverJoinNumerics(
request *http.Request,
clientID, sessionID int64,
nick, channel string,
chID int64,
) {
ctx := request.Context()
chInfo, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err == nil {
_ = chInfo // chInfo is the ID; topic comes from DB.
}
// Get topic from channel info.
channels, listErr := hdlr.params.Database.ListChannels(
ctx, sessionID,
)
topic := ""
if listErr == nil {
for _, ch := range channels {
if ch.Name == channel {
topic = ch.Topic
break
}
}
}
if topic != "" {
hdlr.enqueueNumeric(
ctx, clientID, "332", nick,
[]string{channel}, topic,
)
} else {
hdlr.enqueueNumeric(
ctx, clientID, "331", nick,
[]string{channel}, "No topic is set",
)
}
// Get member list for NAMES reply.
members, memErr := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if memErr == nil && len(members) > 0 {
nicks := make([]string, 0, len(members))
for _, mem := range members {
nicks = append(nicks, mem.Nick)
}
hdlr.enqueueNumeric(
ctx, clientID, "353", nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, "366", nick,
[]string{channel}, "End of /NAMES list",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) handlePart( func (hdlr *Handlers) handlePart(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, target string, nick, target string,
body json.RawMessage, body json.RawMessage,
) { ) {
if target == "" { if target == "" {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"to field required", "461", nick, []string{"PART"},
http.StatusBadRequest, "Not enough parameters",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
@@ -913,11 +1103,15 @@ func (hdlr *Handlers) handlePart(
request.Context(), channel, request.Context(), channel,
) )
if err != nil { if err != nil {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"channel not found", "403", nick, []string{channel},
http.StatusNotFound, "No such channel",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
@@ -961,16 +1155,16 @@ func (hdlr *Handlers) handlePart(
func (hdlr *Handlers) handleNick( func (hdlr *Handlers) handleNick(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick string, nick string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"body required (new nick)", "461", nick, []string{"NICK"},
http.StatusBadRequest, "Not enough parameters",
) )
return return
@@ -979,10 +1173,10 @@ func (hdlr *Handlers) handleNick(
newNick := strings.TrimSpace(lines[0]) newNick := strings.TrimSpace(lines[0])
if !validNickRe.MatchString(newNick) { if !validNickRe.MatchString(newNick) {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"invalid nick", "432", nick, []string{newNick},
http.StatusBadRequest, "Erroneous nickname",
) )
return return
@@ -998,15 +1192,27 @@ func (hdlr *Handlers) handleNick(
return return
} }
hdlr.executeNickChange(
writer, request,
sessionID, clientID, nick, newNick,
)
}
func (hdlr *Handlers) executeNickChange(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, newNick string,
) {
err := hdlr.params.Database.ChangeNick( err := hdlr.params.Database.ChangeNick(
request.Context(), sessionID, newNick, request.Context(), sessionID, newNick,
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE") { if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"nick already in use", "433", nick, []string{newNick},
http.StatusConflict, "Nickname is already in use",
) )
return return
@@ -1056,7 +1262,7 @@ func (hdlr *Handlers) broadcastNick(
dbID, _, _ := hdlr.params.Database.InsertMessage( dbID, _, _ := hdlr.params.Database.InsertMessage(
request.Context(), "NICK", oldNick, "", request.Context(), "NICK", oldNick, "",
json.RawMessage(nickBody), nil, nil, json.RawMessage(nickBody), nil,
) )
_ = hdlr.params.Database.EnqueueToSession( _ = hdlr.params.Database.EnqueueToSession(
@@ -1088,15 +1294,16 @@ func (hdlr *Handlers) broadcastNick(
func (hdlr *Handlers) handleTopic( func (hdlr *Handlers) handleTopic(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID, clientID int64,
nick, target string, nick, target string,
body json.RawMessage, body json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target == "" { if target == "" {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"to field required", "461", nick, []string{"TOPIC"},
http.StatusBadRequest, "Not enough parameters",
) )
return return
@@ -1104,46 +1311,60 @@ func (hdlr *Handlers) handleTopic(
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"body required (topic text)", "461", nick, []string{"TOPIC"},
http.StatusBadRequest, "Not enough parameters",
) )
return return
} }
topic := strings.Join(lines, " ")
channel := target channel := target
if !strings.HasPrefix(channel, "#") { if !strings.HasPrefix(channel, "#") {
channel = "#" + channel channel = "#" + channel
} }
err := hdlr.params.Database.SetTopic( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), channel, topic, request.Context(), channel,
) )
if err != nil { if err != nil {
hdlr.log.Error( hdlr.respondIRCError(
"set topic failed", "error", err, writer, request, clientID, sessionID,
) "403", nick, []string{channel},
hdlr.respondError( "No such channel",
writer, request,
"internal error",
http.StatusInternalServerError,
) )
return return
} }
chID, err := hdlr.params.Database.GetChannelByName( hdlr.executeTopic(
request.Context(), channel, writer, request,
sessionID, clientID, nick,
channel, strings.Join(lines, " "),
body, chID,
)
}
func (hdlr *Handlers) executeTopic(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel, topic string,
body json.RawMessage,
chID int64,
) {
setErr := hdlr.params.Database.SetTopic(
request.Context(), channel, topic,
)
if setErr != nil {
hdlr.log.Error(
"set topic failed", "error", setErr,
) )
if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"channel not found", "internal error",
http.StatusNotFound, http.StatusInternalServerError,
) )
return return
@@ -1157,6 +1378,12 @@ func (hdlr *Handlers) handleTopic(
request, "TOPIC", nick, channel, body, memberIDs, request, "TOPIC", nick, channel, body, memberIDs,
) )
hdlr.enqueueNumeric(
request.Context(), clientID,
"332", nick, []string{channel}, topic,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{ map[string]string{
"status": "ok", "topic": topic, "status": "ok", "topic": topic,
@@ -1182,7 +1409,8 @@ func (hdlr *Handlers) handleQuit(
if len(channels) > 0 { if len(channels) > 0 {
dbID, _, _ = hdlr.params.Database.InsertMessage( dbID, _, _ = hdlr.params.Database.InsertMessage(
request.Context(), "QUIT", nick, "", body, nil, request.Context(), "QUIT", nick, "",
nil, body, nil,
) )
} }
@@ -1431,7 +1659,8 @@ func (hdlr *Handlers) cleanupUser(
if len(channels) > 0 { if len(channels) > 0 {
quitDBID, _, _ = hdlr.params.Database.InsertMessage( quitDBID, _, _ = hdlr.params.Database.InsertMessage(
ctx, "QUIT", nick, "", nil, nil, ctx, "QUIT", nick, "",
nil, nil, nil,
) )
} }

View File

@@ -462,6 +462,19 @@ func findMessage(
return false return false
} }
func findNumeric(
msgs []map[string]any,
numeric string,
) bool {
for _, msg := range msgs {
if msg[commandKey] == numeric {
return true
}
}
return false
}
// --- Tests --- // --- Tests ---
func TestCreateSessionValid(t *testing.T) { func TestCreateSessionValid(t *testing.T) {
@@ -473,6 +486,47 @@ 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) { func TestCreateSessionDuplicate(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
tserver.createSession("alice") tserver.createSession("alice")
@@ -668,11 +722,23 @@ func TestJoinMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("joiner3") token := tserver.createSession("joiner3")
// Drain initial MOTD/welcome numerics.
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: joinCmd}, token, map[string]any{commandKey: joinCmd},
) )
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) 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,
)
} }
} }
@@ -699,9 +765,9 @@ func TestChannelMessage(t *testing.T) {
bodyKey: []string{"hello world"}, bodyKey: []string{"hello world"},
}, },
) )
if status != http.StatusCreated { if status != http.StatusOK {
t.Fatalf( t.Fatalf(
"expected 201, got %d: %v", status, result, "expected 200, got %d: %v", status, result,
) )
} }
@@ -728,11 +794,22 @@ func TestMessageMissingBody(t *testing.T) {
commandKey: joinCmd, toKey: "#test", commandKey: joinCmd, toKey: "#test",
}) })
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, toKey: "#test", commandKey: privmsgCmd, toKey: "#test",
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) 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,
)
} }
} }
@@ -740,12 +817,23 @@ func TestMessageMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("noto") token := tserver.createSession("noto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
bodyKey: []string{"hello"}, bodyKey: []string{"hello"},
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) 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,
)
} }
} }
@@ -759,6 +847,8 @@ func TestNonMemberCannotSend(t *testing.T) {
commandKey: joinCmd, toKey: "#private", commandKey: joinCmd, toKey: "#private",
}) })
_, lastID := tserver.pollMessages(aliceToken, 0)
// Alice tries to send without joining. // Alice tries to send without joining.
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
aliceToken, aliceToken,
@@ -768,8 +858,17 @@ func TestNonMemberCannotSend(t *testing.T) {
bodyKey: []string{"sneaky"}, bodyKey: []string{"sneaky"},
}, },
) )
if status != http.StatusForbidden { if status != http.StatusOK {
t.Fatalf("expected 403, got %d", status) 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,
)
} }
} }
@@ -786,9 +885,9 @@ func TestDirectMessage(t *testing.T) {
bodyKey: []string{"hey bob"}, bodyKey: []string{"hey bob"},
}, },
) )
if status != http.StatusCreated { if status != http.StatusOK {
t.Fatalf( t.Fatalf(
"expected 201, got %d: %v", status, result, "expected 200, got %d: %v", status, result,
) )
} }
@@ -818,13 +917,24 @@ func TestDMToNonexistentUser(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("dmsender") token := tserver.createSession("dmsender")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
toKey: "nobody", toKey: "nobody",
bodyKey: []string{"hello?"}, bodyKey: []string{"hello?"},
}) })
if status != http.StatusNotFound { if status != http.StatusOK {
t.Fatalf("expected 404, got %d", status) 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,
)
} }
} }
@@ -871,12 +981,23 @@ func TestNickCollision(t *testing.T) {
tserver.createSession("taken_nick") tserver.createSession("taken_nick")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK", commandKey: "NICK",
bodyKey: []string{"taken_nick"}, bodyKey: []string{"taken_nick"},
}) })
if status != http.StatusConflict { if status != http.StatusOK {
t.Fatalf("expected 409, got %d", status) 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,
)
} }
} }
@@ -884,12 +1005,23 @@ func TestNickInvalid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("nickval") token := tserver.createSession("nickval")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK", commandKey: "NICK",
bodyKey: []string{"bad nick!"}, bodyKey: []string{"bad nick!"},
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) 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,
)
} }
} }
@@ -897,11 +1029,22 @@ func TestNickEmptyBody(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("nicknobody") token := tserver.createSession("nicknobody")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "NICK"}, token, map[string]any{commandKey: "NICK"},
) )
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) 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,
)
} }
} }
@@ -938,12 +1081,23 @@ func TestTopicMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("topicnoto") token := tserver.createSession("topicnoto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", commandKey: "TOPIC",
bodyKey: []string{"topic"}, bodyKey: []string{"topic"},
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) 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,
)
} }
} }
@@ -955,11 +1109,22 @@ func TestTopicMissingBody(t *testing.T) {
commandKey: joinCmd, toKey: "#topictest", commandKey: joinCmd, toKey: "#topictest",
}) })
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", toKey: "#topictest", commandKey: "TOPIC", toKey: "#topictest",
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) 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,
)
} }
} }
@@ -1027,11 +1192,22 @@ func TestUnknownCommand(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("cmdtest") token := tserver.createSession("cmdtest")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "BOGUS"}, token, map[string]any{commandKey: "BOGUS"},
) )
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) 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,
)
} }
} }
@@ -1278,12 +1454,18 @@ func TestLongPollTimeout(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("lp_timeout") token := tserver.createSession("lp_timeout")
// Drain initial welcome/MOTD numerics.
_, lastID := tserver.pollMessages(token, 0)
start := time.Now() start := time.Now()
resp, err := doRequestAuth( resp, err := doRequestAuth(
t, t,
http.MethodGet, http.MethodGet,
tserver.url(apiMessages+"?timeout=1"), tserver.url(fmt.Sprintf(
"%s?timeout=1&after=%d",
apiMessages, lastID,
)),
token, token,
nil, nil,
) )

View File

@@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister(
return return
} }
hdlr.deliverMOTD(request, clientID, sessionID) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
@@ -162,7 +162,7 @@ func (hdlr *Handlers) handleLogin(
return return
} }
sessionID, _, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser( hdlr.params.Database.LoginUser(
request.Context(), request.Context(),
payload.Nick, payload.Nick,
@@ -178,6 +178,10 @@ func (hdlr *Handlers) handleLogin(
return return
} }
hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick,
)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": payload.Nick,

4
web/dist/app.js vendored

File diff suppressed because one or more lines are too long

314
web/dist/style.css vendored
View File

@@ -1,40 +1,28 @@
* { * { margin: 0; padding: 0; box-sizing: border-box; }
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root { :root {
--bg: #0a0e14; --bg: #1a1a2e;
--bg-secondary: #0d1117; --bg-secondary: #16213e;
--bg-input: #161b22; --bg-input: #0f3460;
--bg-highlight: #1a2030; --text: #e0e0e0;
--text: #c9d1d9; --text-muted: #888;
--text-muted: #6e7681; --accent: #e94560;
--accent: #58a6ff; --accent2: #0f3460;
--accent-dim: #1f6feb; --border: #2a2a4a;
--border: #21262d; --nick: #53a8b6;
--nick: #79c0ff; --timestamp: #666;
--timestamp: #484f58; --tab-active: #e94560;
--tab-active: #58a6ff; --tab-bg: #16213e;
--tab-bg: #0d1117; --tab-hover: #1a1a3e;
--tab-hover: #161b22; --topic-bg: #121a30;
--topic-bg: #0d1117; --unread-bg: #e94560;
--unread-bg: #da3633; --warn: #f0ad4e;
--warn: #d29922;
--op-color: #f0883e;
--voice-color: #3fb950;
--action-color: #bc8cff;
--system-color: #484f58;
} }
html, html, body, #root {
body,
#root {
height: 100%; height: 100%;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', font-family: 'Courier New', Courier, monospace;
Courier, monospace; font-size: 14px;
font-size: 13px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
} }
@@ -69,19 +57,15 @@ body,
padding: 10px 24px; padding: 10px 24px;
font-size: 16px; font-size: 16px;
font-family: inherit; font-family: inherit;
background: var(--accent-dim); background: var(--accent);
border: none; border: none;
color: white; color: white;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
} }
.login-screen button:hover {
background: var(--accent);
}
.login-screen .error { .login-screen .error {
color: var(--unread-bg); color: var(--accent);
} }
.login-screen .motd { .login-screen .motd {
@@ -105,63 +89,36 @@ body,
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
overflow-x: auto; overflow-x: auto;
flex-shrink: 0; flex-shrink: 0;
align-items: stretch; align-items: center;
min-height: 32px;
}
.tab-bar::-webkit-scrollbar {
height: 2px;
}
.tab-bar::-webkit-scrollbar-thumb {
background: var(--border);
} }
.tab { .tab {
display: flex; padding: 8px 16px;
align-items: center;
padding: 6px 12px;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
white-space: nowrap; white-space: nowrap;
color: var(--text-muted); color: var(--text-muted);
user-select: none; user-select: none;
font-size: 12px; position: relative;
gap: 6px;
transition:
background 0.1s,
color 0.1s;
} }
.tab:hover { .tab:hover {
background: var(--tab-hover); background: var(--tab-hover);
color: var(--text);
} }
.tab.active { .tab.active {
color: var(--text); color: var(--text);
border-bottom-color: var(--tab-active); border-bottom-color: var(--tab-active);
background: var(--bg-highlight);
}
.tab.server {
font-weight: bold;
}
.tab .tab-name {
overflow: hidden;
text-overflow: ellipsis;
} }
.tab .close-btn { .tab .close-btn {
margin-left: 8px;
color: var(--text-muted); color: var(--text-muted);
font-size: 14px; font-size: 12px;
line-height: 1;
flex-shrink: 0;
} }
.tab .close-btn:hover { .tab .close-btn:hover {
color: var(--unread-bg); color: var(--accent);
} }
.tab .unread-badge { .tab .unread-badge {
@@ -170,22 +127,19 @@ body,
color: white; color: white;
font-size: 10px; font-size: 10px;
font-weight: bold; font-weight: bold;
padding: 0 5px; padding: 1px 5px;
border-radius: 8px; border-radius: 8px;
margin-left: 6px;
min-width: 16px; min-width: 16px;
text-align: center; text-align: center;
line-height: 16px;
flex-shrink: 0;
} }
/* Connection status */ /* Connection status */
.connection-status { .connection-status {
display: flex; padding: 4px 12px;
align-items: center;
padding: 0 12px;
background: var(--warn); background: var(--warn);
color: var(--bg); color: #1a1a2e;
font-size: 11px; font-size: 12px;
font-weight: bold; font-weight: bold;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
@@ -193,7 +147,7 @@ body,
/* Topic bar */ /* Topic bar */
.topic-bar { .topic-bar {
padding: 4px 12px; padding: 6px 12px;
background: var(--topic-bg); background: var(--topic-bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
color: var(--text-muted); color: var(--text-muted);
@@ -204,11 +158,6 @@ body,
flex-shrink: 0; flex-shrink: 0;
} }
.topic-bar .topic-label {
color: var(--accent);
font-weight: bold;
}
/* Content area */ /* Content area */
.content { .content {
display: flex; display: flex;
@@ -222,210 +171,147 @@ body,
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-width: 0;
} }
.messages { .messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px 0; padding: 8px 12px;
}
.messages::-webkit-scrollbar {
width: 6px;
}
.messages::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
} }
.message { .message {
padding: 1px 12px; padding: 2px 0;
line-height: 1.5; line-height: 1.4;
word-wrap: break-word; word-wrap: break-word;
} }
.message:hover {
background: var(--bg-highlight);
}
.message .timestamp { .message .timestamp {
color: var(--timestamp); color: var(--timestamp);
font-size: 11px; font-size: 12px;
margin-right: 6px; margin-right: 8px;
} }
.message .nick { .message .nick {
color: var(--nick);
font-weight: bold; font-weight: bold;
margin-right: 6px; margin-right: 8px;
} }
.message .nick::before { .message .nick::before { content: '<'; }
content: '<'; .message .nick::after { content: '>'; }
color: var(--text-muted);
}
.message .nick::after {
content: '>';
color: var(--text-muted);
}
.message.system { .message.system {
color: var(--system-color); color: var(--text-muted);
font-style: italic; font-style: italic;
} }
.message.system .timestamp { .message.system .nick {
color: var(--timestamp); color: var(--text-muted);
} }
.message.system .content::before { .message.system .nick::before,
content: '*** '; .message.system .nick::after { content: ''; }
}
.message.action { /* Input */
color: var(--action-color);
}
.message.action .timestamp {
color: var(--timestamp);
}
.message.action .action-nick {
font-weight: bold;
}
/* Input bar — full width at bottom */
.input-bar { .input-bar {
display: flex; display: flex;
align-items: center;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
background: var(--bg-secondary); background: var(--bg-secondary);
flex-shrink: 0; flex-shrink: 0;
} }
.input-bar .input-nick {
padding: 0 8px 0 12px;
color: var(--accent);
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
font-size: 13px;
}
.input-bar .input-nick::after {
content: '>';
color: var(--text-muted);
margin-left: 1px;
}
.input-bar input { .input-bar input {
flex: 1; flex: 1;
padding: 8px 8px; padding: 10px 12px;
font-family: inherit; font-family: inherit;
font-size: 13px; font-size: 14px;
background: transparent; background: var(--bg-input);
border: none; border: none;
color: var(--text); color: var(--text);
outline: none; outline: none;
} }
.input-bar input::placeholder { .input-bar button {
color: var(--text-muted); padding: 10px 16px;
font-family: inherit;
background: var(--accent);
border: none;
color: white;
cursor: pointer;
} }
/* User list */ /* User list */
.user-list { .user-list {
width: 170px; width: 160px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
display: flex; overflow-y: auto;
flex-direction: column; padding: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.user-list-header { .user-list h3 {
padding: 6px 10px;
color: var(--text-muted); color: var(--text-muted);
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; margin-bottom: 8px;
border-bottom: 1px solid var(--border); letter-spacing: 1px;
font-weight: bold;
flex-shrink: 0;
}
.user-list-entries {
overflow-y: auto;
flex: 1;
padding: 4px 0;
}
.user-list-entries::-webkit-scrollbar {
width: 4px;
}
.user-list-entries::-webkit-scrollbar-thumb {
background: var(--border);
} }
.user-list .user { .user-list .user {
padding: 2px 10px; padding: 3px 4px;
font-size: 12px; color: var(--nick);
font-size: 13px;
cursor: pointer; cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
} }
.user-list .user:hover { .user-list .user:hover {
background: var(--tab-hover); background: var(--tab-hover);
} }
.user-list .user.op { /* Server tab */
color: var(--op-color);
}
.user-list .user.voice {
color: var(--voice-color);
}
/* Server tab messages */
.server-messages { .server-messages {
color: var(--text-muted); color: var(--text-muted);
padding: 8px 12px; padding: 12px;
white-space: pre-wrap; white-space: pre-wrap;
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
} }
.server-messages .message { /* Channel join dialog */
padding: 1px 0; .join-dialog {
padding: 12px;
display: flex;
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
margin-left: auto;
} }
.server-messages .message:hover { .join-dialog input {
background: var(--bg-highlight); 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 */ /* Responsive */
@media (max-width: 600px) { @media (max-width: 600px) {
.user-list { .user-list { display: none; }
display: none; .tab { padding: 6px 10px; font-size: 13px; }
}
.tab {
padding: 5px 8px;
font-size: 11px;
}
.input-bar .input-nick {
padding-left: 8px;
font-size: 12px;
}
.input-bar input {
font-size: 12px;
}
} }

View File

@@ -5,17 +5,13 @@ const API = '/api/v1';
const POLL_TIMEOUT = 15; const POLL_TIMEOUT = 15;
const RECONNECT_DELAY = 3000; const RECONNECT_DELAY = 3000;
const MEMBER_REFRESH_INTERVAL = 10000; const MEMBER_REFRESH_INTERVAL = 10000;
const MAX_HISTORY = 100;
function api(path, opts = {}) { function api(path, opts = {}) {
const token = localStorage.getItem('neoirc_token'); const token = localStorage.getItem('neoirc_token');
const headers = { const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
'Content-Type': 'application/json',
...(opts.headers || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
const { signal, ...rest } = opts; const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => { return fetch(API + path, { ...rest, headers, signal }).then(async r => {
const data = await r.json().catch(() => null); const data = await r.json().catch(() => null);
if (!r.ok) throw { status: r.status, data }; if (!r.ok) throw { status: r.status, data };
return data; return data;
@@ -24,17 +20,12 @@ function api(path, opts = {}) {
function formatTime(ts) { function formatTime(ts) {
const d = new Date(ts); const d = new Date(ts);
return d.toLocaleTimeString([], { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} }
function nickColor(nick) { function nickColor(nick) {
let h = 0; let h = 0;
for (let i = 0; i < nick.length; i++) for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h);
h = nick.charCodeAt(i) + ((h << 5) - h);
const hue = Math.abs(h) % 360; const hue = Math.abs(h) % 360;
return `hsl(${hue}, 70%, 65%)`; return `hsl(${hue}, 70%, 65%)`;
} }
@@ -47,17 +38,13 @@ function LoginScreen({ onLogin }) {
const inputRef = useRef(); const inputRef = useRef();
useEffect(() => { useEffect(() => {
api('/server') api('/server').then(s => {
.then((s) => {
if (s.name) setServerName(s.name); if (s.name) setServerName(s.name);
if (s.motd) setMotd(s.motd); if (s.motd) setMotd(s.motd);
}) }).catch(() => {});
.catch(() => {});
const saved = localStorage.getItem('neoirc_token'); const saved = localStorage.getItem('neoirc_token');
if (saved) { if (saved) {
api('/state') api('/state').then(u => onLogin(u.nick)).catch(() => localStorage.removeItem('neoirc_token'));
.then((u) => onLogin(u.nick))
.catch(() => localStorage.removeItem('neoirc_token'));
} }
inputRef.current?.focus(); inputRef.current?.focus();
}, []); }, []);
@@ -68,7 +55,7 @@ function LoginScreen({ onLogin }) {
try { try {
const res = await api('/session', { const res = await api('/session', {
method: 'POST', method: 'POST',
body: JSON.stringify({ nick: nick.trim() }), body: JSON.stringify({ nick: nick.trim() })
}); });
localStorage.setItem('neoirc_token', res.token); localStorage.setItem('neoirc_token', res.token);
onLogin(res.nick); onLogin(res.nick);
@@ -87,7 +74,7 @@ function LoginScreen({ onLogin }) {
type="text" type="text"
placeholder="Choose a nickname..." placeholder="Choose a nickname..."
value={nick} value={nick}
onInput={(e) => setNick(e.target.value)} onInput={e => setNick(e.target.value)}
maxLength={32} maxLength={32}
autoFocus autoFocus
/> />
@@ -107,64 +94,15 @@ function Message({ msg }) {
</div> </div>
); );
} }
if (msg.action) {
return (
<div class="message action">
<span class="timestamp">{formatTime(msg.ts)}</span>
<span class="content">
{'* '}
<span class="action-nick" style={{ color: nickColor(msg.from) }}>
{msg.from}
</span>{' '}
{msg.text}
</span>
</div>
);
}
return ( return (
<div class="message"> <div class="message">
<span class="timestamp">{formatTime(msg.ts)}</span> <span class="timestamp">{formatTime(msg.ts)}</span>
<span class="nick" style={{ color: nickColor(msg.from) }}> <span class="nick" style={{ color: nickColor(msg.from) }}>{msg.from}</span>
{msg.from}
</span>
<span class="content">{msg.text}</span> <span class="content">{msg.text}</span>
</div> </div>
); );
} }
function UserList({ members, userModes, onDM }) {
const sorted = [...members].sort((a, b) => {
const modeA = userModes[a.nick] || '';
const modeB = userModes[b.nick] || '';
const rankA = modeA === 'o' ? 0 : modeA === 'v' ? 1 : 2;
const rankB = modeB === 'o' ? 0 : modeB === 'v' ? 1 : 2;
if (rankA !== rankB) return rankA - rankB;
return a.nick.localeCompare(b.nick);
});
return (
<div class="user-list">
<div class="user-list-header">Users ({members.length})</div>
<div class="user-list-entries">
{sorted.map((u) => {
const mode = userModes[u.nick] || '';
const prefix = mode === 'o' ? '@' : mode === 'v' ? '+' : '';
return (
<div
class={`user ${mode === 'o' ? 'op' : mode === 'v' ? 'voice' : ''}`}
onClick={() => onDM(u.nick)}
title={u.nick}
>
{prefix}
{u.nick}
</div>
);
})}
</div>
</div>
);
}
function App() { function App() {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [nick, setNick] = useState(''); const [nick, setNick] = useState('');
@@ -172,13 +110,11 @@ function App() {
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [messages, setMessages] = useState({ Server: [] }); const [messages, setMessages] = useState({ Server: [] });
const [members, setMembers] = useState({}); const [members, setMembers] = useState({});
const [userModes, setUserModes] = useState({});
const [topics, setTopics] = useState({}); const [topics, setTopics] = useState({});
const [unread, setUnread] = useState({}); const [unread, setUnread] = useState({});
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [joinInput, setJoinInput] = useState('');
const [connected, setConnected] = useState(true); const [connected, setConnected] = useState(true);
const [cmdHistory, setCmdHistory] = useState([]);
const [historyIdx, setHistoryIdx] = useState(-1);
const lastIdRef = useRef(0); const lastIdRef = useRef(0);
const seenIdsRef = useRef(new Set()); const seenIdsRef = useRef(new Set());
@@ -189,115 +125,69 @@ function App() {
const messagesEndRef = useRef(); const messagesEndRef = useRef();
const inputRef = useRef(); const inputRef = useRef();
useEffect(() => { useEffect(() => { tabsRef.current = tabs; }, [tabs]);
tabsRef.current = tabs; useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]);
}, [tabs]); useEffect(() => { nickRef.current = nick; }, [nick]);
useEffect(() => {
activeTabRef.current = activeTab;
}, [activeTab]);
useEffect(() => {
nickRef.current = nick;
}, [nick]);
// Persist joined channels // Persist joined channels
useEffect(() => { useEffect(() => {
const channels = tabs.filter((t) => t.type === 'channel').map((t) => t.name); const channels = tabs.filter(t => t.type === 'channel').map(t => t.name);
localStorage.setItem('neoirc_channels', JSON.stringify(channels)); localStorage.setItem('neoirc_channels', JSON.stringify(channels));
}, [tabs]); }, [tabs]);
// Clear unread on tab switch // Clear unread on tab switch
useEffect(() => { useEffect(() => {
const tab = tabs[activeTab]; const tab = tabs[activeTab];
if (tab) setUnread((prev) => ({ ...prev, [tab.name]: 0 })); if (tab) setUnread(prev => ({ ...prev, [tab.name]: 0 }));
}, [activeTab, tabs]); }, [activeTab, tabs]);
const addMessage = useCallback((tabName, msg) => { const addMessage = useCallback((tabName, msg) => {
if (msg.id && seenIdsRef.current.has(msg.id)) return; if (msg.id && seenIdsRef.current.has(msg.id)) return;
if (msg.id) seenIdsRef.current.add(msg.id); if (msg.id) seenIdsRef.current.add(msg.id);
setMessages((prev) => ({ setMessages(prev => ({
...prev, ...prev,
[tabName]: [...(prev[tabName] || []), msg], [tabName]: [...(prev[tabName] || []), msg]
})); }));
const currentTab = tabsRef.current[activeTabRef.current]; const currentTab = tabsRef.current[activeTabRef.current];
if (!currentTab || currentTab.name !== tabName) { if (!currentTab || currentTab.name !== tabName) {
setUnread((prev) => ({ setUnread(prev => ({ ...prev, [tabName]: (prev[tabName] || 0) + 1 }));
...prev,
[tabName]: (prev[tabName] || 0) + 1,
}));
} }
}, []); }, []);
const addSystemMessage = useCallback((tabName, text) => { const addSystemMessage = useCallback((tabName, text) => {
setMessages((prev) => ({ setMessages(prev => ({
...prev, ...prev,
[tabName]: [ [tabName]: [...(prev[tabName] || []), {
...(prev[tabName] || []),
{
id: 'sys-' + Date.now() + '-' + Math.random(), id: 'sys-' + Date.now() + '-' + Math.random(),
ts: new Date().toISOString(), ts: new Date().toISOString(),
text, text,
system: true, system: true
}, }]
],
})); }));
}, []); }, []);
const refreshMembers = useCallback((channel) => { const refreshMembers = useCallback((channel) => {
const chName = channel.replace('#', ''); const chName = channel.replace('#', '');
api(`/channels/${chName}/members`) api(`/channels/${chName}/members`).then(m => {
.then((m) => { setMembers(prev => ({ ...prev, [channel]: m }));
setMembers((prev) => ({ ...prev, [channel]: m })); }).catch(() => {});
})
.catch(() => {});
}, []); }, []);
const parseNamesReply = useCallback((channel, namesStr) => { const processMessage = useCallback((msg) => {
// Parse 353 RPL_NAMREPLY body: "@op1 alice bob +voiced1"
const nicks = namesStr.trim().split(/\s+/);
const modes = {};
for (const entry of nicks) {
if (entry.startsWith('@')) {
modes[entry.slice(1)] = 'o';
} else if (entry.startsWith('+')) {
modes[entry.slice(1)] = 'v';
}
// plain nicks have no mode entry
}
setUserModes((prev) => ({
...prev,
[channel]: { ...(prev[channel] || {}), ...modes },
}));
}, []);
const processMessage = useCallback(
(msg) => {
const body = Array.isArray(msg.body) ? msg.body.join('\n') : ''; const body = Array.isArray(msg.body) ? msg.body.join('\n') : '';
const base = { const base = { id: msg.id, ts: msg.ts, from: msg.from, to: msg.to, command: msg.command };
id: msg.id,
ts: msg.ts,
from: msg.from,
to: msg.to,
command: msg.command,
};
const isAction = msg.meta && msg.meta.action;
switch (msg.command) { switch (msg.command) {
case 'PRIVMSG': case 'PRIVMSG':
case 'NOTICE': { case 'NOTICE': {
const parsed = { const parsed = { ...base, text: body, system: false };
...base,
text: body,
system: false,
action: isAction,
};
const target = msg.to; const target = msg.to;
if (target && target.startsWith('#')) { if (target && target.startsWith('#')) {
addMessage(target, parsed); addMessage(target, parsed);
} else { } else {
const dmPeer = const dmPeer = msg.from === nickRef.current ? msg.to : msg.from;
msg.from === nickRef.current ? msg.to : msg.from; setTabs(prev => {
setTabs((prev) => { if (!prev.find(t => t.type === 'dm' && t.name === dmPeer)) {
if (!prev.find((t) => t.type === 'dm' && t.name === dmPeer)) {
return [...prev, { type: 'dm', name: dmPeer }]; return [...prev, { type: 'dm', name: dmPeer }];
} }
return prev; return prev;
@@ -308,23 +198,21 @@ function App() {
} }
case 'JOIN': { case 'JOIN': {
const text = `${msg.from} has joined ${msg.to}`; const text = `${msg.from} has joined ${msg.to}`;
if (msg.to) if (msg.to) addMessage(msg.to, { ...base, text, system: true });
addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to); if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to);
break; break;
} }
case 'PART': { case 'PART': {
const reason = body ? ': ' + body : ''; const reason = body ? ': ' + body : '';
const text = `${msg.from} has parted ${msg.to}${reason}`; const text = `${msg.from} has left ${msg.to}${reason}`;
if (msg.to) if (msg.to) addMessage(msg.to, { ...base, text, system: true });
addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to); if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to);
break; break;
} }
case 'QUIT': { case 'QUIT': {
const reason = body ? ': ' + body : ''; const reason = body ? ': ' + body : '';
const text = `${msg.from} has quit${reason}`; const text = `${msg.from} has quit${reason}`;
tabsRef.current.forEach((tab) => { tabsRef.current.forEach(tab => {
if (tab.type === 'channel') { if (tab.type === 'channel') {
addMessage(tab.name, { ...base, text, system: true }); addMessage(tab.name, { ...base, text, system: true });
} }
@@ -334,13 +222,14 @@ function App() {
case 'NICK': { case 'NICK': {
const newNick = Array.isArray(msg.body) ? msg.body[0] : body; const newNick = Array.isArray(msg.body) ? msg.body[0] : body;
const text = `${msg.from} is now known as ${newNick}`; const text = `${msg.from} is now known as ${newNick}`;
tabsRef.current.forEach((tab) => { tabsRef.current.forEach(tab => {
if (tab.type === 'channel') { if (tab.type === 'channel') {
addMessage(tab.name, { ...base, text, system: true }); addMessage(tab.name, { ...base, text, system: true });
} }
}); });
if (msg.from === nickRef.current && newNick) setNick(newNick); if (msg.from === nickRef.current && newNick) setNick(newNick);
tabsRef.current.forEach((tab) => { // Refresh members in all channels
tabsRef.current.forEach(tab => {
if (tab.type === 'channel') refreshMembers(tab.name); if (tab.type === 'channel') refreshMembers(tab.name);
}); });
break; break;
@@ -349,50 +238,19 @@ function App() {
const text = `${msg.from} set the topic: ${body}`; const text = `${msg.from} set the topic: ${body}`;
if (msg.to) { if (msg.to) {
addMessage(msg.to, { ...base, text, system: true }); addMessage(msg.to, { ...base, text, system: true });
setTopics((prev) => ({ ...prev, [msg.to]: body })); setTopics(prev => ({ ...prev, [msg.to]: body }));
} }
break; break;
} }
case '332': {
// RPL_TOPIC — channel topic on join
const channel =
Array.isArray(msg.params) && msg.params.length > 0
? msg.params[0]
: msg.to;
if (channel) {
setTopics((prev) => ({ ...prev, [channel]: body }));
}
break;
}
case '353': {
// RPL_NAMREPLY — parse user modes from names list
const channel =
Array.isArray(msg.params) && msg.params.length > 1
? msg.params[1]
: msg.to;
if (channel && body) {
parseNamesReply(channel, body);
}
break;
}
case '366':
// RPL_ENDOFNAMES — ignore
break;
case '375': case '375':
case '372': case '372':
case '376': case '376':
addMessage('Server', { ...base, text: body, system: true }); addMessage('Server', { ...base, text: body, system: true });
break; break;
default: default:
addMessage('Server', { addMessage('Server', { ...base, text: body || msg.command, system: true });
...base,
text: body || msg.command,
system: true,
});
} }
}, }, [addMessage, refreshMembers]);
[addMessage, refreshMembers, parseNamesReply],
);
// Long-poll loop // Long-poll loop
useEffect(() => { useEffect(() => {
@@ -406,7 +264,7 @@ function App() {
pollAbortRef.current = controller; pollAbortRef.current = controller;
const result = await api( const result = await api(
`/messages?after=${lastIdRef.current}&timeout=${POLL_TIMEOUT}`, `/messages?after=${lastIdRef.current}&timeout=${POLL_TIMEOUT}`,
{ signal: controller.signal }, { signal: controller.signal }
); );
if (!alive) break; if (!alive) break;
setConnected(true); setConnected(true);
@@ -420,16 +278,13 @@ function App() {
if (!alive) break; if (!alive) break;
if (err.name === 'AbortError') continue; if (err.name === 'AbortError') continue;
setConnected(false); setConnected(false);
await new Promise((r) => setTimeout(r, RECONNECT_DELAY)); await new Promise(r => setTimeout(r, RECONNECT_DELAY));
} }
} }
}; };
poll(); poll();
return () => { return () => { alive = false; pollAbortRef.current?.abort(); };
alive = false;
pollAbortRef.current?.abort();
};
}, [loggedIn, processMessage]); }, [loggedIn, processMessage]);
// Refresh members for active channel // Refresh members for active channel
@@ -438,10 +293,7 @@ function App() {
const tab = tabs[activeTab]; const tab = tabs[activeTab];
if (!tab || tab.type !== 'channel') return; if (!tab || tab.type !== 'channel') return;
refreshMembers(tab.name); refreshMembers(tab.name);
const iv = setInterval( const iv = setInterval(() => refreshMembers(tab.name), MEMBER_REFRESH_INTERVAL);
() => refreshMembers(tab.name),
MEMBER_REFRESH_INTERVAL,
);
return () => clearInterval(iv); return () => clearInterval(iv);
}, [loggedIn, activeTab, tabs, refreshMembers]); }, [loggedIn, activeTab, tabs, refreshMembers]);
@@ -451,100 +303,71 @@ function App() {
}, [messages, activeTab]); }, [messages, activeTab]);
// Focus input on tab change // Focus input on tab change
useEffect(() => { useEffect(() => { inputRef.current?.focus(); }, [activeTab]);
inputRef.current?.focus();
}, [activeTab]);
// Fetch topic for active channel // Fetch topic for active channel
useEffect(() => { useEffect(() => {
if (!loggedIn) return; if (!loggedIn) return;
const tab = tabs[activeTab]; const tab = tabs[activeTab];
if (!tab || tab.type !== 'channel') return; if (!tab || tab.type !== 'channel') return;
api('/channels') api('/channels').then(channels => {
.then((channels) => { const ch = channels.find(c => c.name === tab.name);
const ch = channels.find((c) => c.name === tab.name); if (ch && ch.topic) setTopics(prev => ({ ...prev, [tab.name]: ch.topic }));
if (ch && ch.topic) }).catch(() => {});
setTopics((prev) => ({ ...prev, [tab.name]: ch.topic }));
})
.catch(() => {});
}, [loggedIn, activeTab, tabs]); }, [loggedIn, activeTab, tabs]);
const onLogin = useCallback( const onLogin = useCallback(async (userNick) => {
async (userNick) => {
setNick(userNick); setNick(userNick);
setLoggedIn(true); setLoggedIn(true);
addSystemMessage('Server', `Connected as ${userNick}`); addSystemMessage('Server', `Connected as ${userNick}`);
// Auto-rejoin saved channels // Auto-rejoin saved channels
const saved = JSON.parse( const saved = JSON.parse(localStorage.getItem('neoirc_channels') || '[]');
localStorage.getItem('neoirc_channels') || '[]',
);
for (const ch of saved) { for (const ch of saved) {
try { try {
await api('/messages', { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) });
method: 'POST', setTabs(prev => {
body: JSON.stringify({ command: 'JOIN', to: ch }), if (prev.find(t => t.type === 'channel' && t.name === ch)) return prev;
});
setTabs((prev) => {
if (prev.find((t) => t.type === 'channel' && t.name === ch))
return prev;
return [...prev, { type: 'channel', name: ch }]; return [...prev, { type: 'channel', name: ch }];
}); });
} catch (e) { } catch (e) {
// Channel may not exist anymore // Channel may not exist anymore
} }
} }
}, }, [addSystemMessage]);
[addSystemMessage],
);
const joinChannel = async (name) => { const joinChannel = async (name) => {
if (!name) return; if (!name) return;
name = name.trim(); name = name.trim();
if (!name.startsWith('#')) name = '#' + name; if (!name.startsWith('#')) name = '#' + name;
try { try {
await api('/messages', { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: name }) });
method: 'POST', setTabs(prev => {
body: JSON.stringify({ command: 'JOIN', to: name }), if (prev.find(t => t.type === 'channel' && t.name === name)) return prev;
});
setTabs((prev) => {
if (prev.find((t) => t.type === 'channel' && t.name === name))
return prev;
return [...prev, { type: 'channel', name }]; return [...prev, { type: 'channel', name }];
}); });
setActiveTab(tabs.length); setActiveTab(tabs.length);
// Load history // Load history
try { try {
const hist = await api( const hist = await api(`/history?target=${encodeURIComponent(name)}&limit=50`);
`/history?target=${encodeURIComponent(name)}&limit=50`,
);
if (Array.isArray(hist)) { if (Array.isArray(hist)) {
for (const m of hist) processMessage(m); for (const m of hist) processMessage(m);
} }
} catch (e) { } catch (e) {
// History may be empty // History may be empty
} }
setJoinInput('');
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage('Server', `Failed to join ${name}: ${err.data?.error || 'error'}`);
'Server',
`Failed to join ${name}: ${err.data?.error || 'error'}`,
);
} }
}; };
const partChannel = async (name, reason) => { const partChannel = async (name) => {
try { try {
const payload = { command: 'PART', to: name }; await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PART', to: name }) });
if (reason) payload.body = [reason];
await api('/messages', {
method: 'POST',
body: JSON.stringify(payload),
});
} catch (e) { } catch (e) {
// Ignore // Ignore
} }
setTabs((prev) => setTabs(prev => prev.filter(t => !(t.type === 'channel' && t.name === name)));
prev.filter((t) => !(t.type === 'channel' && t.name === name)),
);
setActiveTab(0); setActiveTab(0);
}; };
@@ -553,284 +376,68 @@ function App() {
if (tab.type === 'channel') { if (tab.type === 'channel') {
partChannel(tab.name); partChannel(tab.name);
} else if (tab.type === 'dm') { } else if (tab.type === 'dm') {
setTabs((prev) => prev.filter((_, i) => i !== idx)); setTabs(prev => prev.filter((_, i) => i !== idx));
if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1)); if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1));
} }
}; };
const openDM = (targetNick) => { const openDM = (targetNick) => {
setTabs((prev) => { setTabs(prev => {
if (prev.find((t) => t.type === 'dm' && t.name === targetNick)) if (prev.find(t => t.type === 'dm' && t.name === targetNick)) return prev;
return prev;
return [...prev, { type: 'dm', name: targetNick }]; return [...prev, { type: 'dm', name: targetNick }];
}); });
const idx = tabs.findIndex( const idx = tabs.findIndex(t => t.type === 'dm' && t.name === targetNick);
(t) => t.type === 'dm' && t.name === targetNick,
);
setActiveTab(idx >= 0 ? idx : tabs.length); setActiveTab(idx >= 0 ? idx : tabs.length);
}; };
const handleCommand = async (text) => {
const parts = text.split(' ');
const cmd = parts[0].toLowerCase();
const tab = tabs[activeTab];
switch (cmd) {
case '/join':
if (parts[1]) joinChannel(parts[1]);
else
addSystemMessage(
tab?.name || 'Server',
'Usage: /join #channel',
);
break;
case '/part': {
if (tab?.type === 'channel') {
const reason = parts.slice(1).join(' ') || '';
partChannel(tab.name, reason);
} else {
addSystemMessage(
tab?.name || 'Server',
'Not in a channel',
);
}
break;
}
case '/msg':
if (parts[1] && parts.slice(2).join(' ')) {
const target = parts[1];
const msgBody = parts.slice(2).join(' ');
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'PRIVMSG',
to: target,
body: [msgBody],
}),
});
openDM(target);
} catch (err) {
addSystemMessage(
'Server',
`DM failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(
tab?.name || 'Server',
'Usage: /msg <nick> <message>',
);
}
break;
case '/me':
if (tab && tab.type !== 'server') {
const actionText = parts.slice(1).join(' ');
if (actionText) {
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'PRIVMSG',
to: tab.name,
body: [actionText],
meta: { action: true },
}),
});
} catch (err) {
addSystemMessage(
tab.name,
`Action failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(tab.name, 'Usage: /me <action>');
}
}
break;
case '/nick':
if (parts[1]) {
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'NICK',
body: [parts[1]],
}),
});
} catch (err) {
addSystemMessage(
'Server',
`Nick change failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(
tab?.name || 'Server',
'Usage: /nick <newnick>',
);
}
break;
case '/topic':
if (tab?.type === 'channel') {
const topicText = parts.slice(1).join(' ');
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'TOPIC',
to: tab.name,
body: [topicText],
}),
});
} catch (err) {
addSystemMessage(
tab.name,
`Topic failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(
tab?.name || 'Server',
'Not in a channel',
);
}
break;
case '/mode':
if (tab?.type === 'channel' && parts.length >= 2) {
const modeTarget = parts[1].startsWith('#')
? parts[1]
: tab.name;
const modeParams = parts[1].startsWith('#')
? parts.slice(2)
: parts.slice(1);
try {
await api('/messages', {
method: 'POST',
body: JSON.stringify({
command: 'MODE',
to: modeTarget,
params: modeParams,
}),
});
} catch (err) {
addSystemMessage(
tab.name,
`Mode failed: ${err.data?.error || 'error'}`,
);
}
} else {
addSystemMessage(
tab?.name || 'Server',
'Usage: /mode [#channel] <+/-mode> [nick]',
);
}
break;
case '/quit': {
const quitMsg = parts.slice(1).join(' ');
try {
const payload = { command: 'QUIT' };
if (quitMsg) payload.body = [quitMsg];
await api('/messages', {
method: 'POST',
body: JSON.stringify(payload),
});
} catch (e) {
// Ignore
}
localStorage.removeItem('neoirc_token');
window.location.reload();
break;
}
case '/help':
addSystemMessage(
tab?.name || 'Server',
'Commands: /join #channel, /part [reason], /msg nick message, ' +
'/me action, /nick newnick, /topic text, /mode [#channel] +/-mode [nick], ' +
'/quit [reason], /help',
);
break;
default:
addSystemMessage(
tab?.name || 'Server',
`Unknown command: ${cmd} — Type /help for available commands`,
);
}
};
const sendMessage = async () => { const sendMessage = async () => {
const text = input.trim(); const text = input.trim();
if (!text) return; if (!text) return;
// Add to command history
setCmdHistory((prev) => {
const next = [text, ...prev];
return next.slice(0, MAX_HISTORY);
});
setHistoryIdx(-1);
setInput(''); setInput('');
const tab = tabs[activeTab]; const tab = tabs[activeTab];
if (!tab || tab.type === 'server') return;
if (text.startsWith('/')) { if (text.startsWith('/')) {
await handleCommand(text); const parts = text.split(' ');
const cmd = parts[0].toLowerCase();
if (cmd === '/join' && parts[1]) { joinChannel(parts[1]); return; }
if (cmd === '/part') { if (tab.type === 'channel') partChannel(tab.name); return; }
if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) {
const target = parts[1];
const body = parts.slice(2).join(' ');
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [body] }) });
openDM(target);
} catch (err) {
addSystemMessage('Server', `DM failed: ${err.data?.error || 'error'}`);
}
return; return;
} }
if (cmd === '/nick' && parts[1]) {
if (!tab || tab.type === 'server') { try {
addSystemMessage( await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'NICK', body: [parts[1]] }) });
'Server', } catch (err) {
'Cannot send messages to the server tab. Join a channel with /join #channel', addSystemMessage('Server', `Nick change failed: ${err.data?.error || 'error'}`);
); }
return;
}
if (cmd === '/topic' && tab.type === 'channel') {
const topicText = parts.slice(1).join(' ');
try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'TOPIC', to: tab.name, body: [topicText] }) });
} catch (err) {
addSystemMessage('Server', `Topic failed: ${err.data?.error || 'error'}`);
}
return;
}
addSystemMessage('Server', `Unknown command: ${cmd}`);
return; return;
} }
try { try {
await api('/messages', { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: tab.name, body: [text] }) });
method: 'POST',
body: JSON.stringify({
command: 'PRIVMSG',
to: tab.name,
body: [text],
}),
});
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`);
tab.name,
`Send failed: ${err.data?.error || 'error'}`,
);
}
};
const handleInputKeyDown = (e) => {
if (e.key === 'Enter') {
sendMessage();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (cmdHistory.length > 0) {
const nextIdx = Math.min(historyIdx + 1, cmdHistory.length - 1);
setHistoryIdx(nextIdx);
setInput(cmdHistory[nextIdx]);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIdx > 0) {
const nextIdx = historyIdx - 1;
setHistoryIdx(nextIdx);
setInput(cmdHistory[nextIdx]);
} else {
setHistoryIdx(-1);
setInput('');
}
} }
}; };
@@ -839,86 +446,71 @@ function App() {
const currentTab = tabs[activeTab] || tabs[0]; const currentTab = tabs[activeTab] || tabs[0];
const currentMessages = messages[currentTab.name] || []; const currentMessages = messages[currentTab.name] || [];
const currentMembers = members[currentTab.name] || []; const currentMembers = members[currentTab.name] || [];
const currentUserModes = userModes[currentTab.name] || {};
const currentTopic = topics[currentTab.name] || ''; const currentTopic = topics[currentTab.name] || '';
return ( return (
<div class="app"> <div class="app">
<div class="tab-bar"> <div class="tab-bar">
{!connected && ( {!connected && <div class="connection-status"> Reconnecting...</div>}
<div class="connection-status"> Reconnecting...</div>
)}
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<div <div
class={`tab ${i === activeTab ? 'active' : ''} ${tab.type}`} class={`tab ${i === activeTab ? 'active' : ''}`}
onClick={() => setActiveTab(i)} onClick={() => setActiveTab(i)}
> >
<span class="tab-name">
{tab.type === 'dm' ? `${tab.name}` : tab.name} {tab.type === 'dm' ? `${tab.name}` : tab.name}
</span>
{unread[tab.name] > 0 && i !== activeTab && ( {unread[tab.name] > 0 && i !== activeTab && (
<span class="unread-badge">{unread[tab.name]}</span> <span class="unread-badge">{unread[tab.name]}</span>
)} )}
{tab.type !== 'server' && ( {tab.type !== 'server' && (
<span <span class="close-btn" onClick={(e) => { e.stopPropagation(); closeTab(i); }}>×</span>
class="close-btn"
onClick={(e) => {
e.stopPropagation();
closeTab(i);
}}
>
×
</span>
)} )}
</div> </div>
))} ))}
<div class="join-dialog">
<input
placeholder="#channel"
value={joinInput}
onInput={e => setJoinInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)}
/>
<button onClick={() => joinChannel(joinInput)}>Join</button>
</div>
</div> </div>
{currentTab.type === 'channel' && ( {currentTab.type === 'channel' && currentTopic && (
<div class="topic-bar" title={currentTopic}> <div class="topic-bar" title={currentTopic}>{currentTopic}</div>
<span class="topic-label">Topic:</span>{' '}
{currentTopic || '(no topic set)'}
</div>
)} )}
<div class="content"> <div class="content">
<div class="messages-pane"> <div class="messages-pane">
<div <div class={currentTab.type === 'server' ? 'server-messages' : 'messages'}>
class={ {currentMessages.map(m => <Message msg={m} />)}
currentTab.type === 'server'
? 'server-messages'
: 'messages'
}
>
{currentMessages.map((m) => (
<Message msg={m} />
))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
</div> {currentTab.type !== 'server' && (
<div class="input-bar">
{currentTab.type === 'channel' && ( <input
<UserList ref={inputRef}
members={currentMembers} placeholder={`Message ${currentTab.name}...`}
userModes={currentUserModes} value={input}
onDM={openDM} onInput={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
/> />
<button onClick={sendMessage}>Send</button>
</div>
)} )}
</div> </div>
<div class="input-bar"> {currentTab.type === 'channel' && (
<span class="input-nick">{nick}</span> <div class="user-list">
<input <h3>Users ({currentMembers.length})</h3>
ref={inputRef} {currentMembers.map(u => (
placeholder={ <div class="user" onClick={() => openDM(u.nick)} style={{ color: nickColor(u.nick) }}>
currentTab.type === 'server' {u.nick}
? 'Type /join #channel to get started...' </div>
: `Message ${currentTab.name}...` ))}
} </div>
value={input} )}
onInput={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
</div> </div>
</div> </div>
); );

View File

@@ -1,40 +1,28 @@
* { * { margin: 0; padding: 0; box-sizing: border-box; }
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root { :root {
--bg: #0a0e14; --bg: #1a1a2e;
--bg-secondary: #0d1117; --bg-secondary: #16213e;
--bg-input: #161b22; --bg-input: #0f3460;
--bg-highlight: #1a2030; --text: #e0e0e0;
--text: #c9d1d9; --text-muted: #888;
--text-muted: #6e7681; --accent: #e94560;
--accent: #58a6ff; --accent2: #0f3460;
--accent-dim: #1f6feb; --border: #2a2a4a;
--border: #21262d; --nick: #53a8b6;
--nick: #79c0ff; --timestamp: #666;
--timestamp: #484f58; --tab-active: #e94560;
--tab-active: #58a6ff; --tab-bg: #16213e;
--tab-bg: #0d1117; --tab-hover: #1a1a3e;
--tab-hover: #161b22; --topic-bg: #121a30;
--topic-bg: #0d1117; --unread-bg: #e94560;
--unread-bg: #da3633; --warn: #f0ad4e;
--warn: #d29922;
--op-color: #f0883e;
--voice-color: #3fb950;
--action-color: #bc8cff;
--system-color: #484f58;
} }
html, html, body, #root {
body,
#root {
height: 100%; height: 100%;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', font-family: 'Courier New', Courier, monospace;
Courier, monospace; font-size: 14px;
font-size: 13px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
} }
@@ -69,19 +57,15 @@ body,
padding: 10px 24px; padding: 10px 24px;
font-size: 16px; font-size: 16px;
font-family: inherit; font-family: inherit;
background: var(--accent-dim); background: var(--accent);
border: none; border: none;
color: white; color: white;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
} }
.login-screen button:hover {
background: var(--accent);
}
.login-screen .error { .login-screen .error {
color: var(--unread-bg); color: var(--accent);
} }
.login-screen .motd { .login-screen .motd {
@@ -105,63 +89,36 @@ body,
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
overflow-x: auto; overflow-x: auto;
flex-shrink: 0; flex-shrink: 0;
align-items: stretch; align-items: center;
min-height: 32px;
}
.tab-bar::-webkit-scrollbar {
height: 2px;
}
.tab-bar::-webkit-scrollbar-thumb {
background: var(--border);
} }
.tab { .tab {
display: flex; padding: 8px 16px;
align-items: center;
padding: 6px 12px;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
white-space: nowrap; white-space: nowrap;
color: var(--text-muted); color: var(--text-muted);
user-select: none; user-select: none;
font-size: 12px; position: relative;
gap: 6px;
transition:
background 0.1s,
color 0.1s;
} }
.tab:hover { .tab:hover {
background: var(--tab-hover); background: var(--tab-hover);
color: var(--text);
} }
.tab.active { .tab.active {
color: var(--text); color: var(--text);
border-bottom-color: var(--tab-active); border-bottom-color: var(--tab-active);
background: var(--bg-highlight);
}
.tab.server {
font-weight: bold;
}
.tab .tab-name {
overflow: hidden;
text-overflow: ellipsis;
} }
.tab .close-btn { .tab .close-btn {
margin-left: 8px;
color: var(--text-muted); color: var(--text-muted);
font-size: 14px; font-size: 12px;
line-height: 1;
flex-shrink: 0;
} }
.tab .close-btn:hover { .tab .close-btn:hover {
color: var(--unread-bg); color: var(--accent);
} }
.tab .unread-badge { .tab .unread-badge {
@@ -170,22 +127,19 @@ body,
color: white; color: white;
font-size: 10px; font-size: 10px;
font-weight: bold; font-weight: bold;
padding: 0 5px; padding: 1px 5px;
border-radius: 8px; border-radius: 8px;
margin-left: 6px;
min-width: 16px; min-width: 16px;
text-align: center; text-align: center;
line-height: 16px;
flex-shrink: 0;
} }
/* Connection status */ /* Connection status */
.connection-status { .connection-status {
display: flex; padding: 4px 12px;
align-items: center;
padding: 0 12px;
background: var(--warn); background: var(--warn);
color: var(--bg); color: #1a1a2e;
font-size: 11px; font-size: 12px;
font-weight: bold; font-weight: bold;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
@@ -193,7 +147,7 @@ body,
/* Topic bar */ /* Topic bar */
.topic-bar { .topic-bar {
padding: 4px 12px; padding: 6px 12px;
background: var(--topic-bg); background: var(--topic-bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
color: var(--text-muted); color: var(--text-muted);
@@ -204,11 +158,6 @@ body,
flex-shrink: 0; flex-shrink: 0;
} }
.topic-bar .topic-label {
color: var(--accent);
font-weight: bold;
}
/* Content area */ /* Content area */
.content { .content {
display: flex; display: flex;
@@ -222,210 +171,147 @@ body,
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-width: 0;
} }
.messages { .messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px 0; padding: 8px 12px;
}
.messages::-webkit-scrollbar {
width: 6px;
}
.messages::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
} }
.message { .message {
padding: 1px 12px; padding: 2px 0;
line-height: 1.5; line-height: 1.4;
word-wrap: break-word; word-wrap: break-word;
} }
.message:hover {
background: var(--bg-highlight);
}
.message .timestamp { .message .timestamp {
color: var(--timestamp); color: var(--timestamp);
font-size: 11px; font-size: 12px;
margin-right: 6px; margin-right: 8px;
} }
.message .nick { .message .nick {
color: var(--nick);
font-weight: bold; font-weight: bold;
margin-right: 6px; margin-right: 8px;
} }
.message .nick::before { .message .nick::before { content: '<'; }
content: '<'; .message .nick::after { content: '>'; }
color: var(--text-muted);
}
.message .nick::after {
content: '>';
color: var(--text-muted);
}
.message.system { .message.system {
color: var(--system-color); color: var(--text-muted);
font-style: italic; font-style: italic;
} }
.message.system .timestamp { .message.system .nick {
color: var(--timestamp); color: var(--text-muted);
} }
.message.system .content::before { .message.system .nick::before,
content: '*** '; .message.system .nick::after { content: ''; }
}
.message.action { /* Input */
color: var(--action-color);
}
.message.action .timestamp {
color: var(--timestamp);
}
.message.action .action-nick {
font-weight: bold;
}
/* Input bar — full width at bottom */
.input-bar { .input-bar {
display: flex; display: flex;
align-items: center;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
background: var(--bg-secondary); background: var(--bg-secondary);
flex-shrink: 0; flex-shrink: 0;
} }
.input-bar .input-nick {
padding: 0 8px 0 12px;
color: var(--accent);
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
font-size: 13px;
}
.input-bar .input-nick::after {
content: '>';
color: var(--text-muted);
margin-left: 1px;
}
.input-bar input { .input-bar input {
flex: 1; flex: 1;
padding: 8px 8px; padding: 10px 12px;
font-family: inherit; font-family: inherit;
font-size: 13px; font-size: 14px;
background: transparent; background: var(--bg-input);
border: none; border: none;
color: var(--text); color: var(--text);
outline: none; outline: none;
} }
.input-bar input::placeholder { .input-bar button {
color: var(--text-muted); padding: 10px 16px;
font-family: inherit;
background: var(--accent);
border: none;
color: white;
cursor: pointer;
} }
/* User list */ /* User list */
.user-list { .user-list {
width: 170px; width: 160px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
display: flex; overflow-y: auto;
flex-direction: column; padding: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.user-list-header { .user-list h3 {
padding: 6px 10px;
color: var(--text-muted); color: var(--text-muted);
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; margin-bottom: 8px;
border-bottom: 1px solid var(--border); letter-spacing: 1px;
font-weight: bold;
flex-shrink: 0;
}
.user-list-entries {
overflow-y: auto;
flex: 1;
padding: 4px 0;
}
.user-list-entries::-webkit-scrollbar {
width: 4px;
}
.user-list-entries::-webkit-scrollbar-thumb {
background: var(--border);
} }
.user-list .user { .user-list .user {
padding: 2px 10px; padding: 3px 4px;
font-size: 12px; color: var(--nick);
font-size: 13px;
cursor: pointer; cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
} }
.user-list .user:hover { .user-list .user:hover {
background: var(--tab-hover); background: var(--tab-hover);
} }
.user-list .user.op { /* Server tab */
color: var(--op-color);
}
.user-list .user.voice {
color: var(--voice-color);
}
/* Server tab messages */
.server-messages { .server-messages {
color: var(--text-muted); color: var(--text-muted);
padding: 8px 12px; padding: 12px;
white-space: pre-wrap; white-space: pre-wrap;
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
} }
.server-messages .message { /* Channel join dialog */
padding: 1px 0; .join-dialog {
padding: 12px;
display: flex;
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
margin-left: auto;
} }
.server-messages .message:hover { .join-dialog input {
background: var(--bg-highlight); 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 */ /* Responsive */
@media (max-width: 600px) { @media (max-width: 600px) {
.user-list { .user-list { display: none; }
display: none; .tab { padding: 6px 10px; font-size: 13px; }
}
.tab {
padding: 5px 8px;
font-size: 11px;
}
.input-bar .input-nick {
padding-left: 8px;
font-size: 12px;
}
.input-bar input {
font-size: 12px;
}
} }