Compare commits
3 Commits
f9c145ad09
...
fix/irc-nu
| Author | SHA1 | Date | |
|---|---|---|---|
| 38d222f6a7 | |||
| 8e3166969e | |||
|
|
8d91ad852c |
55
README.md
55
README.md
@@ -845,11 +845,10 @@ 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 now implemented. All IRC command responses
|
**Note:** Numeric replies are planned for full implementation. The current MVP
|
||||||
(success and error) are delivered as numeric replies through the message queue.
|
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead
|
||||||
HTTP error codes are reserved for transport-level issues (auth failures,
|
of numeric replies for error conditions. Numeric replies in the message queue
|
||||||
malformed requests, server errors). The `params` field in the message envelope
|
will be added post-MVP.
|
||||||
carries IRC-style parameters (e.g., channel name, target nick).
|
|
||||||
|
|
||||||
### Channel Modes
|
### Channel Modes
|
||||||
|
|
||||||
@@ -1055,8 +1054,8 @@ reference with all required and optional fields.
|
|||||||
|
|
||||||
| Command | Required Fields | Optional | Response Status |
|
| Command | Required Fields | Optional | Response Status |
|
||||||
|-----------|---------------------|---------------|-----------------|
|
|-----------|---------------------|---------------|-----------------|
|
||||||
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
|
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created |
|
||||||
| `NOTICE` | `to`, `body` | `meta` | 200 OK |
|
| `NOTICE` | `to`, `body` | `meta` | 201 Created |
|
||||||
| `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 |
|
||||||
@@ -1064,44 +1063,18 @@ reference with all required and optional fields.
|
|||||||
| `QUIT` | | `body` | 200 OK |
|
| `QUIT` | | `body` | 200 OK |
|
||||||
| `PING` | | | 200 OK |
|
| `PING` | | | 200 OK |
|
||||||
|
|
||||||
All IRC commands return HTTP 200 OK. IRC-level success and error responses
|
**Errors (all commands):**
|
||||||
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 or empty command |
|
| 400 | `invalid request` | Malformed JSON |
|
||||||
|
| 400 | `to field required` | Missing `to` for commands that need it |
|
||||||
|
| 400 | `body required` | Missing `body` for commands that need it |
|
||||||
|
| 400 | `unknown command: X` | Unrecognized command |
|
||||||
| 401 | `unauthorized` | Missing or invalid auth token |
|
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||||
| 500 | `internal error` | Server-side failure |
|
| 404 | `channel not found` | Target channel doesn't exist |
|
||||||
|
| 404 | `user not found` | DM target nick doesn't exist |
|
||||||
**IRC numeric error replies (delivered via message queue):**
|
| 409 | `nick already in use` | NICK target is taken |
|
||||||
|
|
||||||
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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"`
|
||||||
@@ -492,17 +491,12 @@ 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("[]")
|
||||||
}
|
}
|
||||||
@@ -514,10 +508,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,
|
||||||
params, body, meta, created_at)
|
body, meta, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
msgUUID, command, from, target,
|
msgUUID, command, from, target,
|
||||||
string(params), string(body), string(meta), now)
|
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,
|
||||||
@@ -584,7 +578,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.params, m.body, m.meta, m.created_at
|
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
|
||||||
@@ -648,7 +642,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, params, body, meta, created_at
|
msg_to, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE msg_to = ? AND id < ?
|
WHERE msg_to = ? AND id < ?
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
@@ -665,7 +659,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, params, body, meta, created_at
|
msg_to, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE msg_to = ?
|
WHERE msg_to = ?
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
@@ -692,14 +686,14 @@ func scanMessages(
|
|||||||
var (
|
var (
|
||||||
msg IRCMessage
|
msg IRCMessage
|
||||||
qID int64
|
qID int64
|
||||||
params, body, meta string
|
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,
|
||||||
¶ms, &body, &meta, &createdAt,
|
&body, &meta, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fallbackQID, fmt.Errorf(
|
return nil, fallbackQID, fmt.Errorf(
|
||||||
@@ -707,10 +701,6 @@ func scanMessages(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params != "" && params != "[]" {
|
|
||||||
msg.Params = json.RawMessage(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.Body = json.RawMessage(body)
|
msg.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)
|
||||||
|
|||||||
@@ -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", nil, body, nil,
|
ctx, "PRIVMSG", "poller", "#test", 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", nil, body, nil,
|
ctx, "PRIVMSG", "poller", "#test", 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",
|
||||||
nil, json.RawMessage(`["msg"]`), 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", nil, body, nil,
|
ctx, "PRIVMSG", "sender", "#ch", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ 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
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ const (
|
|||||||
defaultHistLimit = 50
|
defaultHistLimit = 50
|
||||||
maxHistLimit = 500
|
maxHistLimit = 500
|
||||||
cmdPrivmsg = "PRIVMSG"
|
cmdPrivmsg = "PRIVMSG"
|
||||||
|
|
||||||
|
// IRC numeric reply codes per RFC 1459/2812.
|
||||||
|
ircErrNoSuchNick = "401"
|
||||||
|
ircErrNoSuchChannel = "403"
|
||||||
|
ircErrNoRecipient = "411"
|
||||||
|
ircErrNoTextToSend = "412"
|
||||||
|
ircErrUnknownCommand = "421"
|
||||||
|
ircErrNoNicknameGiven = "431"
|
||||||
|
ircErrErroneusNickname = "432"
|
||||||
|
ircErrNicknameInUse = "433"
|
||||||
|
ircErrNotOnChannel = "442"
|
||||||
|
ircErrNeedMoreParams = "461"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (hdlr *Handlers) maxBodySize() int64 {
|
func (hdlr *Handlers) maxBodySize() int64 {
|
||||||
@@ -63,6 +75,43 @@ func (hdlr *Handlers) authSession(
|
|||||||
return sessionID, clientID, nick, nil
|
return sessionID, clientID, nick, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serverName returns the configured server name or the
|
||||||
|
// default "neoirc".
|
||||||
|
func (hdlr *Handlers) serverName() string {
|
||||||
|
if hdlr.params.Config.ServerName != "" {
|
||||||
|
return hdlr.params.Config.ServerName
|
||||||
|
}
|
||||||
|
|
||||||
|
return "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondIRCError sends an IRC numeric error reply as a
|
||||||
|
// JSON response with HTTP 200 OK. This is used for errors
|
||||||
|
// in IRC command processing (as opposed to HTTP transport
|
||||||
|
// errors). The params slice contains IRC-style positional
|
||||||
|
// parameters (e.g. the target nick or channel).
|
||||||
|
func (hdlr *Handlers) respondIRCError(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
nick, numeric, msg string,
|
||||||
|
params ...string,
|
||||||
|
) {
|
||||||
|
resp := map[string]any{
|
||||||
|
"command": numeric,
|
||||||
|
"from": hdlr.serverName(),
|
||||||
|
"to": nick,
|
||||||
|
"body": []string{msg},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(params) > 0 {
|
||||||
|
resp["params"] = params
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.respondJSON(
|
||||||
|
writer, request, resp, http.StatusOK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) requireAuth(
|
func (hdlr *Handlers) requireAuth(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -91,7 +140,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, nil, body, nil,
|
request.Context(), command, from, target, body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("insert message: %w", err)
|
return "", fmt.Errorf("insert message: %w", err)
|
||||||
@@ -185,7 +234,7 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
hdlr.deliverMOTD(request, clientID, sessionID)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
@@ -219,76 +268,45 @@ 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
|
||||||
srvName := hdlr.serverName()
|
serverName := hdlr.serverName()
|
||||||
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
hdlr.deliverWelcome(request, clientID, nick)
|
|
||||||
|
|
||||||
if motd == "" {
|
if motd == "" {
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "375", nick, nil,
|
ctx, clientID, "375", serverName,
|
||||||
"- "+srvName+" Message of the Day -",
|
"- "+serverName+" 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", nick, nil,
|
ctx, clientID, "372", serverName,
|
||||||
"- "+line,
|
"- "+line,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "376", nick, nil,
|
ctx, clientID, "376", serverName,
|
||||||
"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, nick string,
|
command, serverName, text string,
|
||||||
params []string,
|
|
||||||
text string,
|
|
||||||
) {
|
) {
|
||||||
body, err := json.Marshal([]string{text})
|
body, err := json.Marshal([]string{text})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -299,22 +317,9 @@ 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, hdlr.serverName(), nick,
|
ctx, command, serverName, "",
|
||||||
paramsJSON, json.RawMessage(body), nil,
|
json.RawMessage(body), nil,
|
||||||
)
|
)
|
||||||
if insertErr != nil {
|
if insertErr != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
@@ -572,7 +577,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
|||||||
writer, request.Body, hdlr.maxBodySize(),
|
writer, request.Body, hdlr.maxBodySize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
sessionID, clientID, nick, ok :=
|
sessionID, _, nick, ok :=
|
||||||
hdlr.requireAuth(writer, request)
|
hdlr.requireAuth(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -597,10 +602,10 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
|||||||
payload.To = strings.TrimSpace(payload.To)
|
payload.To = strings.TrimSpace(payload.To)
|
||||||
|
|
||||||
if payload.Command == "" {
|
if payload.Command == "" {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"command required",
|
nick, ircErrUnknownCommand,
|
||||||
http.StatusBadRequest,
|
"No command given",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -622,8 +627,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.dispatchCommand(
|
hdlr.dispatchCommand(
|
||||||
writer, request,
|
writer, request, sessionID, nick,
|
||||||
sessionID, clientID, nick,
|
|
||||||
payload.Command, payload.To,
|
payload.Command, payload.To,
|
||||||
payload.Body, bodyLines,
|
payload.Body, bodyLines,
|
||||||
)
|
)
|
||||||
@@ -633,7 +637,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, clientID int64,
|
sessionID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
@@ -641,30 +645,24 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
switch command {
|
switch command {
|
||||||
case cmdPrivmsg, "NOTICE":
|
case cmdPrivmsg, "NOTICE":
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request, sessionID, nick,
|
||||||
sessionID, clientID, nick,
|
|
||||||
command, target, body, bodyLines,
|
command, target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
hdlr.handleJoin(
|
hdlr.handleJoin(
|
||||||
writer, request,
|
writer, request, sessionID, nick, target,
|
||||||
sessionID, clientID, nick, target,
|
|
||||||
)
|
)
|
||||||
case "PART":
|
case "PART":
|
||||||
hdlr.handlePart(
|
hdlr.handlePart(
|
||||||
writer, request,
|
writer, request, sessionID, nick, target, body,
|
||||||
sessionID, clientID, nick, target, body,
|
|
||||||
)
|
)
|
||||||
case "NICK":
|
case "NICK":
|
||||||
hdlr.handleNick(
|
hdlr.handleNick(
|
||||||
writer, request,
|
writer, request, sessionID, nick, bodyLines,
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
)
|
||||||
case "TOPIC":
|
case "TOPIC":
|
||||||
hdlr.handleTopic(
|
hdlr.handleTopic(
|
||||||
writer, request,
|
writer, request, nick, target, body, bodyLines,
|
||||||
sessionID, clientID, nick,
|
|
||||||
target, body, bodyLines,
|
|
||||||
)
|
)
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
@@ -674,63 +672,51 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"command": "PONG",
|
"command": "PONG",
|
||||||
"from": hdlr.serverName(),
|
"from": hdlr.params.Config.ServerName,
|
||||||
},
|
},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
default:
|
default:
|
||||||
hdlr.enqueueNumeric(
|
hdlr.respondIRCError(
|
||||||
request.Context(), clientID,
|
writer, request,
|
||||||
"421", nick, []string{command},
|
nick, ircErrUnknownCommand,
|
||||||
"Unknown command",
|
command+" :Unknown command",
|
||||||
|
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, clientID int64,
|
sessionID 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.enqueueNumeric(
|
hdlr.respondIRCError(
|
||||||
request.Context(), clientID,
|
writer, request,
|
||||||
"461", nick, []string{command},
|
nick, ircErrNoRecipient,
|
||||||
"Not enough parameters",
|
"No recipient given ("+command+")",
|
||||||
)
|
)
|
||||||
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.enqueueNumeric(
|
hdlr.respondIRCError(
|
||||||
request.Context(), clientID,
|
writer, request,
|
||||||
"461", nick, []string{command},
|
nick, ircErrNoTextToSend,
|
||||||
"Not enough parameters",
|
"No text to send",
|
||||||
)
|
)
|
||||||
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,
|
writer, request, sessionID, nick,
|
||||||
sessionID, clientID, nick,
|
|
||||||
command, target, body,
|
command, target, body,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -738,36 +724,15 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.handleDirectMsg(
|
hdlr.handleDirectMsg(
|
||||||
writer, request,
|
writer, request, sessionID, nick,
|
||||||
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, clientID int64,
|
sessionID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
) {
|
) {
|
||||||
@@ -776,9 +741,10 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"403", nick, []string{target},
|
nick, ircErrNoSuchChannel,
|
||||||
"No such channel",
|
"No such channel",
|
||||||
|
target,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -802,26 +768,15 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
|
|
||||||
if !isMember {
|
if !isMember {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"442", nick, []string{target},
|
nick, ircErrNotOnChannel,
|
||||||
"You're not on that channel",
|
"You're not on that channel",
|
||||||
|
target,
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -860,7 +815,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
|||||||
func (hdlr *Handlers) handleDirectMsg(
|
func (hdlr *Handlers) handleDirectMsg(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID, clientID int64,
|
sessionID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
) {
|
) {
|
||||||
@@ -868,15 +823,12 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
request.Context(), target,
|
request.Context(), target,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.respondIRCError(
|
||||||
request.Context(), clientID,
|
writer, request,
|
||||||
"401", nick, []string{target},
|
nick, ircErrNoSuchNick,
|
||||||
"No such nick/channel",
|
"No such nick/channel",
|
||||||
|
target,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "error"},
|
|
||||||
http.StatusOK)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -908,14 +860,15 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
func (hdlr *Handlers) handleJoin(
|
func (hdlr *Handlers) handleJoin(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID, clientID int64,
|
sessionID int64,
|
||||||
nick, target string,
|
nick, target string,
|
||||||
) {
|
) {
|
||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"461", nick, []string{"JOIN"},
|
nick, ircErrNeedMoreParams,
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
|
"JOIN",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -928,26 +881,15 @@ func (hdlr *Handlers) handleJoin(
|
|||||||
|
|
||||||
if !validChannelRe.MatchString(channel) {
|
if !validChannelRe.MatchString(channel) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"403", nick, []string{channel},
|
nick, ircErrNoSuchChannel,
|
||||||
"No such channel",
|
"No such channel",
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
@@ -988,10 +930,6 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
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",
|
||||||
@@ -1000,96 +938,20 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
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, clientID int64,
|
sessionID int64,
|
||||||
nick, target string,
|
nick, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
) {
|
) {
|
||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.respondIRCError(
|
||||||
request.Context(), clientID,
|
writer, request,
|
||||||
"461", nick, []string{"PART"},
|
nick, ircErrNeedMoreParams,
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
|
"PART",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "error"},
|
|
||||||
http.StatusOK)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1103,15 +965,12 @@ func (hdlr *Handlers) handlePart(
|
|||||||
request.Context(), channel,
|
request.Context(), channel,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.respondIRCError(
|
||||||
request.Context(), clientID,
|
writer, request,
|
||||||
"403", nick, []string{channel},
|
nick, ircErrNoSuchChannel,
|
||||||
"No such channel",
|
"No such channel",
|
||||||
|
channel,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "error"},
|
|
||||||
http.StatusOK)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1155,16 +1014,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, clientID int64,
|
sessionID int64,
|
||||||
nick string,
|
nick string,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
lines := bodyLines()
|
lines := bodyLines()
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"461", nick, []string{"NICK"},
|
nick, ircErrNoNicknameGiven,
|
||||||
"Not enough parameters",
|
"No nickname given",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1174,9 +1033,10 @@ func (hdlr *Handlers) handleNick(
|
|||||||
|
|
||||||
if !validNickRe.MatchString(newNick) {
|
if !validNickRe.MatchString(newNick) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"432", nick, []string{newNick},
|
nick, ircErrErroneusNickname,
|
||||||
"Erroneous nickname",
|
"Erroneous nickname",
|
||||||
|
newNick,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1192,27 +1052,16 @@ 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.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"433", nick, []string{newNick},
|
nick, ircErrNicknameInUse,
|
||||||
"Nickname is already in use",
|
"Nickname is already in use",
|
||||||
|
newNick,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1262,7 +1111,7 @@ func (hdlr *Handlers) broadcastNick(
|
|||||||
|
|
||||||
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), "NICK", oldNick, "",
|
request.Context(), "NICK", oldNick, "",
|
||||||
nil, json.RawMessage(nickBody), nil,
|
json.RawMessage(nickBody), nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.params.Database.EnqueueToSession(
|
_ = hdlr.params.Database.EnqueueToSession(
|
||||||
@@ -1294,16 +1143,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.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"461", nick, []string{"TOPIC"},
|
nick, ircErrNeedMoreParams,
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
|
"TOPIC",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1312,59 +1161,47 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
lines := bodyLines()
|
lines := bodyLines()
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"461", nick, []string{"TOPIC"},
|
nick, ircErrNeedMoreParams,
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
|
"TOPIC",
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
||||||
|
request.Context(), channel, topic,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"set topic failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
chID, err := hdlr.params.Database.GetChannelByName(
|
chID, err := hdlr.params.Database.GetChannelByName(
|
||||||
request.Context(), channel,
|
request.Context(), channel,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request,
|
||||||
"403", nick, []string{channel},
|
nick, ircErrNoSuchChannel,
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
channel,
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.executeTopic(
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1378,12 +1215,6 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
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,
|
||||||
@@ -1409,8 +1240,7 @@ 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, "",
|
request.Context(), "QUIT", nick, "", body, nil,
|
||||||
nil, body, nil,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1659,8 +1489,7 @@ 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, "",
|
ctx, "QUIT", nick, "", nil, nil,
|
||||||
nil, nil, nil,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -462,19 +462,6 @@ 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) {
|
||||||
@@ -486,47 +473,6 @@ func TestCreateSessionValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWelcomeNumeric(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
token := tserver.createSession("welcomer")
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "001") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_WELCOME (001), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJoinNumerics(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
token := tserver.createSession("jnumtest")
|
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#numtest",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "353") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_NAMREPLY (353), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "366") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ENDOFNAMES (366), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateSessionDuplicate(t *testing.T) {
|
func TestCreateSessionDuplicate(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
tserver.createSession("alice")
|
tserver.createSession("alice")
|
||||||
@@ -722,22 +668,17 @@ func TestJoinMissingTo(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("joiner3")
|
token := tserver.createSession("joiner3")
|
||||||
|
|
||||||
// Drain initial MOTD/welcome numerics.
|
status, result := tserver.sendCommand(
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
|
||||||
token, map[string]any{commandKey: joinCmd},
|
token, map[string]any{commandKey: joinCmd},
|
||||||
)
|
)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "461" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 461, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -794,21 +735,17 @@ func TestMessageMissingBody(t *testing.T) {
|
|||||||
commandKey: joinCmd, toKey: "#test",
|
commandKey: joinCmd, toKey: "#test",
|
||||||
})
|
})
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: privmsgCmd, toKey: "#test",
|
commandKey: privmsgCmd, toKey: "#test",
|
||||||
})
|
})
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "412" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 412, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -817,9 +754,7 @@ 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, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: privmsgCmd,
|
commandKey: privmsgCmd,
|
||||||
bodyKey: []string{"hello"},
|
bodyKey: []string{"hello"},
|
||||||
})
|
})
|
||||||
@@ -827,12 +762,10 @@ func TestMessageMissingTo(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "411" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 411, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -847,10 +780,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, result := tserver.sendCommand(
|
||||||
aliceToken,
|
aliceToken,
|
||||||
map[string]any{
|
map[string]any{
|
||||||
commandKey: privmsgCmd,
|
commandKey: privmsgCmd,
|
||||||
@@ -862,12 +793,10 @@ func TestNonMemberCannotSend(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
if result[commandKey] != "442" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "442") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
"expected IRC 442, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -917,9 +846,7 @@ 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, result := 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?"},
|
||||||
@@ -928,12 +855,10 @@ func TestDMToNonexistentUser(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "401" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "401") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NOSUCHNICK (401), got %v",
|
"expected IRC 401, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -981,9 +906,7 @@ func TestNickCollision(t *testing.T) {
|
|||||||
|
|
||||||
tserver.createSession("taken_nick")
|
tserver.createSession("taken_nick")
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := 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"},
|
||||||
})
|
})
|
||||||
@@ -991,12 +914,10 @@ func TestNickCollision(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "433" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "433") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NICKNAMEINUSE (433), got %v",
|
"expected IRC 433, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1005,9 +926,7 @@ 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, result := 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!"},
|
||||||
})
|
})
|
||||||
@@ -1015,12 +934,10 @@ func TestNickInvalid(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "432" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "432") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_ERRONEUSNICKNAME (432), got %v",
|
"expected IRC 432, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1029,21 +946,17 @@ 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, result := tserver.sendCommand(
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
|
||||||
token, map[string]any{commandKey: "NICK"},
|
token, map[string]any{commandKey: "NICK"},
|
||||||
)
|
)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "431" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 431, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1081,9 +994,7 @@ 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, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "TOPIC",
|
commandKey: "TOPIC",
|
||||||
bodyKey: []string{"topic"},
|
bodyKey: []string{"topic"},
|
||||||
})
|
})
|
||||||
@@ -1091,12 +1002,10 @@ func TestTopicMissingTo(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "461" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 461, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1109,21 +1018,17 @@ func TestTopicMissingBody(t *testing.T) {
|
|||||||
commandKey: joinCmd, toKey: "#topictest",
|
commandKey: joinCmd, toKey: "#topictest",
|
||||||
})
|
})
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "TOPIC", toKey: "#topictest",
|
commandKey: "TOPIC", toKey: "#topictest",
|
||||||
})
|
})
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "461" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 461, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1192,21 +1097,17 @@ 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, result := tserver.sendCommand(
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
|
||||||
token, map[string]any{commandKey: "BOGUS"},
|
token, map[string]any{commandKey: "BOGUS"},
|
||||||
)
|
)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "421" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "421") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_UNKNOWNCOMMAND (421), got %v",
|
"expected IRC 421, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1215,11 +1116,18 @@ func TestEmptyCommand(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("emptycmd")
|
token := tserver.createSession("emptycmd")
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
status, result := tserver.sendCommand(
|
||||||
token, map[string]any{commandKey: ""},
|
token, map[string]any{commandKey: ""},
|
||||||
)
|
)
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[commandKey] != "421" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected IRC 421, got %v",
|
||||||
|
result[commandKey],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,18 +1362,12 @@ 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(fmt.Sprintf(
|
tserver.url(apiMessages+"?timeout=1"),
|
||||||
"%s?timeout=1&after=%d",
|
|
||||||
apiMessages, lastID,
|
|
||||||
)),
|
|
||||||
token,
|
token,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
hdlr.deliverMOTD(request, clientID, sessionID)
|
||||||
|
|
||||||
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, clientID, token, err :=
|
sessionID, _, token, err :=
|
||||||
hdlr.params.Database.LoginUser(
|
hdlr.params.Database.LoginUser(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
payload.Nick,
|
payload.Nick,
|
||||||
@@ -178,10 +178,6 @@ 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
4
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
593
web/dist/style.css
vendored
593
web/dist/style.css
vendored
@@ -1,317 +1,466 @@
|
|||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #1a1a2e;
|
--bg: #0a0e14;
|
||||||
--bg-secondary: #16213e;
|
--bg-panel: #0d1117;
|
||||||
--bg-input: #0f3460;
|
--bg-input: #0d1117;
|
||||||
--text: #e0e0e0;
|
--bg-tab: #161b22;
|
||||||
--text-muted: #888;
|
--bg-tab-active: #0d1117;
|
||||||
--accent: #e94560;
|
--bg-topic: #0d1117;
|
||||||
--accent2: #0f3460;
|
--text: #c9d1d9;
|
||||||
--border: #2a2a4a;
|
--text-dim: #6e7681;
|
||||||
--nick: #53a8b6;
|
--text-bright: #e6edf3;
|
||||||
--timestamp: #666;
|
--accent: #58a6ff;
|
||||||
--tab-active: #e94560;
|
--accent-dim: #1f6feb;
|
||||||
--tab-bg: #16213e;
|
--border: #21262d;
|
||||||
--tab-hover: #1a1a3e;
|
--system: #7d8590;
|
||||||
--topic-bg: #121a30;
|
--action: #d2a8ff;
|
||||||
--unread-bg: #e94560;
|
--warn: #d29922;
|
||||||
--warn: #f0ad4e;
|
--error: #f85149;
|
||||||
|
--unread: #f0883e;
|
||||||
|
--nick-brackets: #6e7681;
|
||||||
|
--timestamp: #484f58;
|
||||||
|
--input-bg: #161b22;
|
||||||
|
--prompt: #3fb950;
|
||||||
|
--tab-indicator: #58a6ff;
|
||||||
|
--user-list-bg: #0d1117;
|
||||||
|
--user-list-header: #484f58;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
||||||
font-size: 14px;
|
"Consolas", "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Login screen */
|
/* ============================================
|
||||||
|
Login Screen
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.login-screen {
|
.login-screen {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: 16px;
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-screen h1 {
|
.login-box {
|
||||||
color: var(--accent);
|
|
||||||
font-size: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-screen input {
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--bg-input);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-screen button {
|
|
||||||
padding: 10px 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-screen .error {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-screen .motd {
|
|
||||||
color: var(--text-muted);
|
|
||||||
max-width: 400px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: pre-wrap;
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main layout */
|
.login-box h1 {
|
||||||
.app {
|
color: var(--accent);
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box .motd {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: inherit;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input:focus {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box .error {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
IRC App Layout
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.irc-app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab bar */
|
/* ============================================
|
||||||
|
Tab Bar
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-tab);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
overflow-x: auto;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-items: center;
|
height: 32px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex: 1;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 8px 16px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
color: var(--text-dim);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--text-muted);
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 4px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
background: var(--tab-hover);
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: var(--text);
|
color: var(--text-bright);
|
||||||
border-bottom-color: var(--tab-active);
|
background: var(--bg-tab-active);
|
||||||
|
border-bottom: 2px solid var(--tab-indicator);
|
||||||
|
margin-bottom: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab .close-btn {
|
.tab.has-unread .tab-label {
|
||||||
margin-left: 8px;
|
color: var(--unread);
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab .close-btn:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab .unread-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--unread-bg);
|
|
||||||
color: white;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-left: 6px;
|
|
||||||
min-width: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Connection status */
|
.tab .unread-count {
|
||||||
.connection-status {
|
color: var(--unread);
|
||||||
padding: 4px 12px;
|
font-size: 11px;
|
||||||
background: var(--warn);
|
|
||||||
color: #1a1a2e;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
white-space: nowrap;
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Topic bar */
|
.status-nick {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warn {
|
||||||
|
color: var(--warn);
|
||||||
|
animation: blink 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Topic Bar
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.topic-bar {
|
.topic-bar {
|
||||||
padding: 6px 12px;
|
padding: 4px 12px;
|
||||||
background: var(--topic-bg);
|
background: var(--bg-topic);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content area */
|
.topic-label {
|
||||||
.content {
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-text {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Main Content Area
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.main-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Messages */
|
/* ============================================
|
||||||
.messages-pane {
|
Messages Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.messages-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages {
|
.messages-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 12px;
|
padding: 4px 8px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.messages-scroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Message Lines
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 2px 0;
|
padding: 1px 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .timestamp {
|
.message .timestamp {
|
||||||
color: var(--timestamp);
|
color: var(--timestamp);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-right: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .nick {
|
.message .nick {
|
||||||
color: var(--nick);
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-right: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .nick::before { content: '<'; }
|
.message .content {
|
||||||
.message .nick::after { content: '>'; }
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.message.system {
|
/* System messages (joins, parts, quits, etc.) */
|
||||||
color: var(--text-muted);
|
.system-message {
|
||||||
|
color: var(--system);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message .system-text {
|
||||||
|
color: var(--system);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* /me action messages */
|
||||||
|
.action-message .action-text {
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
User List (Right Panel)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
width: 160px;
|
||||||
|
background: var(--user-list-bg);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-header {
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--user-list-header);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-entries {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
flex: 1;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-entry {
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-entry:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-prefix {
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: inline-block;
|
||||||
|
width: 1ch;
|
||||||
|
text-align: right;
|
||||||
|
margin-right: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-name {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Input Line (Bottom)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.input-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prompt {
|
||||||
|
color: var(--prompt);
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-line input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-bright);
|
||||||
|
outline: none;
|
||||||
|
caret-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-line input::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.system .nick {
|
/* ============================================
|
||||||
color: var(--text-muted);
|
Responsive
|
||||||
}
|
============================================ */
|
||||||
|
|
||||||
.message.system .nick::before,
|
|
||||||
.message.system .nick::after { content: ''; }
|
|
||||||
|
|
||||||
/* Input */
|
|
||||||
.input-bar {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-bar input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--bg-input);
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-bar button {
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User list */
|
|
||||||
.user-list {
|
|
||||||
width: 160px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list h3 {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list .user {
|
|
||||||
padding: 3px 4px;
|
|
||||||
color: var(--nick);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list .user:hover {
|
|
||||||
background: var(--tab-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Server tab */
|
|
||||||
.server-messages {
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 12px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Channel join dialog */
|
|
||||||
.join-dialog {
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-dialog input {
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg-input);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 3px;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-dialog button {
|
|
||||||
padding: 6px 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--accent2);
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.user-list { display: none; }
|
.user-list {
|
||||||
.tab { padding: 6px 10px; font-size: 13px; }
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prompt {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
927
web/src/app.jsx
927
web/src/app.jsx
File diff suppressed because it is too large
Load Diff
@@ -1,317 +1,466 @@
|
|||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #1a1a2e;
|
--bg: #0a0e14;
|
||||||
--bg-secondary: #16213e;
|
--bg-panel: #0d1117;
|
||||||
--bg-input: #0f3460;
|
--bg-input: #0d1117;
|
||||||
--text: #e0e0e0;
|
--bg-tab: #161b22;
|
||||||
--text-muted: #888;
|
--bg-tab-active: #0d1117;
|
||||||
--accent: #e94560;
|
--bg-topic: #0d1117;
|
||||||
--accent2: #0f3460;
|
--text: #c9d1d9;
|
||||||
--border: #2a2a4a;
|
--text-dim: #6e7681;
|
||||||
--nick: #53a8b6;
|
--text-bright: #e6edf3;
|
||||||
--timestamp: #666;
|
--accent: #58a6ff;
|
||||||
--tab-active: #e94560;
|
--accent-dim: #1f6feb;
|
||||||
--tab-bg: #16213e;
|
--border: #21262d;
|
||||||
--tab-hover: #1a1a3e;
|
--system: #7d8590;
|
||||||
--topic-bg: #121a30;
|
--action: #d2a8ff;
|
||||||
--unread-bg: #e94560;
|
--warn: #d29922;
|
||||||
--warn: #f0ad4e;
|
--error: #f85149;
|
||||||
|
--unread: #f0883e;
|
||||||
|
--nick-brackets: #6e7681;
|
||||||
|
--timestamp: #484f58;
|
||||||
|
--input-bg: #161b22;
|
||||||
|
--prompt: #3fb950;
|
||||||
|
--tab-indicator: #58a6ff;
|
||||||
|
--user-list-bg: #0d1117;
|
||||||
|
--user-list-header: #484f58;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
||||||
font-size: 14px;
|
"Consolas", "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Login screen */
|
/* ============================================
|
||||||
|
Login Screen
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.login-screen {
|
.login-screen {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: 16px;
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-screen h1 {
|
.login-box {
|
||||||
color: var(--accent);
|
|
||||||
font-size: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-screen input {
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--bg-input);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-screen button {
|
|
||||||
padding: 10px 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-screen .error {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-screen .motd {
|
|
||||||
color: var(--text-muted);
|
|
||||||
max-width: 400px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: pre-wrap;
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main layout */
|
.login-box h1 {
|
||||||
.app {
|
color: var(--accent);
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box .motd {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: inherit;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input:focus {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box .error {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
IRC App Layout
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.irc-app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab bar */
|
/* ============================================
|
||||||
|
Tab Bar
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-tab);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
overflow-x: auto;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-items: center;
|
height: 32px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex: 1;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 8px 16px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
color: var(--text-dim);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--text-muted);
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 4px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
background: var(--tab-hover);
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: var(--text);
|
color: var(--text-bright);
|
||||||
border-bottom-color: var(--tab-active);
|
background: var(--bg-tab-active);
|
||||||
|
border-bottom: 2px solid var(--tab-indicator);
|
||||||
|
margin-bottom: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab .close-btn {
|
.tab.has-unread .tab-label {
|
||||||
margin-left: 8px;
|
color: var(--unread);
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab .close-btn:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab .unread-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--unread-bg);
|
|
||||||
color: white;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-left: 6px;
|
|
||||||
min-width: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Connection status */
|
.tab .unread-count {
|
||||||
.connection-status {
|
color: var(--unread);
|
||||||
padding: 4px 12px;
|
font-size: 11px;
|
||||||
background: var(--warn);
|
|
||||||
color: #1a1a2e;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
white-space: nowrap;
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Topic bar */
|
.status-nick {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warn {
|
||||||
|
color: var(--warn);
|
||||||
|
animation: blink 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Topic Bar
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.topic-bar {
|
.topic-bar {
|
||||||
padding: 6px 12px;
|
padding: 4px 12px;
|
||||||
background: var(--topic-bg);
|
background: var(--bg-topic);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content area */
|
.topic-label {
|
||||||
.content {
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-text {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Main Content Area
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.main-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Messages */
|
/* ============================================
|
||||||
.messages-pane {
|
Messages Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.messages-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages {
|
.messages-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 12px;
|
padding: 4px 8px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.messages-scroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Message Lines
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 2px 0;
|
padding: 1px 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .timestamp {
|
.message .timestamp {
|
||||||
color: var(--timestamp);
|
color: var(--timestamp);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-right: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .nick {
|
.message .nick {
|
||||||
color: var(--nick);
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-right: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .nick::before { content: '<'; }
|
.message .content {
|
||||||
.message .nick::after { content: '>'; }
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.message.system {
|
/* System messages (joins, parts, quits, etc.) */
|
||||||
color: var(--text-muted);
|
.system-message {
|
||||||
|
color: var(--system);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message .system-text {
|
||||||
|
color: var(--system);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* /me action messages */
|
||||||
|
.action-message .action-text {
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
User List (Right Panel)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
width: 160px;
|
||||||
|
background: var(--user-list-bg);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-header {
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--user-list-header);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-entries {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
flex: 1;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-entry {
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-entry:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-prefix {
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: inline-block;
|
||||||
|
width: 1ch;
|
||||||
|
text-align: right;
|
||||||
|
margin-right: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-name {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Input Line (Bottom)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.input-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prompt {
|
||||||
|
color: var(--prompt);
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-line input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-bright);
|
||||||
|
outline: none;
|
||||||
|
caret-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-line input::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.system .nick {
|
/* ============================================
|
||||||
color: var(--text-muted);
|
Responsive
|
||||||
}
|
============================================ */
|
||||||
|
|
||||||
.message.system .nick::before,
|
|
||||||
.message.system .nick::after { content: ''; }
|
|
||||||
|
|
||||||
/* Input */
|
|
||||||
.input-bar {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-bar input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--bg-input);
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-bar button {
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User list */
|
|
||||||
.user-list {
|
|
||||||
width: 160px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list h3 {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list .user {
|
|
||||||
padding: 3px 4px;
|
|
||||||
color: var(--nick);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list .user:hover {
|
|
||||||
background: var(--tab-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Server tab */
|
|
||||||
.server-messages {
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 12px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Channel join dialog */
|
|
||||||
.join-dialog {
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-dialog input {
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg-input);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 3px;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-dialog button {
|
|
||||||
padding: 6px 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--accent2);
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.user-list { display: none; }
|
.user-list {
|
||||||
.tab { padding: 6px 10px; font-size: 13px; }
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prompt {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user