1 Commits
Author SHA1 Message Date
sneak 97e71742ac Show a joined channel's web tab whichever reply comes first (closes #193)
check / check (push) Canceled after 0s
After a join request returned, joinChannel made tab number
tabsRef.current.length active. When the user's own JOIN event had
already added the tab, or the tab was already open, that index was one
past the last tab. The view then fell back to the Server tab and typed
lines were dropped.

joinChannel now records the joined channel's name. An effect makes that
channel's tab active once the tab list holds it, finding the tab by
name.

Model: opus-5-5
2026-10-27 02:18:52 +00:00
23 changed files with 284 additions and 1281 deletions
-4
View File
@@ -61,10 +61,6 @@ linters:
desc: >- desc: >-
Test-support code belongs in test files and in packages whose Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary. directory name ends in test, not in the shipped binary.
- pkg: sneak.berlin/go/neoirc/internal/dbtest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are # Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly. # listed here. Every entry matches the module path exactly.
gomodguard_v2: gomodguard_v2:
+1 -2
View File
@@ -2611,7 +2611,7 @@ listener.
| Variable | Type | Default | Description | | Variable | Type | Default | Description |
| ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | int | `8080` | HTTP listen port | | `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. It must not set `foreign_keys`. See [SQLite Considerations](#sqlite-considerations). | | `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. See [SQLite Considerations](#sqlite-considerations). |
| `DEBUG` | bool | `false` | Switch logging to debug level | | `DEBUG` | bool | `false` | Switch logging to debug level |
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | | `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. | | `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. |
@@ -3342,7 +3342,6 @@ neoirc/
│ │ └── schema/ │ │ └── schema/
│ │ ├── 000.sql # schema_migrations bootstrap │ │ ├── 000.sql # schema_migrations bootstrap
│ │ └── 001_initial.sql │ │ └── 001_initial.sql
│ ├── dbtest/ # Gives each test its own database; only tests import it
│ ├── globals/ # Application-wide metadata │ ├── globals/ # Application-wide metadata
│ │ └── globals.go │ │ └── globals.go
│ ├── handlers/ # HTTP request handlers │ ├── handlers/ # HTTP request handlers
+3 -28
View File
@@ -25,10 +25,7 @@ const (
httpErrThreshold = 400 httpErrThreshold = 400
) )
var ( var errHTTP = errors.New("HTTP error")
errHTTP = errors.New("HTTP error")
errJoinRefused = errors.New("the server refused the join")
)
// Client wraps HTTP calls to the neoirc server API. // Client wraps HTTP calls to the neoirc server API.
type Client struct { type Client struct {
@@ -209,35 +206,13 @@ func (client *Client) PollMessages(
}, nil }, nil
} }
// JoinChannel joins a channel. It returns an error unless the // JoinChannel joins a channel.
// server answers that the session joined. When the server
// refuses the join, it sends the reason to the message queue as
// an error numeric.
func (client *Client) JoinChannel(channel string) error { func (client *Client) JoinChannel(channel string) error {
data, err := client.do( return client.SendMessage(
http.MethodPost, "/api/v1/messages",
&Message{ //nolint:exhaustruct // only command+to needed &Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdJoin, To: channel, Command: irc.CmdJoin, To: channel,
}, },
) )
if err != nil {
return err
}
var resp struct {
Status string `json:"status"`
}
err = json.Unmarshal(data, &resp)
if err != nil {
return fmt.Errorf("decode join: %w", err)
}
if resp.Status != "joined" {
return errJoinRefused
}
return nil
} }
// PartChannel leaves a channel. // PartChannel leaves a channel.
-4
View File
@@ -627,10 +627,6 @@ func (a *App) cmdWindow(args string) {
nick, buf.Name, "connected", nick, buf.Name, "connected",
) )
} else { } else {
a.mu.Lock()
a.target = ""
a.mu.Unlock()
a.ui.SetStatus(nick, "", "connected") a.ui.SetStatus(nick, "", "connected")
} }
} }
+5 -130
View File
@@ -1,7 +1,6 @@
package cli package cli
import ( import (
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -139,9 +138,11 @@ func TestOwnCommandShownOnce(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
// The server accepts every command. Only the response // The server accepts every command and answers nothing.
// to a JOIN is read, and it says the session joined. server := httptest.NewServer(http.HandlerFunc(
server := newServer(t, `{"status": "joined"}`) func(http.ResponseWriter, *http.Request) {},
))
t.Cleanup(server.Close)
app := &App{ app := &App{
ui: NewUI(), ui: NewUI(),
@@ -165,117 +166,6 @@ func TestOwnCommandShownOnce(t *testing.T) {
} }
} }
// TestJoinResponse checks that /join switches to the channel's
// buffer and makes it the target only when the server answers
// that the session joined.
func TestJoinResponse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
response string
// The buffer shown and the target after the join.
want string
}{
{
name: "joined",
response: `{"status": "joined", "channel": "#chan"}`,
want: testChannel,
},
{
name: "refused",
response: `{"status": "error"}`,
want: testOtherNick,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{
ui: NewUI(),
client: api.NewClient(newServer(t, test.response).URL),
nick: testNick,
target: testOtherNick,
connected: true,
}
runUI(t, app.ui)
app.ui.SwitchToBuffer(testOtherNick)
app.cmdJoin(testChannel)
var shown string
app.ui.app.QueueUpdate(func() {
shown = app.ui.buffers[app.ui.currentBuffer].Name
})
if shown != test.want || app.target != test.want {
t.Fatalf("got buffer %s and target %s, want %s",
shown, app.target, test.want)
}
})
}
}
// TestAltDigit checks that Alt and a digit shows that window and
// makes its channel or user the target, as /window does. Window
// 0, the status window, leaves no target.
func TestAltDigit(t *testing.T) {
t.Parallel()
tests := []struct {
name string
digit rune
// The buffer shown and the target after the key.
wantBuffer string
wantTarget string
}{
{
name: "channel window",
digit: '1',
wantBuffer: testChannel,
wantTarget: testChannel,
},
{
name: "status window",
digit: '0',
wantBuffer: "(status)",
wantTarget: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick, target: testOtherNick}
app.ui.OnInput(app.handleInput)
runUI(t, app.ui)
// Window 1 is the channel. Window 2, the one shown, is
// the other user's.
app.ui.SwitchToBuffer(testChannel)
app.ui.SwitchToBuffer(testOtherNick)
key := tcell.NewEventKey(tcell.KeyRune, test.digit, tcell.ModAlt)
app.ui.app.GetInputCapture()(key)
var shown string
app.ui.app.QueueUpdate(func() {
shown = app.ui.buffers[app.ui.currentBuffer].Name
})
if shown != test.wantBuffer || app.target != test.wantTarget {
t.Fatalf("got buffer %s and target %q, want %s and %q",
shown, app.target, test.wantBuffer, test.wantTarget)
}
})
}
}
// TestOwnEventFromOtherClient checks that a message, notice, // TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another // join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to. // client shows as one line in the buffer it belongs to.
@@ -358,21 +248,6 @@ func bufferState(ui *UI, name string) (int, int) {
return lines, unread return lines, unread
} }
// newServer starts a server that answers every request with
// body until the test ends.
func newServer(t *testing.T, body string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(writer, body)
},
))
t.Cleanup(server.Close)
return server
}
// runUI runs ui on a simulated screen until the test ends. // runUI runs ui on a simulated screen until the test ends.
func runUI(t *testing.T, ui *UI) { func runUI(t *testing.T, ui *UI) {
t.Helper() t.Helper()
+2 -5
View File
@@ -240,13 +240,10 @@ func (ui *UI) initKeyCapture() {
return event return event
} }
// Alt and a digit runs /window with that digit.
// SwitchBuffer alone would leave the target as it was.
r := event.Rune() r := event.Rune()
if r >= '0' && r <= '9' { if r >= '0' && r <= '9' {
if ui.onInput != nil { idx := int(r - '0')
ui.onInput("/window " + string(r)) ui.SwitchBuffer(idx)
}
return nil return nil
} }
+7 -103
View File
@@ -3,12 +3,8 @@ package config
import ( import (
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"math"
"os" "os"
"strconv"
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
@@ -32,8 +28,6 @@ Type /help for available commands.`
const DefaultDBURL = "file:///var/lib/neoirc/state.db" + const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
var errOutOfRange = errors.New("setting out of range")
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -69,8 +63,6 @@ type Config struct {
} }
// New creates a new Config by reading from files and environment variables. // New creates a new Config by reading from files and environment variables.
// It returns an error when a number, true/false or duration setting does
// not parse or is out of range.
func New( func New(
_ fx.Lifecycle, params Params, _ fx.Lifecycle, params Params,
) (*Config, error) { ) (*Config, error) {
@@ -115,27 +107,29 @@ func New(
cfg := &Config{ cfg := &Config{
DBURL: viper.GetString("DBURL"), DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"), QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
OperName: viper.GetString("NEOIRC_OPER_NAME"), OperName: viper.GetString("NEOIRC_OPER_NAME"),
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"), OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"), IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"),
log: log, log: log,
params: &params, params: &params,
} }
err = parseSettings(cfg)
if err != nil {
return nil, err
}
// Viper counts an empty environment variable as unset, but an // Viper counts an empty environment variable as unset, but an
// empty IRC_LISTEN_ADDR must turn the IRC listener off. // empty IRC_LISTEN_ADDR must turn the IRC listener off.
ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR") ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR")
@@ -150,93 +144,3 @@ func New(
return cfg, nil return cfg, nil
} }
// parseSettings reads the number and true/false settings into cfg and
// checks the duration settings. Each number and duration must be above
// zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets the
// system pick a port, and 0 bits turns proof of work off. The error
// names each setting that does not parse or is out of range.
func parseSettings(cfg *Config) error {
return errors.Join(
parseInt("PORT", 0, &cfg.Port),
parseInt("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize),
parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits),
parseInt("LOGIN_RATE_BURST", 1, &cfg.LoginRateBurst),
parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit),
parseBool("DEBUG", &cfg.Debug),
parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode),
checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge),
checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge),
checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout),
)
}
// parseInt sets *value to the setting key read as a whole number. It
// returns an error when the value does not parse or is below minimum.
func parseInt(key string, minimum int, value *int) error {
raw := viper.GetString(key)
parsed, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if parsed < minimum {
return fmt.Errorf("%w: %s is %s, but must be at least %d",
errOutOfRange, key, raw, minimum)
}
*value = parsed
return nil
}
// parseFloat sets *value to the setting key read as a floating-point
// number. It returns an error when the value does not parse or is not a
// finite number above zero.
func parseFloat(key string, value *float64) error {
raw := viper.GetString(key)
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 {
return fmt.Errorf(
"%w: %s is %s, but must be a finite number above zero",
errOutOfRange, key, raw)
}
*value = parsed
return nil
}
// parseBool sets *value to the setting key read as true or false.
func parseBool(key string, value *bool) error {
parsed, err := strconv.ParseBool(viper.GetString(key))
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
*value = parsed
return nil
}
// checkDuration returns an error naming the setting key when value is
// not a Go duration above zero.
func checkDuration(key, value string) error {
duration, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if duration <= 0 {
return fmt.Errorf("%w: %s is %s, but must be above zero",
errOutOfRange, key, value)
}
return nil
}
+2 -149
View File
@@ -2,7 +2,6 @@ package config_test
import ( import (
"os" "os"
"strings"
"testing" "testing"
"time" "time"
@@ -11,8 +10,7 @@ import (
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
) )
// loadConfig runs config.New and returns its result. func newConfig(t *testing.T) *config.Config {
func loadConfig(t *testing.T) (*config.Config, error) {
t.Helper() t.Helper()
globs := &globals.Globals{ globs := &globals.Globals{
@@ -26,15 +24,9 @@ func loadConfig(t *testing.T) (*config.Config, error) {
t.Fatal(err) t.Fatal(err)
} }
return config.New( cfg, err := config.New(
nil, config.Params{Globals: globs, Logger: log}, nil, config.Params{Globals: globs, Logger: log},
) )
}
func newConfig(t *testing.T) *config.Config {
t.Helper()
cfg, err := loadConfig(t)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -42,145 +34,6 @@ func newConfig(t *testing.T) *config.Config {
return cfg return cfg
} }
func TestUnsetSettingsKeepDefaults(t *testing.T) {
// Viper counts an empty environment variable as unset.
t.Setenv("PORT", "")
t.Setenv("MAX_MESSAGE_SIZE", "")
t.Setenv("NEOIRC_HASHCASH_BITS", "")
t.Setenv("LOGIN_RATE_BURST", "")
t.Setenv("LOGIN_RATE_LIMIT", "")
t.Setenv("DEBUG", "")
t.Setenv("MAINTENANCE_MODE", "")
t.Setenv("MESSAGE_MAX_AGE", "")
t.Setenv("QUEUE_MAX_AGE", "")
t.Setenv("SESSION_IDLE_TIMEOUT", "")
// Each duration setting defaults to 30 days.
const thirtyDays = "720h"
want := settings{
Port: 8080,
MaxMessageSize: 4096,
HashcashBits: 20,
LoginRateBurst: 5,
LoginRateLimit: 1,
Debug: false,
MaintenanceMode: false,
MessageMaxAge: thirtyDays,
QueueMaxAge: thirtyDays,
SessionIdleTimeout: thirtyDays,
}
got := settingsOf(newConfig(t))
if got != want {
t.Errorf("settings = %+v, want %+v", got, want)
}
}
func TestValidSettingsAreUsed(t *testing.T) {
// Each whole number is the lowest value its setting allows, and 1ns
// is the shortest duration allowed.
t.Setenv("PORT", "0")
t.Setenv("MAX_MESSAGE_SIZE", "1")
t.Setenv("NEOIRC_HASHCASH_BITS", "0")
t.Setenv("LOGIN_RATE_BURST", "1")
t.Setenv("LOGIN_RATE_LIMIT", "0.5")
t.Setenv("DEBUG", "true")
t.Setenv("MAINTENANCE_MODE", "true")
t.Setenv("MESSAGE_MAX_AGE", "1ns")
t.Setenv("QUEUE_MAX_AGE", "1h30m")
t.Setenv("SESSION_IDLE_TIMEOUT", "45m")
want := settings{
Port: 0,
MaxMessageSize: 1,
HashcashBits: 0,
LoginRateBurst: 1,
LoginRateLimit: 0.5,
Debug: true,
MaintenanceMode: true,
MessageMaxAge: "1ns",
QueueMaxAge: "1h30m",
SessionIdleTimeout: "45m",
}
got := settingsOf(newConfig(t))
if got != want {
t.Errorf("settings = %+v, want %+v", got, want)
}
}
// settings holds the Config fields whose settings config.New parses.
type settings struct {
Port int
MaxMessageSize int
HashcashBits int
LoginRateBurst int
LoginRateLimit float64
Debug bool
MaintenanceMode bool
MessageMaxAge string
QueueMaxAge string
SessionIdleTimeout string
}
func settingsOf(cfg *config.Config) settings {
return settings{
Port: cfg.Port,
MaxMessageSize: cfg.MaxMessageSize,
HashcashBits: cfg.HashcashBits,
LoginRateBurst: cfg.LoginRateBurst,
LoginRateLimit: cfg.LoginRateLimit,
Debug: cfg.Debug,
MaintenanceMode: cfg.MaintenanceMode,
MessageMaxAge: cfg.MessageMaxAge,
QueueMaxAge: cfg.QueueMaxAge,
SessionIdleTimeout: cfg.SessionIdleTimeout,
}
}
func TestBadSettingStopsStartup(t *testing.T) {
// For each setting, the first value does not parse and the others
// parse but are out of range.
tests := []struct {
key string
values []string
}{
{key: "PORT", values: []string{"80a", "-1"}},
{key: "MAX_MESSAGE_SIZE", values: []string{"4k", "0", "-4096"}},
{key: "NEOIRC_HASHCASH_BITS", values: []string{"twenty", "-1"}},
{key: "LOGIN_RATE_BURST", values: []string{"five", "0", "-5"}},
{key: "LOGIN_RATE_LIMIT", values: []string{
"fast", "0", "-1", "NaN", "Inf", "-Inf",
}},
{key: "DEBUG", values: []string{"yes"}},
{key: "MAINTENANCE_MODE", values: []string{"on"}},
{key: "MESSAGE_MAX_AGE", values: []string{"30d", "0", "-1h"}},
{key: "QUEUE_MAX_AGE", values: []string{"720", "0s", "-24h"}},
{key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}},
}
for _, test := range tests {
for _, value := range test.values {
t.Run(test.key+"="+value, func(t *testing.T) {
t.Setenv(test.key, value)
_, err := loadConfig(t)
if err == nil {
t.Fatalf("%s=%q: got no error", test.key, value)
}
msg := err.Error()
if !strings.Contains(msg, test.key) ||
!strings.Contains(msg, value) {
t.Errorf("error %q does not name %s and %q",
msg, test.key, value)
}
})
}
}
}
func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) { func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test. // t.Setenv puts back whatever value was there after the test.
t.Setenv("IRC_LISTEN_ADDR", "") t.Setenv("IRC_LISTEN_ADDR", "")
+7 -33
View File
@@ -5,11 +5,9 @@ import (
"context" "context"
"database/sql" "database/sql"
"embed" "embed"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/url"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -24,10 +22,6 @@ import (
const minMigrationParts = 2 const minMigrationParts = 2
var errForeignKeysInDBURL = errors.New(
"DBURL must not set foreign_keys, which the server always turns on",
)
// SchemaFiles contains embedded SQL migration files. // SchemaFiles contains embedded SQL migration files.
// //
//go:embed schema/*.sql //go:embed schema/*.sql
@@ -96,33 +90,6 @@ func (database *Database) connect(ctx context.Context) error {
dbURL = config.DefaultDBURL dbURL = config.DefaultDBURL
} }
// SQLite keeps the foreign keys setting per connection. The driver
// runs each _pragma parameter on every connection it opens. So the
// one added below also covers the connection the pool opens after
// a cancelled query breaks the old one. A foreign_keys pragma in
// DBURL could undo it, because the driver sorts the pragmas.
_, query, hasQuery := strings.Cut(dbURL, "?")
urlParams, err := url.ParseQuery(query)
if err != nil {
return fmt.Errorf("parse DBURL: %w", err)
}
for _, pragma := range urlParams["_pragma"] {
if strings.Contains(strings.ToLower(pragma), "foreign_keys") {
return fmt.Errorf(
"%w: _pragma=%s", errForeignKeysInDBURL, pragma,
)
}
}
separator := "?"
if hasQuery {
separator = "&"
}
dbURL += separator + "_pragma=foreign_keys(1)"
database.log.Info( database.log.Info(
"connecting to database", "url", dbURL, "connecting to database", "url", dbURL,
) )
@@ -142,6 +109,13 @@ func (database *Database) connect(ctx context.Context) error {
database.conn = conn database.conn = conn
database.log.Info("database connected") database.log.Info("database connected")
_, err = database.conn.ExecContext(
ctx, "PRAGMA foreign_keys = ON",
)
if err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
return database.runMigrations(ctx) return database.runMigrations(ctx)
} }
-203
View File
@@ -1,34 +1,19 @@
package db_test package db_test
import ( import (
"context"
"errors"
"net/url"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
) )
const wantBusyTimeoutMS = 5000 const wantBusyTimeoutMS = 5000
const (
// endlessQuery counts the rows of a recursive query that never
// stops, so it runs until its context ends.
endlessQuery = `WITH RECURSIVE n(x) AS
(SELECT 1 UNION ALL SELECT x + 1 FROM n)
SELECT COUNT(*) FROM n`
endlessQueryTimeout = 100 * time.Millisecond
)
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database // TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
// with the default URL's parameters the way the server does, // with the default URL's parameters the way the server does,
// and checks the journal mode and busy timeout it ends up with. // and checks the journal mode and busy timeout it ends up with.
@@ -88,191 +73,3 @@ func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) {
) )
} }
} }
// TestForeignKeysOnAfterCancelledQuery cancels a query while it
// runs, which makes the pool close its only connection and open a
// new one. A TEMP table lasts only as long as its connection, so
// its absence shows the connection was replaced. The test then
// checks that the file database keeps its tables and that the new
// connection has foreign keys on.
func TestForeignKeysOnAfterCancelledQuery(t *testing.T) {
t.Parallel()
_, defaultParams, _ := strings.Cut(config.DefaultDBURL, "?")
urlParams := map[string]string{
"default parameters": "?" + defaultParams,
"no parameters": "",
}
for name, params := range urlParams {
t.Run(name, func(t *testing.T) {
t.Parallel()
dbURL := "file://" +
filepath.Join(t.TempDir(), "state.db") + params
database, err := startDatabase(t, dbURL)
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(
t.Context(), "CREATE TEMP TABLE first_connection (x)",
)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(
t.Context(), endlessQueryTimeout,
)
defer cancel()
_, err = database.GetDB().ExecContext(ctx, endlessQuery)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("endless query: err = %v", err)
}
tempTables := queryInt(
t, database, "SELECT COUNT(*) FROM sqlite_temp_schema",
)
if tempTables != 0 {
t.Fatal("the pool kept its first connection")
}
migrations := queryInt(
t, database, "SELECT COUNT(*) FROM schema_migrations",
)
if migrations == 0 {
t.Error("schema_migrations is empty")
}
foreignKeys := queryInt(t, database, "PRAGMA foreign_keys")
if foreignKeys != 1 {
t.Errorf("foreign_keys = %d, want 1", foreignKeys)
}
})
}
}
// TestNewURLKeepsDataAfterCancelledQuery cancels a query on a test
// database while it runs, which makes the pool close its only
// connection and open a new one. A TEMP table lasts only as long as
// its connection, so its absence shows the connection was replaced.
// The database must still have its tables and rows.
func TestNewURLKeepsDataAfterCancelledQuery(t *testing.T) {
t.Parallel()
database, err := startDatabase(t, dbtest.NewURL(t))
if err != nil {
t.Fatal(err)
}
_, err = database.GetOrCreateChannel(t.Context(), "#test")
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(
t.Context(), "CREATE TEMP TABLE first_connection (x)",
)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), endlessQueryTimeout)
defer cancel()
_, err = database.GetDB().ExecContext(ctx, endlessQuery)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("endless query: err = %v", err)
}
tempTables := queryInt(
t, database, "SELECT COUNT(*) FROM sqlite_temp_schema",
)
if tempTables != 0 {
t.Fatal("the pool kept its first connection")
}
channels := queryInt(t, database, "SELECT COUNT(*) FROM channels")
if channels != 1 {
t.Errorf("channels = %d, want 1", channels)
}
}
// TestDBURLSettingForeignKeysFailsStartup checks that the database
// refuses to start when DBURL sets foreign_keys itself, however it
// is spelled.
func TestDBURLSettingForeignKeysFailsStartup(t *testing.T) {
t.Parallel()
pragmas := []string{
"foreign_keys(0)",
"foreign_keys(off)",
"foreign_keys=0",
"FOREIGN_KEYS(FALSE)",
" Foreign_Keys = 1",
}
for _, pragma := range pragmas {
t.Run(pragma, func(t *testing.T) {
t.Parallel()
dbURL := "file://" +
filepath.Join(t.TempDir(), "state.db") +
"?_pragma=" + url.QueryEscape(pragma)
_, err := startDatabase(t, dbURL)
if err == nil {
t.Fatal("the database started")
}
if !strings.Contains(err.Error(), "DBURL") {
t.Errorf("error %q does not name DBURL", err)
}
})
}
}
// startDatabase starts a Database for dbURL the way the server does.
// It returns the error that stopped it from starting, if any.
func startDatabase(t *testing.T, dbURL string) (*db.Database, error) {
t.Helper()
lifecycle := fxtest.NewLifecycle(t)
log, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
database, err := db.New(lifecycle, db.Params{
Logger: log,
Config: &config.Config{DBURL: dbURL},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(lifecycle.RequireStop)
return database, lifecycle.Start(t.Context())
}
// queryInt runs a query that returns a single number.
func queryInt(t *testing.T, database *db.Database, query string) int {
t.Helper()
var result int
err := database.GetDB().QueryRowContext(t.Context(), query).Scan(&result)
if err != nil {
t.Fatal(err)
}
return result
}
+12 -7
View File
@@ -5,16 +5,21 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"testing" "sync/atomic"
"sneak.berlin/go/neoirc/internal/dbtest"
) )
// NewTestDatabase creates an in-memory database for testing. //nolint:gochecknoglobals // test counter
func NewTestDatabase(tb testing.TB) (*Database, error) { var testDBCounter atomic.Int64
tb.Helper()
dsn := dbtest.NewURL(tb) + "&_pragma=foreign_keys(1)" // NewTestDatabase creates an in-memory database for testing.
func NewTestDatabase() (*Database, error) {
counter := testDBCounter.Add(1)
dsn := fmt.Sprintf(
"file:testdb%d?mode=memory"+
"&cache=shared&_pragma=foreign_keys(1)",
counter,
)
conn, err := sql.Open("sqlite", dsn) conn, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
func setupTestDB(t *testing.T) *db.Database { func setupTestDB(t *testing.T) *db.Database {
t.Helper() t.Helper()
database, err := db.NewTestDatabase(t) database, err := db.NewTestDatabase()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
-39
View File
@@ -1,39 +0,0 @@
// Package dbtest gives each test a database of its own. Only tests
// may import it.
package dbtest
import (
"crypto/rand"
"database/sql"
"testing"
_ "modernc.org/sqlite" // registers the sqlite driver
)
// NewURL returns the URL of a new, empty in-memory database for one
// test. SQLite deletes an in-memory database when its last connection
// closes. A pool closes a connection when a query on it is cancelled
// while it runs. So this function holds a connection of its own open
// until the test ends.
func NewURL(tb testing.TB) string {
tb.Helper()
// A random name, so that no other test can share the database.
dbURL := "file:" + rand.Text() + "?mode=memory&cache=shared"
pool, err := sql.Open("sqlite", dbURL)
if err != nil {
tb.Fatalf("open test database: %v", err)
}
tb.Cleanup(func() { _ = pool.Close() })
conn, err := pool.Conn(tb.Context())
if err != nil {
tb.Fatalf("connect to test database: %v", err)
}
tb.Cleanup(func() { _ = conn.Close() })
return dbURL
}
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -1,3 +1,4 @@
//nolint:paralleltest // global viper, as in api_test.go
package handlers_test package handlers_test
import ( import (
@@ -71,8 +72,6 @@ func numericBody(
} }
func TestUserhost(t *testing.T) { func TestUserhost(t *testing.T) {
t.Parallel()
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("alice") token := tserver.createSession("alice")
@@ -110,8 +109,6 @@ func TestUserhost(t *testing.T) {
} }
func TestVersionAdminInfoTime(t *testing.T) { func TestVersionAdminInfoTime(t *testing.T) {
t.Parallel()
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("frank") token := tserver.createSession("frank")
@@ -150,8 +147,6 @@ func TestVersionAdminInfoTime(t *testing.T) {
} }
func TestKill(t *testing.T) { func TestKill(t *testing.T) {
t.Parallel()
tserver := newTestServerWithOper(t) tserver := newTestServerWithOper(t)
victimToken := tserver.createSession("victim") victimToken := tserver.createSession("victim")
@@ -218,8 +213,6 @@ func TestKill(t *testing.T) {
} }
func TestWallops(t *testing.T) { func TestWallops(t *testing.T) {
t.Parallel()
tserver := newTestServerWithOper(t) tserver := newTestServerWithOper(t)
receiverToken := tserver.createSession("receiver") receiverToken := tserver.createSession("receiver")
@@ -265,8 +258,6 @@ func TestWallops(t *testing.T) {
} }
func TestUserMode(t *testing.T) { func TestUserMode(t *testing.T) {
t.Parallel()
const ( const (
nick = "alice" nick = "alice"
other = "other" other = "other"
+2 -6
View File
@@ -32,12 +32,8 @@ func (c *Conn) relayMessages(ctx context.Context) {
// still signals waitCh. // still signals waitCh.
waitCh := c.brk.Wait(c.sessionID) waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages. The drain ignores the // Drain any available messages.
// cancel that comes when the connection ends, because a delivered := c.drainQueue(ctx)
// query cancelled while it runs leaves the SQLite
// connection marked broken, and the pool closes it. The
// loop still stops on ctx.
delivered := c.drainQueue(context.WithoutCancel(ctx))
if delivered { if delivered {
c.brk.Remove(c.sessionID, waitCh) c.brk.Remove(c.sessionID, waitCh)
+24 -5
View File
@@ -3,6 +3,7 @@ package ircserver_test
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/rand"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -19,7 +20,6 @@ import (
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
@@ -107,7 +107,14 @@ func newTestEnvWithResolver(
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
conn, err := sql.Open("sqlite", dbtest.NewURL(t)) // A random name, so that no other test environment, not
// even an earlier run of the same test, can share it.
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared",
rand.Text(),
)
conn, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
@@ -137,6 +144,20 @@ func newTestEnvWithResolver(
brk := broker.New() brk := broker.New()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
log := slog.New(slog.NewTextHandler( log := slog.New(slog.NewTextHandler(
os.Stderr, os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
@@ -146,9 +167,7 @@ func newTestEnvWithResolver(
log, cfg, database, brk, interval, deadline, resolve, log, cfg, database, brk, interval, deadline, resolve,
) )
// Port 0 makes the system pick a free port. dial reads it err = srv.Start(addr)
// back from the server's listener.
err = srv.Start("127.0.0.1:0")
if err != nil { if err != nil {
t.Fatalf("start irc server: %v", err) t.Fatalf("start irc server: %v", err)
} }
-6
View File
@@ -15,12 +15,6 @@ func NewTestSPAServer() *Server {
return srv return srv
} }
// Listener returns the listener the start hook bound for the
// HTTP server.
func (srv *Server) Listener() net.Listener {
return srv.listener
}
// ServeUntilShutdown runs serveUntilShutdown, which serves the // ServeUntilShutdown runs serveUntilShutdown, which serves the
// HTTP server on listener, and returns when it does. // HTTP server on listener, and returns when it does.
func (srv *Server) ServeUntilShutdown(listener net.Listener) { func (srv *Server) ServeUntilShutdown(listener net.Listener) {
-3
View File
@@ -50,7 +50,6 @@ type Server struct {
sentryEnabled bool sentryEnabled bool
log *slog.Logger log *slog.Logger
httpServer *http.Server httpServer *http.Server
listener net.Listener
router *chi.Mux router *chi.Mux
params Params params Params
mw *middleware.Middleware mw *middleware.Middleware
@@ -180,8 +179,6 @@ func (srv *Server) startHTTP(ctx context.Context) error {
return fmt.Errorf("http listen: %w", err) return fmt.Errorf("http listen: %w", err)
} }
srv.listener = listener
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr, Addr: listenAddr,
ReadTimeout: httpReadTimeout, ReadTimeout: httpReadTimeout,
+48 -15
View File
@@ -5,6 +5,7 @@
package server_test package server_test
import ( import (
"crypto/rand"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -18,7 +19,6 @@ import (
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers" "sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck" "sneak.berlin/go/neoirc/internal/healthcheck"
@@ -45,9 +45,10 @@ const (
// for a long poll the HTTP server is serving to finish, and // for a long poll the HTTP server is serving to finish, and
// that the poll gets its response. // that the poll gets its response.
func TestStopWaitsForRequestInProgress(t *testing.T) { func TestStopWaitsForRequestInProgress(t *testing.T) {
var srv *server.Server port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
app, brk, database := startApp(t, fx.Populate(&srv)) app, brk, database := startApp(t, port)
sessionID, _, token, err := database.CreateSession( sessionID, _, token, err := database.CreateSession(
t.Context(), "poller", "poller", "", "", t.Context(), "poller", "poller", "", "",
@@ -56,12 +57,9 @@ func TestStopWaitsForRequestInProgress(t *testing.T) {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
tcpAddr, ok := srv.Listener().Addr().(*net.TCPAddr) waitUntil(t, "the HTTP server to listen", func() bool {
if !ok { return canConnect(t, addr)
t.Fatalf("listener address %v is not TCP", srv.Listener().Addr()) })
}
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(tcpAddr.Port))
statuses := make(chan int, 1) statuses := make(chan int, 1)
@@ -147,7 +145,7 @@ func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
func TestServeFailureStopsApp(t *testing.T) { func TestServeFailureStopsApp(t *testing.T) {
var srv *server.Server var srv *server.Server
app, _, _ := startApp(t, fx.Populate(&srv)) app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait() shutdowns := app.Wait()
@@ -170,7 +168,7 @@ func TestServeFailureStopsApp(t *testing.T) {
func TestServeAfterStopDoesNotStopApp(t *testing.T) { func TestServeAfterStopDoesNotStopApp(t *testing.T) {
var srv *server.Server var srv *server.Server
app, _, _ := startApp(t, fx.Populate(&srv)) app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait() shutdowns := app.Wait()
@@ -188,15 +186,16 @@ func TestServeAfterStopDoesNotStopApp(t *testing.T) {
} }
} }
// startApp starts the app newApp builds, with the HTTP port // startApp starts the app newApp builds, with the HTTP server
// set to 0, so the system gives the server a free port. // listening on port.
func startApp( func startApp(
t *testing.T, t *testing.T,
port int,
options ...fx.Option, options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) { ) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper() t.Helper()
app, brk, database := newApp(t, 0, options...) app, brk, database := newApp(t, port, options...)
app.RequireStart() app.RequireStart()
@@ -242,7 +241,10 @@ func newApp(
stats.New, stats.New,
), ),
fx.Decorate(func(cfg *config.Config) *config.Config { fx.Decorate(func(cfg *config.Config) *config.Config {
cfg.DBURL = dbtest.NewURL(t) // A random name, so that no other test can share it.
cfg.DBURL = fmt.Sprintf(
"file:%s?mode=memory&cache=shared", rand.Text(),
)
cfg.Port = port cfg.Port = port
return cfg return cfg
@@ -255,6 +257,21 @@ func newApp(
return app, brk, database return app, brk, database
} }
// freePort returns a TCP port that nothing was listening on a
// moment ago.
func freePort(t *testing.T) int {
t.Helper()
listener := closedListener(t)
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", listener.Addr())
}
return tcpAddr.Port
}
// closedListener returns a TCP listener that has already been // closedListener returns a TCP listener that has already been
// closed, so that accepting a connection on it fails. // closed, so that accepting a connection on it fails.
func closedListener(t *testing.T) net.Listener { func closedListener(t *testing.T) net.Listener {
@@ -277,6 +294,22 @@ func closedListener(t *testing.T) net.Listener {
return listener return listener
} }
// canConnect reports whether addr accepts a TCP connection.
func canConnect(t *testing.T, addr string) bool {
t.Helper()
var dialer net.Dialer
conn, err := dialer.DialContext(t.Context(), "tcp", addr)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// waitUntil calls done every checkStep until it returns true, // waitUntil calls done every checkStep until it returns true,
// and fails the test if it does not within testTimeout. // and fails the test if it does not within testTimeout.
func waitUntil(t *testing.T, what string, done func() bool) { func waitUntil(t *testing.T, what string, done func() bool) {
+8 -2
View File
@@ -6,6 +6,7 @@ package service_test
import ( import (
"context" "context"
"crypto/rand"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -22,7 +23,6 @@ import (
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
@@ -46,7 +46,13 @@ type testEnv struct {
func newTestEnv(t *testing.T) *testEnv { func newTestEnv(t *testing.T) *testEnv {
t.Helper() t.Helper()
dbURL := dbtest.NewURL(t) // A random name, so that no other test can share it, not
// even one that has finished but still holds its database
// open.
dbURL := fmt.Sprintf(
"file:%s?mode=memory&cache=shared",
rand.Text(),
)
var ( var (
database *db.Database database *db.Database
+79 -147
View File
@@ -1,166 +1,98 @@
# Message schemas # Message Schemas
This directory holds JSON Schema files (draft 2020-12) for the messages that the JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
neoirc HTTP API carries. `message.json` describes the fields that every message **IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
can have. `commands/` has files for some commands. `numerics/` has files for over HTTP.
some of the numeric replies. A numeric reply is a server reply named by a
three-digit number, such as `001`. `README.md` at the repository root documents
the protocol.
The server can also run an IRC listener for ordinary IRC clients. The IRC ## Envelope
listener sends plain IRC lines, which these files do not describe.
## Message fields Every message is a JSON object with a `command` field. The format maps directly
to IRC wire format:
Every message is one JSON object, which `message.json` calls the envelope. These
are the fields it can have:
- `command`: the IRC command name, such as `PRIVMSG`, or the name of a numeric
reply, such as `RPL_WELCOME`. Always present.
- `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME`. Absent
on other messages.
- `id`: the message's UUID, a unique ID that the server assigns.
- `from`: the sender's nick, or the server name on a server message.
- `to`: the target, such as a channel name starting with `#` or a nick.
- `params`: an array of strings with the message's other parameters, in order.
`KICK` events and many numeric replies have it. The HTTP API ignores `params`
sent by a client.
- `body`: an array of strings. It is never a bare string. In a `PRIVMSG` or
`NOTICE`, each string is one line of text.
- `ts`: the time the server stored the message. It is an RFC 3339 time in UTC,
such as `2026-10-27T12:00:00.123Z`.
- `meta`: an object of extra data, such as a hashcash stamp in `meta.hashcash`.
A hashcash stamp is a short string that takes a computer some work to make. A
`PRIVMSG` to a channel with mode `+H` needs one. The HTTP API relays the
`meta` of a `PRIVMSG` or `NOTICE` unchanged. It drops the `meta` of every
other command.
Here is one `PRIVMSG` as the IRC listener sends it and as the HTTP API sends it.
The JSON leaves out `id`, `ts` and `meta`:
``` ```
IRC: :alice!alice@* PRIVMSG #general :hello world IRC: :nick PRIVMSG #channel :hello world
JSON: {"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]} JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]}
IRC: :server 353 nick = #channel :user1 @op1 +voice1
JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["user1 @op1 +voice1"]}
Multiline: {"command": "PRIVMSG", "to": "#ch", "body": ["line 1", "line 2"]}
Structured: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}
``` ```
In the JSON, a numeric reply has its name in `command` and its number in `code`. Common fields (see `message.json` for full schema):
This `353` reply lists the members of `#general`. The JSON again leaves out
`id`, `ts` and `meta`:
``` | Field | Type | Description |
{"command": "RPL_NAMREPLY", "code": 353, "from": "neoirc", "to": "alice", | --------- | --------------- | ---------------------------------------------------- |
"params": ["=", "#general"], "body": ["@op1!op1@host1 alice!alice@host2"]} | `id` | string (uuid) | Server-assigned message UUID |
``` | `command` | string | IRC command or 3-digit numeric code |
| `from` | string | Source nick or server name (IRC prefix) |
| `to` | string | Target: #channel or nick |
| `params` | string[] | Middle parameters (mainly for numerics) |
| `body` | array \| object | Structured body — never a raw string (see below) |
| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) |
| `meta` | object | Extensible metadata (signatures, hashes, etc.) |
A `PRIVMSG` whose `body` has two strings is a two-line message: **Structured bodies:** `body` is always an array of strings (for text) or an
object (for structured data like PUBKEY). Never a raw string. This enables:
``` - Multiline messages without escape sequences
{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} - Deterministic canonicalization via RFC 8785 JCS for signing
``` - Structured data where needed
## Files ## Commands
Commands, in `commands/`: IRC commands used for client↔server and server↔server communication.
- `PRIVMSG.json`: a message to a channel or a user. | Command | File | RFC | Description |
- `NOTICE.json`: a notice, which is a message that a client must not answer | --------- | ----------------------- | ----------- | -------------------------- |
automatically. | `PRIVMSG` | `commands/PRIVMSG.json` | 1459 §4.4.1 | Message to channel or user |
- `JOIN.json`: a user joined a channel. | `NOTICE` | `commands/NOTICE.json` | 1459 §4.4.2 | Notice (no auto-reply) |
- `PART.json`: a user left a channel. | `JOIN` | `commands/JOIN.json` | 1459 §4.2.1 | Join a channel |
- `QUIT.json`: a user left the server. | `PART` | `commands/PART.json` | 1459 §4.2.2 | Leave a channel |
- `NICK.json`: a user changed nick. | `QUIT` | `commands/QUIT.json` | 1459 §4.1.6 | User disconnected |
- `TOPIC.json`: a channel topic was set. | `NICK` | `commands/NICK.json` | 1459 §4.1.2 | Change nickname |
- `MODE.json`: a channel mode was changed. | `TOPIC` | `commands/TOPIC.json` | 1459 §4.2.4 | Get/set channel topic |
- `KICK.json`: a channel operator removed a user from a channel. | `MODE` | `commands/MODE.json` | 1459 §4.2.3 | Set channel/user modes |
- `PING.json`: a check that the server still answers. | `KICK` | `commands/KICK.json` | 1459 §4.2.8 | Kick user from channel |
- `PONG.json`: the answer to a `PING`. | `PING` | `commands/PING.json` | 1459 §4.6.2 | Keepalive |
- `PUBKEY.json`: an announcement of a user's public signing key. The server has | `PONG` | `commands/PONG.json` | 1459 §4.6.3 | Keepalive response |
no such command. It answers a `PUBKEY` with `ERR_UNKNOWNCOMMAND` (`421`). | `PUBKEY` | `commands/PUBKEY.json` | (extension) | Announce/relay signing key |
Numeric replies, in `numerics/`: ## Numeric Replies
- `001.json`: `RPL_WELCOME`, which welcomes a new user. Three-digit codes for server responses, per IRC convention.
- `002.json`: `RPL_YOURHOST`, the server's name and version.
- `003.json`: `RPL_CREATED`, which says when the server was created.
- `004.json`: `RPL_MYINFO`, the server's version and its lists of modes.
- `322.json`: `RPL_LIST`, one channel in the answer to `LIST`.
- `323.json`: `RPL_LISTEND`, the end of the answer to `LIST`.
- `332.json`: `RPL_TOPIC`, a channel's topic.
- `353.json`: `RPL_NAMREPLY`, a channel's members.
- `366.json`: `RPL_ENDOFNAMES`, the end of the member list.
- `372.json`: `RPL_MOTD`, one line of the message of the day.
- `375.json`: `RPL_MOTDSTART`, the start of the message of the day.
- `376.json`: `RPL_ENDOFMOTD`, the end of the message of the day.
- `401.json`: `ERR_NOSUCHNICK`, the nick does not exist.
- `403.json`: `ERR_NOSUCHCHANNEL`, the channel does not exist.
- `433.json`: `ERR_NICKNAMEINUSE`, the nick is taken.
- `442.json`: `ERR_NOTONCHANNEL`, the user is not on that channel.
- `482.json`: `ERR_CHANOPRIVSNEEDED`, the user is not a channel operator.
## Where the files differ from the HTTP API ### Success / Informational (0xx–3xx)
Where a schema file and the HTTP API disagree, the HTTP API is right: | Code | Name | File | Description |
| ----- | -------------- | ------------------- | ------------------------------ |
| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation |
| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info |
| `003` | RPL_CREATED | `numerics/003.json` | Server creation date |
| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes |
| `322` | RPL_LIST | `numerics/322.json` | Channel list entry |
| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list |
| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic |
| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list |
| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list |
| `372` | RPL_MOTD | `numerics/372.json` | MOTD line |
| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD |
| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD |
- `message.json` and the files in `numerics/` put the three-digit code in ### Errors (4xx)
`command`. They have no `code` field. The HTTP API puts the reply's name in
`command` and its number in `code`.
- `message.json` allows an object as `body`. The HTTP API reads only arrays of
strings. A `PRIVMSG` with an object as `body` gets `ERR_NOTEXTTOSEND` (`412`).
- `message.json` says `to` is a channel or a nick. A `WALLOPS` message has `*`
there.
- `message.json` allows `meta` on every message. The HTTP API keeps a client's
`meta` only on a `PRIVMSG` or `NOTICE`.
- `message.json` describes signature fields in `meta`. The server never checks a
signature.
- `message.json` does not describe `meta.hashcash`. The HTTP API reads it in a
`PRIVMSG` to a channel with mode `+H`.
- `JOIN.json` has no `body`. A client's `JOIN` can carry the channel key in
`body`. When a user joins, the `JOIN` event repeats the channel name in
`body`.
- `JOIN.json` allows a channel name of any length. The server allows at most 63
characters after the `#`. It answers a longer name with `ERR_NOSUCHCHANNEL`
(`403`).
- `KICK.json` puts the kicked nick in `params`. The HTTP API reads a client's
`KICK` from `body`: the nick, then an optional reason.
- `MODE.json` puts the mode change in `params`. The HTTP API uses `body`
instead. A client sends the change and any value as separate strings, such as
`"+o"` and `"bob"`. In a `MODE` event, `body` holds the change as one string,
such as `"+o bob"`, or as two strings, such as `"+k"` and `"secret"`.
- `NOTICE.json` allows `*` as `to`. The server answers a `NOTICE` to `*` with
`ERR_NOSUCHNICK` (`401`).
- `NOTICE.json` has an example notice from the server to `*`. The server sends
no such notice.
- `PING.json` and `PONG.json` describe a token that the `PONG` repeats. The HTTP
API ignores the `body` of a `PING`. Its answer is the HTTP response
`{"command": "PONG", "from": "<server name>"}`.
- `PING.json` and `PONG.json` also describe messages between servers. The server
does not connect to other IRC servers.
- `PUBKEY.json` describes a command the server does not have.
- `QUIT.json` says that `QUIT` comes only from the server. A client can send it
too, to end its session. A client's `QUIT` can carry a reason in `body`.
- `TOPIC.json` says a `TOPIC` without a `body` queries the topic. The HTTP API
answers such a `TOPIC` with `ERR_NEEDMOREPARAMS` (`461`).
- `TOPIC.json` says an empty array clears the topic. The HTTP API answers `[]`
with `ERR_NEEDMOREPARAMS` (`461`). A `body` of `[""]` clears the topic.
- `003.json` describes the server's creation date. The HTTP API sends the date
the server last started.
- `004.json` has example mode lists. The HTTP API sends an empty string as the
user modes and `ikmnostl` as the channel modes.
- `322.json` says `LIST` gets one reply per channel. The server leaves out a
secret channel, one with mode `+s`, unless the user is on it.
- `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such
query. It sends `RPL_TOPIC` after a client sets the topic, and after a `JOIN`
to a channel that has one.
- `353.json` says `body` lists nicks. The HTTP API lists `nick!user@host` in
place of each nick, such as `@op1!op1@host1`.
- `353.json` says the first parameter marks a channel as public, private or
secret. The server always sends `=`, the mark for a public channel.
- `376.json` gives the text as `End of /MOTD command`. The HTTP API adds a full
stop.
- `401.json` gives the text as `No such nick/channel`. The server sends
`No such nick` in answer to a `PRIVMSG` or `NOTICE` to an unknown nick.
- `433.json` has an example with `*` as `to`. The HTTP API sends `433` only in
answer to a `NICK`, with the user's nick as `to`.
The HTTP API also accepts some messages that the files do not allow, such as a | Code | Name | File | Description |
`JOIN` whose `to` has no `#`. | ----- | -------------------- | ------------------- | ----------------------- |
| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel |
| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel |
| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use |
| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel |
| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator |
## Federation (S2S)
Server-to-server messages use the same command format. Federated servers relay
messages with an additional `origin` field in `meta` to track the source server.
The PING/PONG commands serve as inter-server keepalives. State sync after link
establishment uses a burst of JOIN, NICK, TOPIC, and MODE commands.
+69 -102
View File
@@ -7,11 +7,6 @@ const RECONNECT_DELAY = 3000;
const MEMBER_REFRESH_INTERVAL = 10000; const MEMBER_REFRESH_INTERVAL = 10000;
const ACTION_PREFIX = "\x01ACTION "; const ACTION_PREFIX = "\x01ACTION ";
const ACTION_SUFFIX = "\x01"; const ACTION_SUFFIX = "\x01";
// SERVER_TAB is the Server tab's name, under which its messages and
// unread count are stored. No nick or channel name can contain *, so a
// user with the nick Server gets a direct-message tab of its own. The
// tab bar labels this tab Server.
const SERVER_TAB = "*server*";
// Hashcash proof-of-work helpers using Web Crypto API. // Hashcash proof-of-work helpers using Web Crypto API.
@@ -275,9 +270,9 @@ function UserList({ members, onNickClick }) {
function App() { function App() {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [nick, setNick] = useState(""); const [nick, setNick] = useState("");
const [tabs, setTabs] = useState([{ type: "server", name: SERVER_TAB }]); const [tabs, setTabs] = useState([{ type: "server", name: "Server" }]);
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [messages, setMessages] = useState({ [SERVER_TAB]: [] }); const [messages, setMessages] = useState({ Server: [] });
const [members, setMembers] = useState({}); const [members, setMembers] = useState({});
const [topics, setTopics] = useState({}); const [topics, setTopics] = useState({});
const [unread, setUnread] = useState({}); const [unread, setUnread] = useState({});
@@ -285,6 +280,8 @@ function App() {
const [connected, setConnected] = useState(true); const [connected, setConnected] = useState(true);
const [inputHistory, setInputHistory] = useState([]); const [inputHistory, setInputHistory] = useState([]);
const [historyIndex, setHistoryIndex] = useState(-1); const [historyIndex, setHistoryIndex] = useState(-1);
// A channel this client has just joined, whose tab is to be shown.
const [joinedChannel, setJoinedChannel] = useState(null);
const lastIdRef = useRef(0); const lastIdRef = useRef(0);
const seenIdsRef = useRef(new Set()); const seenIdsRef = useRef(new Set());
@@ -320,6 +317,18 @@ function App() {
if (tab) setUnread((prev) => ({ ...prev, [tab.name]: 0 })); if (tab) setUnread((prev) => ({ ...prev, [tab.name]: 0 }));
}, [activeTab, tabs]); }, [activeTab, tabs]);
// Show the tab of a channel this client has just joined. The tab is
// found by name in the updated tab list, because the user's own JOIN
// event may have added it before the join request returned.
useEffect(() => {
if (!joinedChannel) return;
const idx = tabs.findIndex(
(t) => t.type === "channel" && t.name === joinedChannel,
);
if (idx >= 0) setActiveTab(idx);
setJoinedChannel(null);
}, [tabs, joinedChannel]);
const addMessage = useCallback((tabName, msg) => { const addMessage = useCallback((tabName, msg) => {
if (msg.id && seenIdsRef.current.has(msg.id)) return; if (msg.id && seenIdsRef.current.has(msg.id)) return;
if (msg.id) seenIdsRef.current.add(msg.id); if (msg.id) seenIdsRef.current.add(msg.id);
@@ -360,24 +369,12 @@ function App() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
// removeChannelTab closes a channel's tab. The tab the user was // removeChannelTab closes a channel's tab and shows the Server tab.
// reading stays active, found by its name; when it was the closed tab,
// the Server tab shows.
const removeChannelTab = useCallback((name) => { const removeChannelTab = useCallback((name) => {
setTabs((prev) => { setTabs((prev) =>
const remaining = prev.filter( prev.filter((t) => !(t.type === "channel" && t.name === name)),
(t) => !(t.type === "channel" && t.name === name),
); );
// Preact runs this update at once, on the newest active tab. setActiveTab(0);
setActiveTab((active) => {
const reading = prev[active]?.name;
const idx = remaining.findIndex((t) => t.name === reading);
return idx >= 0 ? idx : 0;
});
return remaining;
});
}, []); }, []);
const processMessage = useCallback( const processMessage = useCallback(
@@ -470,15 +467,6 @@ function App() {
const text = `${kicked} was kicked from ${msg.to} by ${msg.from}${reason}`; const text = `${kicked} was kicked from ${msg.to} by ${msg.from}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true }); if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (kicked === nickRef.current) { if (kicked === nickRef.current) {
// The channel's tab closes, so the Server tab gets the line
// too. It needs an id of its own: addMessage drops a message
// whose id it has already seen.
addMessage(SERVER_TAB, {
...base,
id: msg.id + "-server",
text,
system: true,
});
removeChannelTab(msg.to); removeChannelTab(msg.to);
} else if (msg.to && msg.to.startsWith("#")) { } else if (msg.to && msg.to.startsWith("#")) {
refreshMembers(msg.to); refreshMembers(msg.to);
@@ -573,7 +561,7 @@ function App() {
const chName = msg.params[0]; const chName = msg.params[0];
const count = msg.params[1]; const count = msg.params[1];
const chTopic = body || ""; const chTopic = body || "";
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${chName} (${count} users): ${chTopic.trim()}`, text: `${chName} (${count} users): ${chTopic.trim()}`,
system: true, system: true,
@@ -583,7 +571,7 @@ function App() {
break; break;
} }
case 323: case 323:
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: body || "End of channel list", text: body || "End of channel list",
system: true, system: true,
@@ -596,7 +584,7 @@ function App() {
const whoCh = msg.params[0]; const whoCh = msg.params[0];
const whoNick = msg.params[4]; const whoNick = msg.params[4];
const whoFlags = msg.params.length > 5 ? msg.params[5] : ""; const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${whoCh} ${whoNick} ${whoFlags}`, text: `${whoCh} ${whoNick} ${whoFlags}`,
system: true, system: true,
@@ -606,7 +594,7 @@ function App() {
break; break;
} }
case 315: case 315:
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: body || "End of /WHO list", text: body || "End of /WHO list",
system: true, system: true,
@@ -617,7 +605,7 @@ function App() {
// RPL_WHOISUSER — params nick, user, host, *; body realname. // RPL_WHOISUSER — params nick, user, host, *; body realname.
if (Array.isArray(msg.params) && msg.params.length >= 3) { if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, wiUser, wiHost] = msg.params; const [wiNick, wiUser, wiHost] = msg.params;
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${wiNick} is ${wiUser}@${wiHost} (${body})`, text: `${wiNick} is ${wiUser}@${wiHost} (${body})`,
system: true, system: true,
@@ -631,7 +619,7 @@ function App() {
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
const wiServer = msg.params[1]; const wiServer = msg.params[1];
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${wiNick} on ${wiServer}`, text: `${wiNick} on ${wiServer}`,
system: true, system: true,
@@ -643,7 +631,7 @@ function App() {
case 313: { case 313: {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator". // RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${msg.params[0]} ${body}`, text: `${msg.params[0]} ${body}`,
system: true, system: true,
@@ -657,7 +645,7 @@ function App() {
if (Array.isArray(msg.params) && msg.params.length >= 3) { if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, idle, signon] = msg.params; const [wiNick, idle, signon] = msg.params;
const signedOn = new Date(Number(signon) * 1000).toLocaleString(); const signedOn = new Date(Number(signon) * 1000).toLocaleString();
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`, text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
system: true, system: true,
@@ -670,7 +658,7 @@ function App() {
// RPL_WHOISCHANNELS — nick, channels. // RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${wiNick} is on: ${body}`, text: `${wiNick} is on: ${body}`,
system: true, system: true,
@@ -680,7 +668,7 @@ function App() {
break; break;
} }
case 318: case 318:
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: body || "End of /WHOIS list", text: body || "End of /WHOIS list",
system: true, system: true,
@@ -690,7 +678,7 @@ function App() {
case 324: { case 324: {
// RPL_CHANNELMODEIS — channel, mode string. // RPL_CHANNELMODEIS — channel, mode string.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`, text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true, system: true,
@@ -702,12 +690,12 @@ function App() {
case 375: case 375:
case 372: case 372:
case 376: case 376:
addMessage(SERVER_TAB, { ...base, text: body, system: true }); addMessage("Server", { ...base, text: body, system: true });
break; break;
default: default:
if (body) { if (body) {
addMessage(SERVER_TAB, { ...base, text: body, system: true }); addMessage("Server", { ...base, text: body, system: true });
} }
} }
}, },
@@ -833,7 +821,7 @@ function App() {
); );
setNick(userNick); setNick(userNick);
setLoggedIn(true); setLoggedIn(true);
addSystemMessage(SERVER_TAB, `Connected as ${userNick}`); addSystemMessage("Server", `Connected as ${userNick}`);
if (isResumed) { if (isResumed) {
// Request MOTD on resumed sessions (new sessions // Request MOTD on resumed sessions (new sessions
@@ -857,11 +845,10 @@ function App() {
// Fresh session — join any previously saved channels. // Fresh session — join any previously saved channels.
for (const ch of saved) { for (const ch of saved) {
try { try {
const res = await api("/messages", { await api("/messages", {
method: "POST", method: "POST",
body: JSON.stringify({ command: "JOIN", to: ch }), body: JSON.stringify({ command: "JOIN", to: ch }),
}); });
if (res?.status !== "joined") continue;
setTabs((prev) => { setTabs((prev) => {
if (prev.find((t) => t.type === "channel" && t.name === ch)) if (prev.find((t) => t.type === "channel" && t.name === ch))
return prev; return prev;
@@ -881,30 +868,17 @@ function App() {
name = name.trim(); name = name.trim();
if (!name.startsWith("#")) name = "#" + name; if (!name.startsWith("#")) name = "#" + name;
try { try {
const res = await api("/messages", { await api("/messages", {
method: "POST", method: "POST",
body: JSON.stringify({ command: "JOIN", to: name }), body: JSON.stringify({ command: "JOIN", to: name }),
}); });
// A refused join opens no tab. The error numeric that the
// server sends to the message queue tells the user why.
if (res?.status !== "joined") return;
// Show the channel's tab. Preact runs this update at once on the
// newest tab list, so the tab is found there by name: the user's
// own JOIN event may already have added it, and other tabs may
// have closed.
setTabs((prev) => { setTabs((prev) => {
const idx = prev.findIndex( if (prev.find((t) => t.type === "channel" && t.name === name))
(t) => t.type === "channel" && t.name === name,
);
if (idx >= 0) {
setActiveTab(idx);
return prev; return prev;
}
setActiveTab(prev.length);
return [...prev, { type: "channel", name }]; return [...prev, { type: "channel", name }];
}); });
setJoinedChannel(name);
try { try {
const hist = await api( const hist = await api(
`/history?target=${encodeURIComponent(name)}&limit=50`, `/history?target=${encodeURIComponent(name)}&limit=50`,
@@ -917,7 +891,7 @@ function App() {
} }
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Failed to join ${name}: ${err.data?.error || "error"}`, `Failed to join ${name}: ${err.data?.error || "error"}`,
); );
} }
@@ -948,23 +922,16 @@ function App() {
const openDM = (targetNick) => { const openDM = (targetNick) => {
if (targetNick === nickRef.current) return; if (targetNick === nickRef.current) return;
// Show the direct-message tab. Preact runs this update at once on
// the newest tab list, so the tab is found there by name: the
// server's copy of the user's message may already have added it,
// and other tabs may have opened or closed.
setTabs((prev) => { setTabs((prev) => {
const idx = prev.findIndex( if (prev.find((t) => t.type === "dm" && t.name === targetNick))
(t) => t.type === "dm" && t.name === targetNick,
);
if (idx >= 0) {
setActiveTab(idx);
return prev; return prev;
}
setActiveTab(prev.length);
return [...prev, { type: "dm", name: targetNick }]; return [...prev, { type: "dm", name: targetNick }];
}); });
const idx = tabs.findIndex(
(t) => t.type === "dm" && t.name === targetNick,
);
setActiveTab(idx >= 0 ? idx : tabs.length);
}; };
const handleCommand = async (text) => { const handleCommand = async (text) => {
@@ -975,7 +942,7 @@ function App() {
switch (cmd) { switch (cmd) {
case "/join": { case "/join": {
if (parts[1]) joinChannel(parts[1]); if (parts[1]) joinChannel(parts[1]);
else addSystemMessage(SERVER_TAB, "Usage: /join #channel"); else addSystemMessage("Server", "Usage: /join #channel");
break; break;
} }
@@ -984,7 +951,7 @@ function App() {
const reason = parts.slice(1).join(" ") || undefined; const reason = parts.slice(1).join(" ") || undefined;
partChannel(tab.name, reason); partChannel(tab.name, reason);
} else { } else {
addSystemMessage(SERVER_TAB, "You are not in a channel"); addSystemMessage("Server", "You are not in a channel");
} }
break; break;
@@ -1005,19 +972,19 @@ function App() {
openDM(target); openDM(target);
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Message failed: ${err.data?.error || "error"}`, `Message failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /msg <nick> <message>"); addSystemMessage("Server", "Usage: /msg <nick> <message>");
} }
break; break;
} }
case "/me": { case "/me": {
if (tab.type === "server") { if (tab.type === "server") {
addSystemMessage(SERVER_TAB, "Cannot use /me in server window"); addSystemMessage("Server", "Cannot use /me in server window");
break; break;
} }
@@ -1039,7 +1006,7 @@ function App() {
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /me <action>"); addSystemMessage("Server", "Usage: /me <action>");
} }
break; break;
@@ -1053,19 +1020,19 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Nick change failed: ${err.data?.error || "error"}`, `Nick change failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /nick <newnick>"); addSystemMessage("Server", "Usage: /nick <newnick>");
} }
break; break;
} }
case "/topic": { case "/topic": {
if (tab.type !== "channel") { if (tab.type !== "channel") {
addSystemMessage(SERVER_TAB, "You are not in a channel"); addSystemMessage("Server", "You are not in a channel");
break; break;
} }
@@ -1082,13 +1049,13 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Topic change failed: ${err.data?.error || "error"}`, `Topic change failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`, `Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`,
); );
} }
@@ -1097,7 +1064,7 @@ function App() {
} }
case "/mode": { case "/mode": {
if (tab.type !== "channel") { if (tab.type !== "channel") {
addSystemMessage(SERVER_TAB, "You are not in a channel"); addSystemMessage("Server", "You are not in a channel");
break; break;
} }
@@ -1115,7 +1082,7 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`MODE failed: ${err.data?.error || "error"}`, `MODE failed: ${err.data?.error || "error"}`,
); );
} }
@@ -1146,7 +1113,7 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Failed to request MOTD: ${err.data?.error || "error"}`, `Failed to request MOTD: ${err.data?.error || "error"}`,
); );
} }
@@ -1170,13 +1137,13 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Message failed: ${err.data?.error || "error"}`, `Message failed: ${err.data?.error || "error"}`,
); );
} }
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /query <nick> [message]"); addSystemMessage("Server", "Usage: /query <nick> [message]");
} }
break; break;
@@ -1189,7 +1156,7 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Failed to list channels: ${err.data?.error || "error"}`, `Failed to list channels: ${err.data?.error || "error"}`,
); );
} }
@@ -1206,12 +1173,12 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`WHO failed: ${err.data?.error || "error"}`, `WHO failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /who #channel"); addSystemMessage("Server", "Usage: /who #channel");
} }
break; break;
@@ -1225,12 +1192,12 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`WHOIS failed: ${err.data?.error || "error"}`, `WHOIS failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /whois <nick>"); addSystemMessage("Server", "Usage: /whois <nick>");
} }
break; break;
@@ -1261,13 +1228,13 @@ function App() {
" /help — Show this help", " /help — Show this help",
]; ];
for (const line of helpLines) { for (const line of helpLines) {
addSystemMessage(SERVER_TAB, line); addSystemMessage("Server", line);
} }
break; break;
} }
default: default:
addSystemMessage(SERVER_TAB, `Unknown command: ${cmd}`); addSystemMessage("Server", `Unknown command: ${cmd}`);
} }
}; };
@@ -1296,7 +1263,7 @@ function App() {
if (tab.type === "server") { if (tab.type === "server") {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
"Cannot send messages to the server window. Use /join #channel first.", "Cannot send messages to the server window. Use /join #channel first.",
); );
@@ -1367,7 +1334,7 @@ function App() {
key={tab.name} key={tab.name}
> >
<span class="tab-label"> <span class="tab-label">
{tab.type === "server" ? "Server" : tab.name} {tab.type === "dm" ? tab.name : tab.name}
</span> </span>
{unread[tab.name] > 0 && i !== activeTab && ( {unread[tab.name] > 0 && i !== activeTab && (
<span class="unread-count">({unread[tab.name]})</span> <span class="unread-count">({unread[tab.name]})</span>