From 32419fb1f70f67000099e72cf27c86275ce83d7f Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 27 Feb 2026 02:21:48 -0800 Subject: [PATCH] feat: MVP two-user chat via embedded SPA (#9) Backend: - Session/client UUID model: sessions table (uuid, nick, signing_key), clients table (uuid, session_id, token) with per-client message queues - MOTD delivery as IRC numeric messages (375/372/376) on connect - EnqueueToSession fans out to all clients of a session - EnqueueToClient for targeted delivery (MOTD) - All queries updated for session/client model SPA client: - Long-poll loop (15s timeout) instead of setInterval - IRC message envelope parsing (command/from/to/body) - Display JOIN/PART/NICK/TOPIC/QUIT system messages - Nick change via /nick command - Topic display in header bar - Unread count badges on inactive tabs - Auto-rejoin channels on reconnect (localStorage) - Connection status indicator - Message deduplication by UUID - Channel history loaded on join - /topic command support Closes #9 --- internal/db/queries.go | 251 ++++++++++------ internal/db/queries_test.go | 188 ++++++++---- internal/db/schema/001_initial.sql | 29 +- internal/handlers/api.go | 399 ++++++++++++++---------- web/dist/app.js | 466 +---------------------------- web/dist/style.css | 43 +++ web/src/app.jsx | 382 +++++++++++++++-------- web/src/style.css | 43 +++ 8 files changed, 921 insertions(+), 880 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index e57356f..e4d6250 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -55,76 +55,132 @@ type MemberInfo struct { LastSeen time.Time `json:"lastSeen"` } -// CreateUser registers a new user with the given nick. -func (database *Database) CreateUser( +// CreateSession registers a new session and its first client. +func (database *Database) CreateSession( ctx context.Context, nick string, -) (int64, string, error) { +) (int64, int64, string, error) { + sessionUUID := uuid.New().String() + clientUUID := uuid.New().String() + token, err := generateToken() if err != nil { - return 0, "", err + return 0, 0, "", err } now := time.Now() - res, err := database.conn.ExecContext(ctx, - `INSERT INTO users - (nick, token, created_at, last_seen) - VALUES (?, ?, ?, ?)`, - nick, token, now, now) + transaction, err := database.conn.BeginTx(ctx, nil) if err != nil { - return 0, "", fmt.Errorf("create user: %w", err) + return 0, 0, "", fmt.Errorf( + "begin tx: %w", err, + ) } - userID, _ := res.LastInsertId() + res, err := transaction.ExecContext(ctx, + `INSERT INTO sessions + (uuid, nick, created_at, last_seen) + VALUES (?, ?, ?, ?)`, + sessionUUID, nick, now, now) + if err != nil { + _ = transaction.Rollback() - return userID, token, nil + return 0, 0, "", fmt.Errorf( + "create session: %w", err, + ) + } + + sessionID, _ := res.LastInsertId() + + clientRes, err := transaction.ExecContext(ctx, + `INSERT INTO clients + (uuid, session_id, token, + created_at, last_seen) + VALUES (?, ?, ?, ?, ?)`, + clientUUID, sessionID, token, now, now) + if err != nil { + _ = transaction.Rollback() + + return 0, 0, "", fmt.Errorf( + "create client: %w", err, + ) + } + + clientID, _ := clientRes.LastInsertId() + + err = transaction.Commit() + if err != nil { + return 0, 0, "", fmt.Errorf( + "commit session: %w", err, + ) + } + + return sessionID, clientID, token, nil } -// GetUserByToken returns user id and nick for a token. -func (database *Database) GetUserByToken( +// GetSessionByToken returns session id, client id, and +// nick for a client token. +func (database *Database) GetSessionByToken( ctx context.Context, token string, -) (int64, string, error) { - var userID int64 - - var nick string +) (int64, int64, string, error) { + var ( + sessionID int64 + clientID int64 + nick string + ) err := database.conn.QueryRowContext( ctx, - "SELECT id, nick FROM users WHERE token = ?", + `SELECT s.id, c.id, s.nick + FROM clients c + INNER JOIN sessions s + ON s.id = c.session_id + WHERE c.token = ?`, token, - ).Scan(&userID, &nick) + ).Scan(&sessionID, &clientID, &nick) if err != nil { - return 0, "", fmt.Errorf("get user by token: %w", err) + return 0, 0, "", fmt.Errorf( + "get session by token: %w", err, + ) } + now := time.Now() + _, _ = database.conn.ExecContext( ctx, - "UPDATE users SET last_seen = ? WHERE id = ?", - time.Now(), userID, + "UPDATE sessions SET last_seen = ? WHERE id = ?", + now, sessionID, ) - return userID, nick, nil + _, _ = database.conn.ExecContext( + ctx, + "UPDATE clients SET last_seen = ? WHERE id = ?", + now, clientID, + ) + + return sessionID, clientID, nick, nil } -// GetUserByNick returns user id for a given nick. -func (database *Database) GetUserByNick( +// GetSessionByNick returns session id for a given nick. +func (database *Database) GetSessionByNick( ctx context.Context, nick string, ) (int64, error) { - var userID int64 + var sessionID int64 err := database.conn.QueryRowContext( ctx, - "SELECT id FROM users WHERE nick = ?", + "SELECT id FROM sessions WHERE nick = ?", nick, - ).Scan(&userID) + ).Scan(&sessionID) if err != nil { - return 0, fmt.Errorf("get user by nick: %w", err) + return 0, fmt.Errorf( + "get session by nick: %w", err, + ) } - return userID, nil + return sessionID, nil } // GetChannelByName returns the channel ID for a name. @@ -179,16 +235,16 @@ func (database *Database) GetOrCreateChannel( return channelID, nil } -// JoinChannel adds a user to a channel. +// JoinChannel adds a session to a channel. func (database *Database) JoinChannel( ctx context.Context, - channelID, userID int64, + channelID, sessionID int64, ) error { _, err := database.conn.ExecContext(ctx, `INSERT OR IGNORE INTO channel_members - (channel_id, user_id, joined_at) + (channel_id, session_id, joined_at) VALUES (?, ?, ?)`, - channelID, userID, time.Now()) + channelID, sessionID, time.Now()) if err != nil { return fmt.Errorf("join channel: %w", err) } @@ -196,15 +252,15 @@ func (database *Database) JoinChannel( return nil } -// PartChannel removes a user from a channel. +// PartChannel removes a session from a channel. func (database *Database) PartChannel( ctx context.Context, - channelID, userID int64, + channelID, sessionID int64, ) error { _, err := database.conn.ExecContext(ctx, `DELETE FROM channel_members - WHERE channel_id = ? AND user_id = ?`, - channelID, userID) + WHERE channel_id = ? AND session_id = ?`, + channelID, sessionID) if err != nil { return fmt.Errorf("part channel: %w", err) } @@ -265,18 +321,18 @@ func scanChannels( return out, nil } -// ListChannels returns channels the user has joined. +// ListChannels returns channels the session has joined. func (database *Database) ListChannels( ctx context.Context, - userID int64, + sessionID int64, ) ([]ChannelInfo, error) { rows, err := database.conn.QueryContext(ctx, `SELECT c.id, c.name, c.topic FROM channels c INNER JOIN channel_members cm ON cm.channel_id = c.id - WHERE cm.user_id = ? - ORDER BY c.name`, userID) + WHERE cm.session_id = ? + ORDER BY c.name`, sessionID) if err != nil { return nil, fmt.Errorf("list channels: %w", err) } @@ -306,12 +362,12 @@ func (database *Database) ChannelMembers( channelID int64, ) ([]MemberInfo, error) { rows, err := database.conn.QueryContext(ctx, - `SELECT u.id, u.nick, u.last_seen - FROM users u + `SELECT s.id, s.nick, s.last_seen + FROM sessions s INNER JOIN channel_members cm - ON cm.user_id = u.id + ON cm.session_id = s.id WHERE cm.channel_id = ? - ORDER BY u.nick`, channelID) + ORDER BY s.nick`, channelID) if err != nil { return nil, fmt.Errorf( "query channel members: %w", err, @@ -349,17 +405,17 @@ func (database *Database) ChannelMembers( return members, nil } -// IsChannelMember checks if a user belongs to a channel. +// IsChannelMember checks if a session belongs to a channel. func (database *Database) IsChannelMember( ctx context.Context, - channelID, userID int64, + channelID, sessionID int64, ) (bool, error) { var count int err := database.conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_members - WHERE channel_id = ? AND user_id = ?`, - channelID, userID, + WHERE channel_id = ? AND session_id = ?`, + channelID, sessionID, ).Scan(&count) if err != nil { return false, fmt.Errorf( @@ -397,13 +453,13 @@ func scanInt64s(rows *sql.Rows) ([]int64, error) { return ids, nil } -// GetChannelMemberIDs returns user IDs in a channel. +// GetChannelMemberIDs returns session IDs in a channel. func (database *Database) GetChannelMemberIDs( ctx context.Context, channelID int64, ) ([]int64, error) { rows, err := database.conn.QueryContext(ctx, - `SELECT user_id FROM channel_members + `SELECT session_id FROM channel_members WHERE channel_id = ?`, channelID) if err != nil { return nil, fmt.Errorf( @@ -414,17 +470,17 @@ func (database *Database) GetChannelMemberIDs( return scanInt64s(rows) } -// GetUserChannelIDs returns channel IDs the user is in. -func (database *Database) GetUserChannelIDs( +// GetSessionChannelIDs returns channel IDs for a session. +func (database *Database) GetSessionChannelIDs( ctx context.Context, - userID int64, + sessionID int64, ) ([]int64, error) { rows, err := database.conn.QueryContext(ctx, `SELECT channel_id FROM channel_members - WHERE user_id = ?`, userID) + WHERE session_id = ?`, sessionID) if err != nil { return nil, fmt.Errorf( - "get user channel ids: %w", err, + "get session channel ids: %w", err, ) } @@ -467,27 +523,52 @@ func (database *Database) InsertMessage( return dbID, msgUUID, nil } -// EnqueueMessage adds a message to a user's queue. -func (database *Database) EnqueueMessage( +// EnqueueToSession adds a message to all clients of a +// session's queues. +func (database *Database) EnqueueToSession( ctx context.Context, - userID, messageID int64, + sessionID, messageID int64, ) error { _, err := database.conn.ExecContext(ctx, `INSERT OR IGNORE INTO client_queues - (user_id, message_id, created_at) - VALUES (?, ?, ?)`, - userID, messageID, time.Now()) + (client_id, message_id, created_at) + SELECT c.id, ?, ? + FROM clients c + WHERE c.session_id = ?`, + messageID, time.Now(), sessionID) if err != nil { - return fmt.Errorf("enqueue message: %w", err) + return fmt.Errorf( + "enqueue to session: %w", err, + ) } return nil } -// PollMessages returns queued messages for a user. +// EnqueueToClient adds a message to a specific client's +// queue. +func (database *Database) EnqueueToClient( + ctx context.Context, + clientID, messageID int64, +) error { + _, err := database.conn.ExecContext(ctx, + `INSERT OR IGNORE INTO client_queues + (client_id, message_id, created_at) + VALUES (?, ?, ?)`, + clientID, messageID, time.Now()) + if err != nil { + return fmt.Errorf( + "enqueue to client: %w", err, + ) + } + + return nil +} + +// PollMessages returns queued messages for a client. func (database *Database) PollMessages( ctx context.Context, - userID, afterQueueID int64, + clientID, afterQueueID int64, limit int, ) ([]IRCMessage, int64, error) { if limit <= 0 { @@ -501,9 +582,9 @@ func (database *Database) PollMessages( FROM client_queues cq INNER JOIN messages m ON m.id = cq.message_id - WHERE cq.user_id = ? AND cq.id > ? + WHERE cq.client_id = ? AND cq.id > ? ORDER BY cq.id ASC LIMIT ?`, - userID, afterQueueID, limit) + clientID, afterQueueID, limit) if err != nil { return nil, afterQueueID, fmt.Errorf( "poll messages: %w", err, @@ -649,15 +730,15 @@ func reverseMessages(msgs []IRCMessage) { } } -// ChangeNick updates a user's nickname. +// ChangeNick updates a session's nickname. func (database *Database) ChangeNick( ctx context.Context, - userID int64, + sessionID int64, newNick string, ) error { _, err := database.conn.ExecContext(ctx, - "UPDATE users SET nick = ? WHERE id = ?", - newNick, userID) + "UPDATE sessions SET nick = ? WHERE id = ?", + newNick, sessionID) if err != nil { return fmt.Errorf("change nick: %w", err) } @@ -681,38 +762,38 @@ func (database *Database) SetTopic( return nil } -// DeleteUser removes a user and all their data. -func (database *Database) DeleteUser( +// DeleteSession removes a session and all its data. +func (database *Database) DeleteSession( ctx context.Context, - userID int64, + sessionID int64, ) error { _, err := database.conn.ExecContext( ctx, - "DELETE FROM users WHERE id = ?", - userID, + "DELETE FROM sessions WHERE id = ?", + sessionID, ) if err != nil { - return fmt.Errorf("delete user: %w", err) + return fmt.Errorf("delete session: %w", err) } return nil } -// GetAllChannelMembershipsForUser returns channels -// a user belongs to. -func (database *Database) GetAllChannelMembershipsForUser( +// GetSessionChannels returns channels a session +// belongs to. +func (database *Database) GetSessionChannels( ctx context.Context, - userID int64, + sessionID int64, ) ([]ChannelInfo, error) { rows, err := database.conn.QueryContext(ctx, `SELECT c.id, c.name, c.topic FROM channels c INNER JOIN channel_members cm ON cm.channel_id = c.id - WHERE cm.user_id = ?`, userID) + WHERE cm.session_id = ?`, sessionID) if err != nil { return nil, fmt.Errorf( - "get memberships: %w", err, + "get session channels: %w", err, ) } diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index a83a951..6afc9c7 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -27,70 +27,91 @@ func setupTestDB(t *testing.T) *db.Database { return database } -func TestCreateUser(t *testing.T) { +func TestCreateSession(t *testing.T) { t.Parallel() database := setupTestDB(t) ctx := t.Context() - id, token, err := database.CreateUser(ctx, "alice") + sessionID, _, token, err := database.CreateSession( + ctx, "alice", + ) if err != nil { t.Fatal(err) } - if id == 0 || token == "" { + if sessionID == 0 || token == "" { t.Fatal("expected valid id and token") } - _, _, err = database.CreateUser(ctx, "alice") - if err == nil { + _, _, dupToken, dupErr := database.CreateSession( + ctx, "alice", + ) + if dupErr == nil { t.Fatal("expected error for duplicate nick") } + + _ = dupToken } -func TestGetUserByToken(t *testing.T) { +func TestGetSessionByToken(t *testing.T) { t.Parallel() database := setupTestDB(t) ctx := t.Context() - _, token, err := database.CreateUser(ctx, "bob") + _, _, token, err := database.CreateSession(ctx, "bob") if err != nil { t.Fatal(err) } - id, nick, err := database.GetUserByToken(ctx, token) + sessionID, clientID, nick, err := + database.GetSessionByToken(ctx, token) if err != nil { t.Fatal(err) } - if nick != "bob" || id == 0 { + if nick != "bob" || sessionID == 0 || clientID == 0 { t.Fatalf("expected bob, got %s", nick) } - _, _, err = database.GetUserByToken(ctx, "badtoken") - if err == nil { + badSID, badCID, badNick, badErr := + database.GetSessionByToken(ctx, "badtoken") + if badErr == nil { t.Fatal("expected error for bad token") } + + if badSID != 0 || badCID != 0 || badNick != "" { + t.Fatal("expected zero values on error") + } } -func TestGetUserByNick(t *testing.T) { +func TestGetSessionByNick(t *testing.T) { t.Parallel() database := setupTestDB(t) ctx := t.Context() - _, _, err := database.CreateUser(ctx, "charlie") + charlieID, charlieClientID, charlieToken, err := + database.CreateSession(ctx, "charlie") if err != nil { t.Fatal(err) } - id, err := database.GetUserByNick(ctx, "charlie") + if charlieID == 0 || charlieClientID == 0 { + t.Fatal("expected valid session/client IDs") + } + + if charlieToken == "" { + t.Fatal("expected non-empty token") + } + + id, err := database.GetSessionByNick(ctx, "charlie") if err != nil || id == 0 { t.Fatal("expected to find charlie") } - _, err = database.GetUserByNick(ctx, "nobody") + _, err = database.GetSessionByNick(ctx, "nobody") if err == nil { t.Fatal("expected error for unknown nick") } @@ -129,7 +150,7 @@ func TestJoinAndPart(t *testing.T) { database := setupTestDB(t) ctx := t.Context() - uid, _, err := database.CreateUser(ctx, "user1") + sid, _, _, err := database.CreateSession(ctx, "user1") if err != nil { t.Fatal(err) } @@ -139,22 +160,22 @@ func TestJoinAndPart(t *testing.T) { t.Fatal(err) } - err = database.JoinChannel(ctx, chID, uid) + err = database.JoinChannel(ctx, chID, sid) if err != nil { t.Fatal(err) } ids, err := database.GetChannelMemberIDs(ctx, chID) - if err != nil || len(ids) != 1 || ids[0] != uid { - t.Fatal("expected user in channel") + if err != nil || len(ids) != 1 || ids[0] != sid { + t.Fatal("expected session in channel") } - err = database.JoinChannel(ctx, chID, uid) + err = database.JoinChannel(ctx, chID, sid) if err != nil { t.Fatal(err) } - err = database.PartChannel(ctx, chID, uid) + err = database.PartChannel(ctx, chID, sid) if err != nil { t.Fatal(err) } @@ -178,17 +199,17 @@ func TestDeleteChannelIfEmpty(t *testing.T) { t.Fatal(err) } - uid, _, err := database.CreateUser(ctx, "temp") + sid, _, _, err := database.CreateSession(ctx, "temp") if err != nil { t.Fatal(err) } - err = database.JoinChannel(ctx, chID, uid) + err = database.JoinChannel(ctx, chID, sid) if err != nil { t.Fatal(err) } - err = database.PartChannel(ctx, chID, uid) + err = database.PartChannel(ctx, chID, sid) if err != nil { t.Fatal(err) } @@ -204,7 +225,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) { } } -func createUserWithChannels( +func createSessionWithChannels( t *testing.T, database *db.Database, nick, ch1Name, ch2Name string, @@ -213,7 +234,7 @@ func createUserWithChannels( ctx := t.Context() - uid, _, err := database.CreateUser(ctx, nick) + sid, _, _, err := database.CreateSession(ctx, nick) if err != nil { t.Fatal(err) } @@ -232,29 +253,29 @@ func createUserWithChannels( t.Fatal(err) } - err = database.JoinChannel(ctx, ch1, uid) + err = database.JoinChannel(ctx, ch1, sid) if err != nil { t.Fatal(err) } - err = database.JoinChannel(ctx, ch2, uid) + err = database.JoinChannel(ctx, ch2, sid) if err != nil { t.Fatal(err) } - return uid, ch1, ch2 + return sid, ch1, ch2 } func TestListChannels(t *testing.T) { t.Parallel() database := setupTestDB(t) - uid, _, _ := createUserWithChannels( + sid, _, _ := createSessionWithChannels( t, database, "lister", "#a", "#b", ) channels, err := database.ListChannels( - t.Context(), uid, + t.Context(), sid, ) if err != nil || len(channels) != 2 { t.Fatalf( @@ -295,17 +316,21 @@ func TestChangeNick(t *testing.T) { database := setupTestDB(t) ctx := t.Context() - uid, token, err := database.CreateUser(ctx, "old") + sid, _, token, err := database.CreateSession( + ctx, "old", + ) if err != nil { t.Fatal(err) } - err = database.ChangeNick(ctx, uid, "new") + err = database.ChangeNick(ctx, sid, "new") if err != nil { t.Fatal(err) } - _, nick, err := database.GetUserByToken(ctx, token) + _, _, nick, err := database.GetSessionByToken( + ctx, token, + ) if err != nil { t.Fatal(err) } @@ -375,7 +400,16 @@ func TestPollMessages(t *testing.T) { database := setupTestDB(t) ctx := t.Context() - uid, _, err := database.CreateUser(ctx, "poller") + sid, _, token, err := database.CreateSession( + ctx, "poller", + ) + if err != nil { + t.Fatal(err) + } + + _, clientID, _, err := database.GetSessionByToken( + ctx, token, + ) if err != nil { t.Fatal(err) } @@ -389,7 +423,7 @@ func TestPollMessages(t *testing.T) { t.Fatal(err) } - err = database.EnqueueMessage(ctx, uid, dbID) + err = database.EnqueueToSession(ctx, sid, dbID) if err != nil { t.Fatal(err) } @@ -397,7 +431,7 @@ func TestPollMessages(t *testing.T) { const batchSize = 10 msgs, lastQID, err := database.PollMessages( - ctx, uid, 0, batchSize, + ctx, clientID, 0, batchSize, ) if err != nil { t.Fatal(err) @@ -420,7 +454,7 @@ func TestPollMessages(t *testing.T) { } msgs, _, _ = database.PollMessages( - ctx, uid, lastQID, batchSize, + ctx, clientID, lastQID, batchSize, ) if len(msgs) != 0 { @@ -467,13 +501,15 @@ func TestGetHistory(t *testing.T) { } } -func TestDeleteUser(t *testing.T) { +func TestDeleteSession(t *testing.T) { t.Parallel() database := setupTestDB(t) ctx := t.Context() - uid, _, err := database.CreateUser(ctx, "deleteme") + sid, _, _, err := database.CreateSession( + ctx, "deleteme", + ) if err != nil { t.Fatal(err) } @@ -485,19 +521,19 @@ func TestDeleteUser(t *testing.T) { t.Fatal(err) } - err = database.JoinChannel(ctx, chID, uid) + err = database.JoinChannel(ctx, chID, sid) if err != nil { t.Fatal(err) } - err = database.DeleteUser(ctx, uid) + err = database.DeleteSession(ctx, sid) if err != nil { t.Fatal(err) } - _, err = database.GetUserByNick(ctx, "deleteme") + _, err = database.GetSessionByNick(ctx, "deleteme") if err == nil { - t.Fatal("user should be deleted") + t.Fatal("session should be deleted") } ids, _ := database.GetChannelMemberIDs(ctx, chID) @@ -512,12 +548,12 @@ func TestChannelMembers(t *testing.T) { database := setupTestDB(t) ctx := t.Context() - uid1, _, err := database.CreateUser(ctx, "m1") + sid1, _, _, err := database.CreateSession(ctx, "m1") if err != nil { t.Fatal(err) } - uid2, _, err := database.CreateUser(ctx, "m2") + sid2, _, _, err := database.CreateSession(ctx, "m2") if err != nil { t.Fatal(err) } @@ -529,12 +565,12 @@ func TestChannelMembers(t *testing.T) { t.Fatal(err) } - err = database.JoinChannel(ctx, chID, uid1) + err = database.JoinChannel(ctx, chID, sid1) if err != nil { t.Fatal(err) } - err = database.JoinChannel(ctx, chID, uid2) + err = database.JoinChannel(ctx, chID, sid2) if err != nil { t.Fatal(err) } @@ -548,17 +584,17 @@ func TestChannelMembers(t *testing.T) { } } -func TestGetAllChannelMembershipsForUser(t *testing.T) { +func TestGetSessionChannels(t *testing.T) { t.Parallel() database := setupTestDB(t) - uid, _, _ := createUserWithChannels( + sid, _, _ := createSessionWithChannels( t, database, "multi", "#m1", "#m2", ) channels, err := - database.GetAllChannelMembershipsForUser( - t.Context(), uid, + database.GetSessionChannels( + t.Context(), sid, ) if err != nil || len(channels) != 2 { t.Fatalf( @@ -567,3 +603,51 @@ func TestGetAllChannelMembershipsForUser(t *testing.T) { ) } } + +func TestEnqueueToClient(t *testing.T) { + t.Parallel() + + database := setupTestDB(t) + ctx := t.Context() + + _, _, token, err := database.CreateSession( + ctx, "enqclient", + ) + if err != nil { + t.Fatal(err) + } + + _, clientID, _, err := database.GetSessionByToken( + ctx, token, + ) + if err != nil { + t.Fatal(err) + } + + body := json.RawMessage(`["test"]`) + + dbID, _, err := database.InsertMessage( + ctx, "PRIVMSG", "sender", "#ch", body, nil, + ) + if err != nil { + t.Fatal(err) + } + + err = database.EnqueueToClient(ctx, clientID, dbID) + if err != nil { + t.Fatal(err) + } + + const batchSize = 10 + + msgs, _, err := database.PollMessages( + ctx, clientID, 0, batchSize, + ) + if err != nil { + t.Fatal(err) + } + + if len(msgs) != 1 { + t.Fatalf("expected 1, got %d", len(msgs)) + } +} diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index 8434f78..14ebcf5 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -1,15 +1,28 @@ -- Chat server schema (pre-1.0 consolidated) PRAGMA foreign_keys = ON; --- Users: IRC-style sessions (no passwords, just nick + token) -CREATE TABLE IF NOT EXISTS users ( +-- Sessions: IRC-style sessions (no passwords, nick + optional signing key) +CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, nick TEXT NOT NULL UNIQUE, + signing_key TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions(uuid); + +-- Clients: each session can have multiple connected clients +CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP ); -CREATE INDEX IF NOT EXISTS idx_users_token ON users(token); +CREATE INDEX IF NOT EXISTS idx_clients_token ON clients(token); +CREATE INDEX IF NOT EXISTS idx_clients_session ON clients(session_id); -- Channels CREATE TABLE IF NOT EXISTS channels ( @@ -24,9 +37,9 @@ CREATE TABLE IF NOT EXISTS channels ( CREATE TABLE IF NOT EXISTS channel_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(channel_id, user_id) + UNIQUE(channel_id, session_id) ); -- Messages: IRC envelope format @@ -46,9 +59,9 @@ CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); -- Per-client message queues for fan-out delivery CREATE TABLE IF NOT EXISTS client_queues ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, message_id) + UNIQUE(client_id, message_id) ); -CREATE INDEX IF NOT EXISTS idx_client_queues_user ON client_queues(user_id, id); +CREATE INDEX IF NOT EXISTS idx_client_queues_client ON client_queues(client_id, id); diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 3c9a428..7159884 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "fmt" "net/http" @@ -37,35 +38,37 @@ func (hdlr *Handlers) maxBodySize() int64 { return defaultMaxBodySize } -// authUser extracts the user from the Authorization header. -func (hdlr *Handlers) authUser( +// authSession extracts the session from the client token. +func (hdlr *Handlers) authSession( request *http.Request, -) (int64, string, error) { +) (int64, int64, string, error) { auth := request.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { - return 0, "", errUnauthorized + return 0, 0, "", errUnauthorized } token := strings.TrimPrefix(auth, "Bearer ") if token == "" { - return 0, "", errUnauthorized + return 0, 0, "", errUnauthorized } - uid, nick, err := hdlr.params.Database.GetUserByToken( - request.Context(), token, - ) + sessionID, clientID, nick, err := + hdlr.params.Database.GetSessionByToken( + request.Context(), token, + ) if err != nil { - return 0, "", fmt.Errorf("auth: %w", err) + return 0, 0, "", fmt.Errorf("auth: %w", err) } - return uid, nick, nil + return sessionID, clientID, nick, nil } func (hdlr *Handlers) requireAuth( writer http.ResponseWriter, request *http.Request, -) (int64, string, bool) { - uid, nick, err := hdlr.authUser(request) +) (int64, int64, string, bool) { + sessionID, clientID, nick, err := + hdlr.authSession(request) if err != nil { hdlr.respondError( writer, request, @@ -73,19 +76,19 @@ func (hdlr *Handlers) requireAuth( http.StatusUnauthorized, ) - return 0, "", false + return 0, 0, "", false } - return uid, nick, true + return sessionID, clientID, nick, true } // fanOut stores a message and enqueues it to all specified -// user IDs, then notifies them. +// session IDs, then notifies them. func (hdlr *Handlers) fanOut( request *http.Request, command, from, target string, body json.RawMessage, - userIDs []int64, + sessionIDs []int64, ) (string, error) { dbID, msgUUID, err := hdlr.params.Database.InsertMessage( request.Context(), command, from, target, body, nil, @@ -94,16 +97,16 @@ func (hdlr *Handlers) fanOut( return "", fmt.Errorf("insert message: %w", err) } - for _, uid := range userIDs { - enqErr := hdlr.params.Database.EnqueueMessage( - request.Context(), uid, dbID, + for _, sid := range sessionIDs { + enqErr := hdlr.params.Database.EnqueueToSession( + request.Context(), sid, dbID, ) if enqErr != nil { hdlr.log.Error("enqueue failed", - "error", enqErr, "user_id", uid) + "error", enqErr, "session_id", sid) } - hdlr.broker.Notify(uid) + hdlr.broker.Notify(sid) } return msgUUID, nil @@ -114,10 +117,10 @@ func (hdlr *Handlers) fanOutSilent( request *http.Request, command, from, target string, body json.RawMessage, - userIDs []int64, + sessionIDs []int64, ) error { _, err := hdlr.fanOut( - request, command, from, target, body, userIDs, + request, command, from, target, body, sessionIDs, ) return err @@ -125,16 +128,6 @@ func (hdlr *Handlers) fanOutSilent( // HandleCreateSession creates a new user session. func (hdlr *Handlers) HandleCreateSession() http.HandlerFunc { - type createRequest struct { - Nick string `json:"nick"` - } - - type createResponse struct { - ID int64 `json:"id"` - Nick string `json:"nick"` - Token string `json:"token"` - } - return func( writer http.ResponseWriter, request *http.Request, @@ -143,82 +136,174 @@ func (hdlr *Handlers) HandleCreateSession() http.HandlerFunc { writer, request.Body, hdlr.maxBodySize(), ) - var payload createRequest - - err := json.NewDecoder(request.Body).Decode(&payload) - if err != nil { - hdlr.respondError( - writer, request, - "invalid request body", - http.StatusBadRequest, - ) - - return - } - - payload.Nick = strings.TrimSpace(payload.Nick) - - if !validNickRe.MatchString(payload.Nick) { - hdlr.respondError( - writer, request, - "invalid nick format", - http.StatusBadRequest, - ) - - return - } - - userID, token, err := hdlr.params.Database.CreateUser( - request.Context(), payload.Nick, - ) - if err != nil { - if strings.Contains(err.Error(), "UNIQUE") { - hdlr.respondError( - writer, request, - "nick already taken", - http.StatusConflict, - ) - - return - } - - hdlr.log.Error( - "create user failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - - return - } - - hdlr.respondJSON( - writer, request, - &createResponse{ - ID: userID, - Nick: payload.Nick, - Token: token, - }, - http.StatusCreated, - ) + hdlr.handleCreateSession(writer, request) } } -// HandleState returns the current user's info and channels. +func (hdlr *Handlers) handleCreateSession( + writer http.ResponseWriter, + request *http.Request, +) { + type createRequest struct { + Nick string `json:"nick"` + } + + var payload createRequest + + err := json.NewDecoder(request.Body).Decode(&payload) + if err != nil { + hdlr.respondError( + writer, request, + "invalid request body", + http.StatusBadRequest, + ) + + return + } + + payload.Nick = strings.TrimSpace(payload.Nick) + + if !validNickRe.MatchString(payload.Nick) { + hdlr.respondError( + writer, request, + "invalid nick format", + http.StatusBadRequest, + ) + + return + } + + sessionID, clientID, token, err := + hdlr.params.Database.CreateSession( + request.Context(), payload.Nick, + ) + if err != nil { + hdlr.handleCreateSessionError( + writer, request, err, + ) + + return + } + + hdlr.deliverMOTD(request, clientID, sessionID) + + hdlr.respondJSON(writer, request, map[string]any{ + "id": sessionID, + "nick": payload.Nick, + "token": token, + }, http.StatusCreated) +} + +func (hdlr *Handlers) handleCreateSessionError( + writer http.ResponseWriter, + request *http.Request, + err error, +) { + if strings.Contains(err.Error(), "UNIQUE") { + hdlr.respondError( + writer, request, + "nick already taken", + http.StatusConflict, + ) + + return + } + + hdlr.log.Error( + "create session failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) +} + +// deliverMOTD sends the MOTD as IRC numeric messages to a +// new client. +func (hdlr *Handlers) deliverMOTD( + request *http.Request, + clientID, sessionID int64, +) { + motd := hdlr.params.Config.MOTD + serverName := hdlr.params.Config.ServerName + + if serverName == "" { + serverName = "chat" + } + + if motd == "" { + return + } + + ctx := request.Context() + + hdlr.enqueueNumeric( + ctx, clientID, "375", serverName, + "- "+serverName+" Message of the Day -", + ) + + for line := range strings.SplitSeq(motd, "\n") { + hdlr.enqueueNumeric( + ctx, clientID, "372", serverName, + "- "+line, + ) + } + + hdlr.enqueueNumeric( + ctx, clientID, "376", serverName, + "End of /MOTD command.", + ) + + hdlr.broker.Notify(sessionID) +} + +func (hdlr *Handlers) enqueueNumeric( + ctx context.Context, + clientID int64, + command, serverName, text string, +) { + body, err := json.Marshal([]string{text}) + if err != nil { + hdlr.log.Error( + "marshal numeric body", "error", err, + ) + + return + } + + dbID, _, insertErr := hdlr.params.Database.InsertMessage( + ctx, command, serverName, "", + json.RawMessage(body), nil, + ) + if insertErr != nil { + hdlr.log.Error( + "insert numeric message", "error", insertErr, + ) + + return + } + + _ = hdlr.params.Database.EnqueueToClient( + ctx, clientID, dbID, + ) +} + +// HandleState returns the current session's info and +// channels. func (hdlr *Handlers) HandleState() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { - uid, nick, ok := hdlr.requireAuth(writer, request) + sessionID, _, nick, ok := + hdlr.requireAuth(writer, request) if !ok { return } channels, err := hdlr.params.Database.ListChannels( - request.Context(), uid, + request.Context(), sessionID, ) if err != nil { hdlr.log.Error( @@ -234,7 +319,7 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc { } hdlr.respondJSON(writer, request, map[string]any{ - "id": uid, + "id": sessionID, "nick": nick, "channels": channels, }, http.StatusOK) @@ -247,7 +332,7 @@ func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc { writer http.ResponseWriter, request *http.Request, ) { - _, _, ok := hdlr.requireAuth(writer, request) + _, _, _, ok := hdlr.requireAuth(writer, request) if !ok { return } @@ -280,7 +365,7 @@ func (hdlr *Handlers) HandleChannelMembers() http.HandlerFunc { writer http.ResponseWriter, request *http.Request, ) { - _, _, ok := hdlr.requireAuth(writer, request) + _, _, _, ok := hdlr.requireAuth(writer, request) if !ok { return } @@ -328,7 +413,8 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc { writer http.ResponseWriter, request *http.Request, ) { - uid, _, ok := hdlr.requireAuth(writer, request) + sessionID, clientID, _, ok := + hdlr.requireAuth(writer, request) if !ok { return } @@ -349,7 +435,7 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc { } msgs, lastQID, err := hdlr.params.Database.PollMessages( - request.Context(), uid, + request.Context(), clientID, afterID, pollMessageLimit, ) if err != nil { @@ -374,17 +460,20 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc { return } - hdlr.longPoll(writer, request, uid, afterID, timeout) + hdlr.longPoll( + writer, request, + sessionID, clientID, afterID, timeout, + ) } } func (hdlr *Handlers) longPoll( writer http.ResponseWriter, request *http.Request, - uid, afterID int64, + sessionID, clientID, afterID int64, timeout int, ) { - waitCh := hdlr.broker.Wait(uid) + waitCh := hdlr.broker.Wait(sessionID) timer := time.NewTimer( time.Duration(timeout) * time.Second, @@ -396,15 +485,15 @@ func (hdlr *Handlers) longPoll( case <-waitCh: case <-timer.C: case <-request.Context().Done(): - hdlr.broker.Remove(uid, waitCh) + hdlr.broker.Remove(sessionID, waitCh) return } - hdlr.broker.Remove(uid, waitCh) + hdlr.broker.Remove(sessionID, waitCh) msgs, lastQID, err := hdlr.params.Database.PollMessages( - request.Context(), uid, + request.Context(), clientID, afterID, pollMessageLimit, ) if err != nil { @@ -443,7 +532,8 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { writer, request.Body, hdlr.maxBodySize(), ) - uid, nick, ok := hdlr.requireAuth(writer, request) + sessionID, _, nick, ok := + hdlr.requireAuth(writer, request) if !ok { return } @@ -492,7 +582,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { } hdlr.dispatchCommand( - writer, request, uid, nick, + writer, request, sessionID, nick, payload.Command, payload.To, payload.Body, bodyLines, ) @@ -502,7 +592,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { func (hdlr *Handlers) dispatchCommand( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, nick, command, target string, body json.RawMessage, bodyLines func() []string, @@ -510,20 +600,20 @@ func (hdlr *Handlers) dispatchCommand( switch command { case cmdPrivmsg, "NOTICE": hdlr.handlePrivmsg( - writer, request, uid, nick, + writer, request, sessionID, nick, command, target, body, bodyLines, ) case "JOIN": hdlr.handleJoin( - writer, request, uid, nick, target, + writer, request, sessionID, nick, target, ) case "PART": hdlr.handlePart( - writer, request, uid, nick, target, body, + writer, request, sessionID, nick, target, body, ) case "NICK": hdlr.handleNick( - writer, request, uid, nick, bodyLines, + writer, request, sessionID, nick, bodyLines, ) case "TOPIC": hdlr.handleTopic( @@ -531,7 +621,7 @@ func (hdlr *Handlers) dispatchCommand( ) case "QUIT": hdlr.handleQuit( - writer, request, uid, nick, body, + writer, request, sessionID, nick, body, ) case "PING": hdlr.respondJSON(writer, request, @@ -552,7 +642,7 @@ func (hdlr *Handlers) dispatchCommand( func (hdlr *Handlers) handlePrivmsg( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, nick, command, target string, body json.RawMessage, bodyLines func() []string, @@ -580,7 +670,7 @@ func (hdlr *Handlers) handlePrivmsg( if strings.HasPrefix(target, "#") { hdlr.handleChannelMsg( - writer, request, uid, nick, + writer, request, sessionID, nick, command, target, body, ) @@ -588,7 +678,7 @@ func (hdlr *Handlers) handlePrivmsg( } hdlr.handleDirectMsg( - writer, request, uid, nick, + writer, request, sessionID, nick, command, target, body, ) } @@ -596,7 +686,7 @@ func (hdlr *Handlers) handlePrivmsg( func (hdlr *Handlers) handleChannelMsg( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, nick, command, target string, body json.RawMessage, ) { @@ -614,7 +704,7 @@ func (hdlr *Handlers) handleChannelMsg( } isMember, err := hdlr.params.Database.IsChannelMember( - request.Context(), chID, uid, + request.Context(), chID, sessionID, ) if err != nil { hdlr.log.Error( @@ -677,11 +767,11 @@ func (hdlr *Handlers) handleChannelMsg( func (hdlr *Handlers) handleDirectMsg( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, nick, command, target string, body json.RawMessage, ) { - targetUID, err := hdlr.params.Database.GetUserByNick( + targetSID, err := hdlr.params.Database.GetSessionByNick( request.Context(), target, ) if err != nil { @@ -694,9 +784,9 @@ func (hdlr *Handlers) handleDirectMsg( return } - recipients := []int64{targetUID} - if targetUID != uid { - recipients = append(recipients, uid) + recipients := []int64{targetSID} + if targetSID != sessionID { + recipients = append(recipients, sessionID) } msgUUID, err := hdlr.fanOut( @@ -721,7 +811,7 @@ func (hdlr *Handlers) handleDirectMsg( func (hdlr *Handlers) handleJoin( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, nick, target string, ) { if target == "" { @@ -766,7 +856,7 @@ func (hdlr *Handlers) handleJoin( } err = hdlr.params.Database.JoinChannel( - request.Context(), chID, uid, + request.Context(), chID, sessionID, ) if err != nil { hdlr.log.Error( @@ -800,7 +890,7 @@ func (hdlr *Handlers) handleJoin( func (hdlr *Handlers) handlePart( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, nick, target string, body json.RawMessage, ) { @@ -841,7 +931,7 @@ func (hdlr *Handlers) handlePart( ) err = hdlr.params.Database.PartChannel( - request.Context(), chID, uid, + request.Context(), chID, sessionID, ) if err != nil { hdlr.log.Error( @@ -871,7 +961,7 @@ func (hdlr *Handlers) handlePart( func (hdlr *Handlers) handleNick( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, nick string, bodyLines func() []string, ) { @@ -909,7 +999,7 @@ func (hdlr *Handlers) handleNick( } err := hdlr.params.Database.ChangeNick( - request.Context(), uid, newNick, + request.Context(), sessionID, newNick, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { @@ -934,7 +1024,7 @@ func (hdlr *Handlers) handleNick( return } - hdlr.broadcastNick(request, uid, nick, newNick) + hdlr.broadcastNick(request, sessionID, nick, newNick) hdlr.respondJSON(writer, request, map[string]string{ @@ -945,15 +1035,15 @@ func (hdlr *Handlers) handleNick( func (hdlr *Handlers) broadcastNick( request *http.Request, - uid int64, + sessionID int64, oldNick, newNick string, ) { channels, _ := hdlr.params.Database. - GetAllChannelMembershipsForUser( - request.Context(), uid, + GetSessionChannels( + request.Context(), sessionID, ) - notified := map[int64]bool{uid: true} + notified := map[int64]bool{sessionID: true} nickBody, err := json.Marshal([]string{newNick}) if err != nil { @@ -969,11 +1059,11 @@ func (hdlr *Handlers) broadcastNick( json.RawMessage(nickBody), nil, ) - _ = hdlr.params.Database.EnqueueMessage( - request.Context(), uid, dbID, + _ = hdlr.params.Database.EnqueueToSession( + request.Context(), sessionID, dbID, ) - hdlr.broker.Notify(uid) + hdlr.broker.Notify(sessionID) for _, chanInfo := range channels { memberIDs, _ := hdlr.params.Database. @@ -985,7 +1075,7 @@ func (hdlr *Handlers) broadcastNick( if !notified[mid] { notified[mid] = true - _ = hdlr.params.Database.EnqueueMessage( + _ = hdlr.params.Database.EnqueueToSession( request.Context(), mid, dbID, ) @@ -1077,13 +1167,13 @@ func (hdlr *Handlers) handleTopic( func (hdlr *Handlers) handleQuit( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, nick string, body json.RawMessage, ) { channels, _ := hdlr.params.Database. - GetAllChannelMembershipsForUser( - request.Context(), uid, + GetSessionChannels( + request.Context(), sessionID, ) notified := map[int64]bool{} @@ -1103,10 +1193,10 @@ func (hdlr *Handlers) handleQuit( ) for _, mid := range memberIDs { - if mid != uid && !notified[mid] { + if mid != sessionID && !notified[mid] { notified[mid] = true - _ = hdlr.params.Database.EnqueueMessage( + _ = hdlr.params.Database.EnqueueToSession( request.Context(), mid, dbID, ) @@ -1115,7 +1205,7 @@ func (hdlr *Handlers) handleQuit( } _ = hdlr.params.Database.PartChannel( - request.Context(), chanInfo.ID, uid, + request.Context(), chanInfo.ID, sessionID, ) _ = hdlr.params.Database.DeleteChannelIfEmpty( @@ -1123,8 +1213,8 @@ func (hdlr *Handlers) handleQuit( ) } - _ = hdlr.params.Database.DeleteUser( - request.Context(), uid, + _ = hdlr.params.Database.DeleteSession( + request.Context(), sessionID, ) hdlr.respondJSON(writer, request, @@ -1138,7 +1228,8 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { writer http.ResponseWriter, request *http.Request, ) { - uid, nick, ok := hdlr.requireAuth(writer, request) + sessionID, _, nick, ok := + hdlr.requireAuth(writer, request) if !ok { return } @@ -1155,7 +1246,7 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { } if !hdlr.canAccessHistory( - writer, request, uid, nick, target, + writer, request, sessionID, nick, target, ) { return } @@ -1198,12 +1289,12 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { func (hdlr *Handlers) canAccessHistory( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, nick, target string, ) bool { if strings.HasPrefix(target, "#") { return hdlr.canAccessChannelHistory( - writer, request, uid, target, + writer, request, sessionID, target, ) } @@ -1225,7 +1316,7 @@ func (hdlr *Handlers) canAccessHistory( func (hdlr *Handlers) canAccessChannelHistory( writer http.ResponseWriter, request *http.Request, - uid int64, + sessionID int64, target string, ) bool { chID, err := hdlr.params.Database.GetChannelByName( @@ -1242,7 +1333,7 @@ func (hdlr *Handlers) canAccessChannelHistory( } isMember, err := hdlr.params.Database.IsChannelMember( - request.Context(), chID, uid, + request.Context(), chID, sessionID, ) if err != nil { hdlr.log.Error( diff --git a/web/dist/app.js b/web/dist/app.js index 38642c2..0cd5a41 100644 --- a/web/dist/app.js +++ b/web/dist/app.js @@ -1,464 +1,2 @@ -(()=>{ -// Minimal Preact-like runtime using raw DOM for simplicity and zero build step. -// This replaces the previous Preact SPA with a vanilla JS implementation. - -const API = '/api/v1'; -let token = localStorage.getItem('chat_token'); -let myNick = ''; -let myUID = 0; -let lastQueueID = 0; -let pollController = null; -let channels = []; // [{name, topic}] -let activeTab = null; // '#channel' or 'nick' or 'server' -let messages = {}; // target -> [{command,from,to,body,ts,system}] -let unread = {}; // target -> count -let members = {}; // '#channel' -> [{nick}] - -function $(sel, parent) { return (parent||document).querySelector(sel); } -function $$(sel, parent) { return [...(parent||document).querySelectorAll(sel)]; } -function el(tag, attrs, ...children) { - const e = document.createElement(tag); - if (attrs) Object.entries(attrs).forEach(([k,v]) => { - if (k === 'class') e.className = v; - else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v); - else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v); - else e.setAttribute(k, v); - }); - children.flat(Infinity).forEach(c => { - if (c == null) return; - e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); - }); - return e; -} - -async function api(path, opts = {}) { - const headers = {'Content-Type': 'application/json', ...(opts.headers||{})}; - if (token) headers['Authorization'] = `Bearer ${token}`; - const resp = await fetch(API + path, {...opts, headers, signal: opts.signal}); - const data = await resp.json().catch(() => null); - if (!resp.ok) throw {status: resp.status, data}; - return data; -} - -function nickColor(nick) { - let h = 0; - for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h); - return `hsl(${Math.abs(h) % 360}, 70%, 65%)`; -} - -function formatTime(ts) { - return new Date(ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); -} - -function addMessage(target, msg) { - if (!messages[target]) messages[target] = []; - messages[target].push(msg); - if (messages[target].length > 500) messages[target] = messages[target].slice(-400); - if (target !== activeTab) { - unread[target] = (unread[target] || 0) + 1; - renderTabs(); - } - if (target === activeTab) renderMessages(); -} - -function addSystemMessage(target, text) { - addMessage(target, {command: 'SYSTEM', from: '*', body: [text], ts: new Date().toISOString(), system: true}); -} - -// --- Rendering --- - -function renderApp() { - const root = $('#root'); - root.innerHTML = ''; - root.appendChild(el('div', {class:'app'}, - el('div', {class:'tab-bar', id:'tabs'}), - el('div', {class:'content'}, - el('div', {class:'messages-pane'}, - el('div', {class:'messages', id:'msg-list'}), - el('div', {class:'input-bar', id:'input-bar'}, - el('input', {id:'msg-input', placeholder:'Message...', onKeydown: e => { if(e.key==='Enter') sendInput(); }}), - el('button', {onClick: sendInput}, 'Send') - ) - ), - el('div', {class:'user-list', id:'user-list'}) - ) - )); - renderTabs(); - renderMessages(); - renderMembers(); - $('#msg-input')?.focus(); -} - -function renderTabs() { - const container = $('#tabs'); - if (!container) return; - container.innerHTML = ''; - - // Server tab - const serverTab = el('div', {class: `tab ${activeTab === 'server' ? 'active' : ''}`, onClick: () => switchTab('server')}, 'Server'); - container.appendChild(serverTab); - - // Channel tabs - channels.forEach(ch => { - const badge = unread[ch.name] ? ` (${unread[ch.name]})` : ''; - const tab = el('div', {class: `tab ${activeTab === ch.name ? 'active' : ''}`}, - el('span', {onClick: () => switchTab(ch.name)}, ch.name + badge), - el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); partChannel(ch.name); }}, '×') - ); - container.appendChild(tab); - }); - - // DM tabs - Object.keys(messages).filter(k => !k.startsWith('#') && k !== 'server').forEach(nick => { - const badge = unread[nick] ? ` (${unread[nick]})` : ''; - const tab = el('div', {class: `tab ${activeTab === nick ? 'active' : ''}`}, - el('span', {onClick: () => switchTab(nick)}, '→' + nick + badge), - el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); delete messages[nick]; delete unread[nick]; if(activeTab===nick) switchTab('server'); else renderTabs(); }}, '×') - ); - container.appendChild(tab); - }); - - // Join input - const joinDiv = el('div', {class:'join-dialog'}, - el('input', {id:'join-input', placeholder:'#channel', onKeydown: e => { if(e.key==='Enter') joinFromInput(); }}), - el('button', {onClick: joinFromInput}, 'Join') - ); - container.appendChild(joinDiv); -} - -function renderMessages() { - const container = $('#msg-list'); - if (!container) return; - const msgs = messages[activeTab] || []; - container.innerHTML = ''; - msgs.forEach(m => { - const isSystem = m.system || ['JOIN','PART','QUIT','NICK','TOPIC'].includes(m.command); - const bodyText = Array.isArray(m.body) ? m.body.join('\n') : (m.body || ''); - - let displayText = bodyText; - if (m.command === 'JOIN') displayText = `${m.from} has joined ${m.to}`; - else if (m.command === 'PART') displayText = `${m.from} has left ${m.to}` + (bodyText ? ` (${bodyText})` : ''); - else if (m.command === 'QUIT') displayText = `${m.from} has quit` + (bodyText ? ` (${bodyText})` : ''); - else if (m.command === 'NICK') displayText = `${m.from} is now known as ${bodyText}`; - else if (m.command === 'TOPIC') displayText = `${m.from} set topic: ${bodyText}`; - - const msgEl = el('div', {class: `message ${isSystem ? 'system' : ''}`}, - el('span', {class:'timestamp'}, m.ts ? formatTime(m.ts) : ''), - isSystem - ? el('span', {class:'nick'}, '*') - : el('span', {class:'nick', style:{color: nickColor(m.from)}}, m.from), - el('span', {class:'content'}, displayText) - ); - container.appendChild(msgEl); - }); - container.scrollTop = container.scrollHeight; -} - -function renderMembers() { - const container = $('#user-list'); - if (!container) return; - if (!activeTab || !activeTab.startsWith('#')) { - container.innerHTML = ''; - return; - } - const mems = members[activeTab] || []; - container.innerHTML = ''; - container.appendChild(el('h3', null, `Users (${mems.length})`)); - mems.forEach(m => { - container.appendChild(el('div', {class:'user', style:{color: nickColor(m.nick)}, onClick: () => openDM(m.nick)}, m.nick)); - }); -} - -function switchTab(target) { - activeTab = target; - unread[target] = 0; - renderTabs(); - renderMessages(); - renderMembers(); - if (activeTab?.startsWith('#')) fetchMembers(activeTab); - $('#msg-input')?.focus(); -} - -// --- Actions --- - -async function joinFromInput() { - const input = $('#join-input'); - if (!input) return; - let name = input.value.trim(); - if (!name) return; - if (!name.startsWith('#')) name = '#' + name; - input.value = ''; - try { - await api('/messages', {method:'POST', body: JSON.stringify({command:'JOIN', to: name})}); - } catch(e) { - addSystemMessage('server', `Failed to join ${name}: ${e.data?.error || 'error'}`); - } -} - -async function partChannel(name) { - try { - await api('/messages', {method:'POST', body: JSON.stringify({command:'PART', to: name})}); - } catch(e) {} - channels = channels.filter(c => c.name !== name); - delete members[name]; - if (activeTab === name) switchTab('server'); - else renderTabs(); -} - -function openDM(nick) { - if (nick === myNick) return; - if (!messages[nick]) messages[nick] = []; - switchTab(nick); -} - -async function sendInput() { - const input = $('#msg-input'); - if (!input) return; - const text = input.value.trim(); - if (!text) return; - input.value = ''; - - if (text.startsWith('/')) { - const parts = text.split(' '); - const cmd = parts[0].toLowerCase(); - if (cmd === '/join' && parts[1]) { $('#join-input').value = parts[1]; joinFromInput(); return; } - if (cmd === '/part') { if(activeTab?.startsWith('#')) partChannel(activeTab); return; } - if (cmd === '/nick' && parts[1]) { - try { - await api('/messages', {method:'POST', body: JSON.stringify({command:'NICK', body:[parts[1]]})}); - } catch(e) { - addSystemMessage(activeTab||'server', `Nick change failed: ${e.data?.error || 'error'}`); - } - return; - } - if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) { - const target = parts[1]; - const msg = parts.slice(2).join(' '); - try { - await api('/messages', {method:'POST', body: JSON.stringify({command:'PRIVMSG', to: target, body:[msg]})}); - openDM(target); - } catch(e) { - addSystemMessage(activeTab||'server', `DM failed: ${e.data?.error || 'error'}`); - } - return; - } - if (cmd === '/quit') { - try { await api('/messages', {method:'POST', body: JSON.stringify({command:'QUIT'})}); } catch(e) {} - localStorage.removeItem('chat_token'); - location.reload(); - return; - } - addSystemMessage(activeTab||'server', `Unknown command: ${cmd}`); - return; - } - - if (!activeTab || activeTab === 'server') { - addSystemMessage('server', 'Select a channel or user to send messages'); - return; - } - - try { - await api('/messages', {method:'POST', body: JSON.stringify({command:'PRIVMSG', to: activeTab, body:[text]})}); - } catch(e) { - addSystemMessage(activeTab, `Send failed: ${e.data?.error || 'error'}`); - } -} - -async function fetchMembers(channel) { - try { - const name = channel.replace('#',''); - const data = await api(`/channels/${name}/members`); - members[channel] = data; - renderMembers(); - } catch(e) {} -} - -// --- Polling --- - -async function pollLoop() { - while (true) { - try { - if (pollController) pollController.abort(); - pollController = new AbortController(); - const data = await api(`/messages?after=${lastQueueID}&timeout=15`, {signal: pollController.signal}); - if (data.last_id) lastQueueID = data.last_id; - - for (const msg of (data.messages || [])) { - handleMessage(msg); - } - } catch(e) { - if (e instanceof DOMException && e.name === 'AbortError') continue; - if (e.status === 401) { - localStorage.removeItem('chat_token'); - location.reload(); - return; - } - await new Promise(r => setTimeout(r, 2000)); - } - } -} - -function handleMessage(msg) { - const body = Array.isArray(msg.body) ? msg.body : []; - const bodyText = body.join('\n'); - - switch (msg.command) { - case 'PRIVMSG': - case 'NOTICE': { - let target = msg.to; - // DM: if it's to me, show under sender's nick tab - if (!target.startsWith('#')) { - target = msg.from === myNick ? msg.to : msg.from; - if (!messages[target]) messages[target] = []; - } - addMessage(target, msg); - break; - } - case 'JOIN': { - addMessage(msg.to, msg); - if (msg.from === myNick) { - // We joined a channel - if (!channels.find(c => c.name === msg.to)) { - channels.push({name: msg.to, topic: ''}); - } - switchTab(msg.to); - fetchMembers(msg.to); - } else if (activeTab === msg.to) { - fetchMembers(msg.to); - } - break; - } - case 'PART': { - addMessage(msg.to, msg); - if (msg.from === myNick) { - channels = channels.filter(c => c.name !== msg.to); - if (activeTab === msg.to) switchTab('server'); - else renderTabs(); - } else if (activeTab === msg.to) { - fetchMembers(msg.to); - } - break; - } - case 'QUIT': { - // Show in all channels where this user might be - channels.forEach(ch => { - addMessage(ch.name, msg); - }); - break; - } - case 'NICK': { - const newNick = body[0] || ''; - if (msg.from === myNick) { - myNick = newNick; - addSystemMessage(activeTab || 'server', `You are now known as ${newNick}`); - } else { - channels.forEach(ch => { - addMessage(ch.name, msg); - }); - } - break; - } - case 'TOPIC': { - addMessage(msg.to, msg); - const ch = channels.find(c => c.name === msg.to); - if (ch) ch.topic = bodyText; - break; - } - default: - addSystemMessage('server', `[${msg.command}] ${bodyText}`); - } -} - -// --- Login --- - -function renderLogin() { - const root = $('#root'); - root.innerHTML = ''; - - let serverName = 'Chat'; - let motd = ''; - - api('/server').then(data => { - if (data.name) { serverName = data.name; $('h1', root).textContent = serverName; } - if (data.motd) { motd = data.motd; const m = $('.motd', root); if(m) m.textContent = motd; } - }).catch(() => {}); - - const form = el('form', {class:'login-screen', onSubmit: async (e) => { - e.preventDefault(); - const nick = $('input', form).value.trim(); - if (!nick) return; - const errEl = $('.error', form); - if (errEl) errEl.textContent = ''; - try { - const data = await api('/session', {method:'POST', body: JSON.stringify({nick})}); - token = data.token; - myNick = data.nick; - myUID = data.id; - localStorage.setItem('chat_token', token); - startApp(); - } catch(err) { - const errEl = $('.error', form) || form.appendChild(el('div', {class:'error'})); - errEl.textContent = err.data?.error || 'Connection failed'; - } - }}, - el('h1', null, serverName), - motd ? el('div', {class:'motd'}, motd) : null, - el('input', {type:'text', placeholder:'Choose a nickname...', maxLength:'32', autofocus:'true'}), - el('button', {type:'submit'}, 'Connect'), - el('div', {class:'error'}) - ); - root.appendChild(form); - $('input', form)?.focus(); -} - -async function startApp() { - messages = {server: []}; - unread = {}; - channels = []; - activeTab = 'server'; - lastQueueID = 0; - - addSystemMessage('server', `Connected as ${myNick}`); - - // Fetch server info - try { - const info = await api('/server'); - if (info.motd) addSystemMessage('server', `MOTD: ${info.motd}`); - } catch(e) {} - - // Fetch current state (channels we're already in) - try { - const state = await api('/state'); - myNick = state.nick; - myUID = state.id; - if (state.channels) { - state.channels.forEach(ch => { - channels.push({name: ch.name, topic: ch.topic}); - if (!messages[ch.name]) messages[ch.name] = []; - }); - if (channels.length > 0) switchTab(channels[0].name); - } - } catch(e) {} - - renderApp(); - pollLoop(); -} - -// --- Init --- - -if (token) { - // Try to resume session - api('/state').then(data => { - myNick = data.nick; - myUID = data.id; - startApp(); - }).catch(() => { - localStorage.removeItem('chat_token'); - token = null; - renderLogin(); - }); -} else { - renderLogin(); -} - -})(); +(()=>{var Y=(a=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(a,{get:(f,p)=>(typeof require<"u"?require:f)[p]}):a)(function(a){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+a+'" is not supported')});var o=Y("preact"),s=Y("preact/hooks"),se="/api/v1",oe=15,ae=3e3,ce=1e4;function u(a,f={}){let p=localStorage.getItem("chat_token"),y={"Content-Type":"application/json",...f.headers||{}};p&&(y.Authorization=`Bearer ${p}`);let{signal:i,...h}=f;return fetch(se+a,{...h,headers:y,signal:i}).then(async d=>{let v=await d.json().catch(()=>null);if(!d.ok)throw{status:d.status,data:v};return v})}function X(a){return new Date(a).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}function Z(a){let f=0;for(let y=0;y{u("/server").then(m=>{m.name&&$(m.name),m.motd&&d(m.motd)}).catch(()=>{}),localStorage.getItem("chat_token")&&u("/state").then(m=>a(m.nick)).catch(()=>localStorage.removeItem("chat_token")),g.current?.focus()},[]),(0,o.h)("div",{class:"login-screen"},(0,o.h)("h1",null,v),h&&(0,o.h)("div",{class:"motd"},h),(0,o.h)("form",{onSubmit:async T=>{T.preventDefault(),i("");try{let m=await u("/session",{method:"POST",body:JSON.stringify({nick:f.trim()})});localStorage.setItem("chat_token",m.token),a(m.nick)}catch(m){i(m.data?.error||"Connection failed")}}},(0,o.h)("input",{ref:g,type:"text",placeholder:"Choose a nickname...",value:f,onInput:T=>p(T.target.value),maxLength:32,autoFocus:!0}),(0,o.h)("button",{type:"submit"},"Connect")),y&&(0,o.h)("div",{class:"error"},y))}function de({msg:a}){return a.system?(0,o.h)("div",{class:"message system"},(0,o.h)("span",{class:"timestamp"},X(a.ts)),(0,o.h)("span",{class:"content"},a.text)):(0,o.h)("div",{class:"message"},(0,o.h)("span",{class:"timestamp"},X(a.ts)),(0,o.h)("span",{class:"nick",style:{color:Z(a.from)}},a.from),(0,o.h)("span",{class:"content"},a.text))}function le(){let[a,f]=(0,s.useState)(!1),[p,y]=(0,s.useState)(""),[i,h]=(0,s.useState)([{type:"server",name:"Server"}]),[d,v]=(0,s.useState)(0),[$,g]=(0,s.useState)({Server:[]}),[N,T]=(0,s.useState)({}),[m,x]=(0,s.useState)({}),[J,_]=(0,s.useState)({}),[j,L]=(0,s.useState)(""),[C,D]=(0,s.useState)(""),[ee,U]=(0,s.useState)(!0),M=(0,s.useRef)(0),V=(0,s.useRef)(new Set),W=(0,s.useRef)(null),w=(0,s.useRef)(i),K=(0,s.useRef)(d),R=(0,s.useRef)(p),B=(0,s.useRef)(),F=(0,s.useRef)();(0,s.useEffect)(()=>{w.current=i},[i]),(0,s.useEffect)(()=>{K.current=d},[d]),(0,s.useEffect)(()=>{R.current=p},[p]),(0,s.useEffect)(()=>{let e=i.filter(t=>t.type==="channel").map(t=>t.name);localStorage.setItem("chat_channels",JSON.stringify(e))},[i]),(0,s.useEffect)(()=>{let e=i[d];e&&_(t=>({...t,[e.name]:0}))},[d,i]);let b=(0,s.useCallback)((e,t)=>{if(t.id&&V.current.has(t.id))return;t.id&&V.current.add(t.id),g(r=>({...r,[e]:[...r[e]||[],t]}));let n=w.current[K.current];(!n||n.name!==e)&&_(r=>({...r,[e]:(r[e]||0)+1}))},[]),S=(0,s.useCallback)((e,t)=>{g(n=>({...n,[e]:[...n[e]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:t,system:!0}]}))},[]),I=(0,s.useCallback)(e=>{let t=e.replace("#","");u(`/channels/${t}/members`).then(n=>{T(r=>({...r,[e]:n}))}).catch(()=>{})},[]),E=(0,s.useCallback)(e=>{let t=Array.isArray(e.body)?e.body.join(` +`):"",n={id:e.id,ts:e.ts,from:e.from,to:e.to,command:e.command};switch(e.command){case"PRIVMSG":case"NOTICE":{let r={...n,text:t,system:!1},c=e.to;if(c&&c.startsWith("#"))b(c,r);else{let l=e.from===R.current?e.to:e.from;h(O=>O.find(Q=>Q.type==="dm"&&Q.name===l)?O:[...O,{type:"dm",name:l}]),b(l,r)}break}case"JOIN":{let r=`${e.from} has joined ${e.to}`;e.to&&b(e.to,{...n,text:r,system:!0}),e.to&&e.to.startsWith("#")&&I(e.to);break}case"PART":{let r=t?": "+t:"",c=`${e.from} has left ${e.to}${r}`;e.to&&b(e.to,{...n,text:c,system:!0}),e.to&&e.to.startsWith("#")&&I(e.to);break}case"QUIT":{let r=t?": "+t:"",c=`${e.from} has quit${r}`;w.current.forEach(l=>{l.type==="channel"&&b(l.name,{...n,text:c,system:!0})});break}case"NICK":{let r=Array.isArray(e.body)?e.body[0]:t,c=`${e.from} is now known as ${r}`;w.current.forEach(l=>{l.type==="channel"&&b(l.name,{...n,text:c,system:!0})}),e.from===R.current&&r&&y(r),w.current.forEach(l=>{l.type==="channel"&&I(l.name)});break}case"TOPIC":{let r=`${e.from} set the topic: ${t}`;e.to&&(b(e.to,{...n,text:r,system:!0}),x(c=>({...c,[e.to]:t})));break}case"375":case"372":case"376":b("Server",{...n,text:t,system:!0});break;default:b("Server",{...n,text:t||e.command,system:!0})}},[b,I]);(0,s.useEffect)(()=>{if(!a)return;let e=!0;return(async()=>{for(;e;)try{let n=new AbortController;W.current=n;let r=await u(`/messages?after=${M.current}&timeout=${oe}`,{signal:n.signal});if(!e)break;if(U(!0),r.messages)for(let c of r.messages)E(c);r.last_id>M.current&&(M.current=r.last_id)}catch(n){if(!e)break;if(n.name==="AbortError")continue;U(!1),await new Promise(r=>setTimeout(r,ae))}})(),()=>{e=!1,W.current?.abort()}},[a,E]),(0,s.useEffect)(()=>{if(!a)return;let e=i[d];if(!e||e.type!=="channel")return;I(e.name);let t=setInterval(()=>I(e.name),ce);return()=>clearInterval(t)},[a,d,i,I]),(0,s.useEffect)(()=>{B.current?.scrollIntoView({behavior:"smooth"})},[$,d]),(0,s.useEffect)(()=>{F.current?.focus()},[d]),(0,s.useEffect)(()=>{if(!a)return;let e=i[d];!e||e.type!=="channel"||u("/channels").then(t=>{let n=t.find(r=>r.name===e.name);n&&n.topic&&x(r=>({...r,[e.name]:n.topic}))}).catch(()=>{})},[a,d,i]);let te=(0,s.useCallback)(async e=>{y(e),f(!0),S("Server",`Connected as ${e}`);let t=JSON.parse(localStorage.getItem("chat_channels")||"[]");for(let n of t)try{await u("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:n})}),h(r=>r.find(c=>c.type==="channel"&&c.name===n)?r:[...r,{type:"channel",name:n}])}catch{}},[S]),P=async e=>{if(e){e=e.trim(),e.startsWith("#")||(e="#"+e);try{await u("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:e})}),h(t=>t.find(n=>n.type==="channel"&&n.name===e)?t:[...t,{type:"channel",name:e}]),v(i.length);try{let t=await u(`/history?target=${encodeURIComponent(e)}&limit=50`);if(Array.isArray(t))for(let n of t)E(n)}catch{}D("")}catch(t){S("Server",`Failed to join ${e}: ${t.data?.error||"error"}`)}}},G=async e=>{try{await u("/messages",{method:"POST",body:JSON.stringify({command:"PART",to:e})})}catch{}h(t=>t.filter(n=>!(n.type==="channel"&&n.name===e))),v(0)},ne=e=>{let t=i[e];t.type==="channel"?G(t.name):t.type==="dm"&&(h(n=>n.filter((r,c)=>c!==e)),d>=e&&v(Math.max(0,d-1)))},q=e=>{h(n=>n.find(r=>r.type==="dm"&&r.name===e)?n:[...n,{type:"dm",name:e}]);let t=i.findIndex(n=>n.type==="dm"&&n.name===e);v(t>=0?t:i.length)},z=async()=>{let e=j.trim();if(!e)return;L("");let t=i[d];if(!(!t||t.type==="server")){if(e.startsWith("/")){let n=e.split(" "),r=n[0].toLowerCase();if(r==="/join"&&n[1]){P(n[1]);return}if(r==="/part"){t.type==="channel"&&G(t.name);return}if(r==="/msg"&&n[1]&&n.slice(2).join(" ")){let c=n[1],l=n.slice(2).join(" ");try{await u("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:c,body:[l]})}),q(c)}catch(O){S("Server",`DM failed: ${O.data?.error||"error"}`)}return}if(r==="/nick"&&n[1]){try{await u("/messages",{method:"POST",body:JSON.stringify({command:"NICK",body:[n[1]]})})}catch(c){S("Server",`Nick change failed: ${c.data?.error||"error"}`)}return}if(r==="/topic"&&t.type==="channel"){let c=n.slice(1).join(" ");try{await u("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:t.name,body:[c]})})}catch(l){S("Server",`Topic failed: ${l.data?.error||"error"}`)}return}S("Server",`Unknown command: ${r}`);return}try{await u("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:t.name,body:[e]})})}catch(n){S(t.name,`Send failed: ${n.data?.error||"error"}`)}}};if(!a)return(0,o.h)(ie,{onLogin:te});let k=i[d]||i[0],re=$[k.name]||[],H=N[k.name]||[],A=m[k.name]||"";return(0,o.h)("div",{class:"app"},(0,o.h)("div",{class:"tab-bar"},!ee&&(0,o.h)("div",{class:"connection-status"},"\u26A0 Reconnecting..."),i.map((e,t)=>(0,o.h)("div",{class:`tab ${t===d?"active":""}`,onClick:()=>v(t)},e.type==="dm"?`\u2192${e.name}`:e.name,J[e.name]>0&&t!==d&&(0,o.h)("span",{class:"unread-badge"},J[e.name]),e.type!=="server"&&(0,o.h)("span",{class:"close-btn",onClick:n=>{n.stopPropagation(),ne(t)}},"\xD7"))),(0,o.h)("div",{class:"join-dialog"},(0,o.h)("input",{placeholder:"#channel",value:C,onInput:e=>D(e.target.value),onKeyDown:e=>e.key==="Enter"&&P(C)}),(0,o.h)("button",{onClick:()=>P(C)},"Join"))),k.type==="channel"&&A&&(0,o.h)("div",{class:"topic-bar",title:A},A),(0,o.h)("div",{class:"content"},(0,o.h)("div",{class:"messages-pane"},(0,o.h)("div",{class:k.type==="server"?"server-messages":"messages"},re.map(e=>(0,o.h)(de,{msg:e})),(0,o.h)("div",{ref:B})),k.type!=="server"&&(0,o.h)("div",{class:"input-bar"},(0,o.h)("input",{ref:F,placeholder:`Message ${k.name}...`,value:j,onInput:e=>L(e.target.value),onKeyDown:e=>e.key==="Enter"&&z()}),(0,o.h)("button",{onClick:z},"Send"))),k.type==="channel"&&(0,o.h)("div",{class:"user-list"},(0,o.h)("h3",null,"Users (",H.length,")"),H.map(e=>(0,o.h)("div",{class:"user",onClick:()=>q(e.nick),style:{color:Z(e.nick)}},e.nick)))))}(0,o.render)((0,o.h)(le,null),document.getElementById("root"));})(); diff --git a/web/dist/style.css b/web/dist/style.css index 929111c..ecf3ebb 100644 --- a/web/dist/style.css +++ b/web/dist/style.css @@ -14,6 +14,9 @@ --tab-active: #e94560; --tab-bg: #16213e; --tab-hover: #1a1a3e; + --topic-bg: #121a30; + --unread-bg: #e94560; + --warn: #f0ad4e; } html, body, #root { @@ -86,6 +89,7 @@ html, body, #root { border-bottom: 1px solid var(--border); overflow-x: auto; flex-shrink: 0; + align-items: center; } .tab { @@ -95,6 +99,7 @@ html, body, #root { white-space: nowrap; color: var(--text-muted); user-select: none; + position: relative; } .tab:hover { @@ -116,6 +121,43 @@ html, body, #root { color: var(--accent); } +.tab .unread-badge { + display: inline-block; + background: var(--unread-bg); + color: white; + font-size: 10px; + font-weight: bold; + padding: 1px 5px; + border-radius: 8px; + margin-left: 6px; + min-width: 16px; + text-align: center; +} + +/* Connection status */ +.connection-status { + padding: 4px 12px; + background: var(--warn); + color: #1a1a2e; + font-size: 12px; + font-weight: bold; + white-space: nowrap; + flex-shrink: 0; +} + +/* Topic bar */ +.topic-bar { + padding: 6px 12px; + background: var(--topic-bg); + border-bottom: 1px solid var(--border); + color: var(--text-muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; +} + /* Content area */ .content { display: flex; @@ -243,6 +285,7 @@ html, body, #root { gap: 8px; background: var(--bg-secondary); border-bottom: 1px solid var(--border); + margin-left: auto; } .join-dialog input { diff --git a/web/src/app.jsx b/web/src/app.jsx index 846dd51..41ed141 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -1,13 +1,17 @@ -import { h, render, Component } from 'preact'; +import { h, render } from 'preact'; import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; const API = '/api/v1'; +const POLL_TIMEOUT = 15; +const RECONNECT_DELAY = 3000; +const MEMBER_REFRESH_INTERVAL = 10000; function api(path, opts = {}) { const token = localStorage.getItem('chat_token'); const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; if (token) headers['Authorization'] = `Bearer ${token}`; - return fetch(API + path, { ...opts, headers }).then(async r => { + const { signal, ...rest } = opts; + return fetch(API + path, { ...rest, headers, signal }).then(async r => { const data = await r.json().catch(() => null); if (!r.ok) throw { status: r.status, data }; return data; @@ -19,7 +23,6 @@ function formatTime(ts) { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } -// Nick color hashing function nickColor(nick) { let h = 0; for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h); @@ -39,10 +42,9 @@ function LoginScreen({ onLogin }) { if (s.name) setServerName(s.name); if (s.motd) setMotd(s.motd); }).catch(() => {}); - // Check for saved token const saved = localStorage.getItem('chat_token'); if (saved) { - api('/state').then(u => onLogin(u.nick, saved)).catch(() => localStorage.removeItem('chat_token')); + api('/state').then(u => onLogin(u.nick)).catch(() => localStorage.removeItem('chat_token')); } inputRef.current?.focus(); }, []); @@ -56,7 +58,7 @@ function LoginScreen({ onLogin }) { body: JSON.stringify({ nick: nick.trim() }) }); localStorage.setItem('chat_token', res.token); - onLogin(res.nick, res.token); + onLogin(res.nick); } catch (err) { setError(err.data?.error || 'Connection failed'); } @@ -84,11 +86,19 @@ function LoginScreen({ onLogin }) { } function Message({ msg }) { + if (msg.system) { + return ( +
+ {formatTime(msg.ts)} + {msg.text} +
+ ); + } return ( -
- {formatTime(msg.createdAt)} - {msg.nick} - {msg.content} +
+ {formatTime(msg.ts)} + {msg.from} + {msg.text}
); } @@ -98,93 +108,194 @@ function App() { const [nick, setNick] = useState(''); const [tabs, setTabs] = useState([{ type: 'server', name: 'Server' }]); const [activeTab, setActiveTab] = useState(0); - const [messages, setMessages] = useState({ server: [] }); // keyed by tab name - const [members, setMembers] = useState({}); // keyed by channel name + const [messages, setMessages] = useState({ Server: [] }); + const [members, setMembers] = useState({}); + const [topics, setTopics] = useState({}); + const [unread, setUnread] = useState({}); const [input, setInput] = useState(''); const [joinInput, setJoinInput] = useState(''); - const [lastMsgId, setLastMsgId] = useState(0); + const [connected, setConnected] = useState(true); + + const lastIdRef = useRef(0); + const seenIdsRef = useRef(new Set()); + const pollAbortRef = useRef(null); + const tabsRef = useRef(tabs); + const activeTabRef = useRef(activeTab); + const nickRef = useRef(nick); const messagesEndRef = useRef(); const inputRef = useRef(); - const pollRef = useRef(); + + useEffect(() => { tabsRef.current = tabs; }, [tabs]); + useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]); + useEffect(() => { nickRef.current = nick; }, [nick]); + + // Persist joined channels + useEffect(() => { + const channels = tabs.filter(t => t.type === 'channel').map(t => t.name); + localStorage.setItem('chat_channels', JSON.stringify(channels)); + }, [tabs]); + + // Clear unread on tab switch + useEffect(() => { + const tab = tabs[activeTab]; + if (tab) setUnread(prev => ({ ...prev, [tab.name]: 0 })); + }, [activeTab, tabs]); const addMessage = useCallback((tabName, msg) => { + if (msg.id && seenIdsRef.current.has(msg.id)) return; + if (msg.id) seenIdsRef.current.add(msg.id); setMessages(prev => ({ ...prev, [tabName]: [...(prev[tabName] || []), msg] })); + const currentTab = tabsRef.current[activeTabRef.current]; + if (!currentTab || currentTab.name !== tabName) { + setUnread(prev => ({ ...prev, [tabName]: (prev[tabName] || 0) + 1 })); + } }, []); const addSystemMessage = useCallback((tabName, text) => { - addMessage(tabName, { - id: Date.now(), - nick: '*', - content: text, - createdAt: new Date().toISOString(), - system: true - }); - }, [addMessage]); + setMessages(prev => ({ + ...prev, + [tabName]: [...(prev[tabName] || []), { + id: 'sys-' + Date.now() + '-' + Math.random(), + ts: new Date().toISOString(), + text, + system: true + }] + })); + }, []); - const onLogin = useCallback((userNick, token) => { - setNick(userNick); - setLoggedIn(true); - addSystemMessage('server', `Connected as ${userNick}`); - // Fetch server info - api('/server').then(s => { - if (s.motd) addSystemMessage('server', `MOTD: ${s.motd}`); + const refreshMembers = useCallback((channel) => { + const chName = channel.replace('#', ''); + api(`/channels/${chName}/members`).then(m => { + setMembers(prev => ({ ...prev, [channel]: m })); }).catch(() => {}); - }, [addSystemMessage]); + }, []); - // Poll for new messages + const processMessage = useCallback((msg) => { + const body = Array.isArray(msg.body) ? msg.body.join('\n') : ''; + const base = { id: msg.id, ts: msg.ts, from: msg.from, to: msg.to, command: msg.command }; + + switch (msg.command) { + case 'PRIVMSG': + case 'NOTICE': { + const parsed = { ...base, text: body, system: false }; + const target = msg.to; + if (target && target.startsWith('#')) { + addMessage(target, parsed); + } else { + const dmPeer = msg.from === nickRef.current ? msg.to : msg.from; + setTabs(prev => { + if (!prev.find(t => t.type === 'dm' && t.name === dmPeer)) { + return [...prev, { type: 'dm', name: dmPeer }]; + } + return prev; + }); + addMessage(dmPeer, parsed); + } + break; + } + case 'JOIN': { + const text = `${msg.from} has joined ${msg.to}`; + if (msg.to) addMessage(msg.to, { ...base, text, system: true }); + if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to); + break; + } + case 'PART': { + const reason = body ? ': ' + body : ''; + const text = `${msg.from} has left ${msg.to}${reason}`; + if (msg.to) addMessage(msg.to, { ...base, text, system: true }); + if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to); + break; + } + case 'QUIT': { + const reason = body ? ': ' + body : ''; + const text = `${msg.from} has quit${reason}`; + tabsRef.current.forEach(tab => { + if (tab.type === 'channel') { + addMessage(tab.name, { ...base, text, system: true }); + } + }); + break; + } + case 'NICK': { + const newNick = Array.isArray(msg.body) ? msg.body[0] : body; + const text = `${msg.from} is now known as ${newNick}`; + tabsRef.current.forEach(tab => { + if (tab.type === 'channel') { + addMessage(tab.name, { ...base, text, system: true }); + } + }); + if (msg.from === nickRef.current && newNick) setNick(newNick); + // Refresh members in all channels + tabsRef.current.forEach(tab => { + if (tab.type === 'channel') refreshMembers(tab.name); + }); + break; + } + case 'TOPIC': { + const text = `${msg.from} set the topic: ${body}`; + if (msg.to) { + addMessage(msg.to, { ...base, text, system: true }); + setTopics(prev => ({ ...prev, [msg.to]: body })); + } + break; + } + case '375': + case '372': + case '376': + addMessage('Server', { ...base, text: body, system: true }); + break; + default: + addMessage('Server', { ...base, text: body || msg.command, system: true }); + } + }, [addMessage, refreshMembers]); + + // Long-poll loop useEffect(() => { if (!loggedIn) return; let alive = true; + const poll = async () => { - try { - const msgs = await api(`/messages?after=${lastMsgId}`); - if (!alive) return; - let maxId = lastMsgId; - for (const msg of msgs) { - if (msg.id > maxId) maxId = msg.id; - if (msg.isDm) { - const dmTab = msg.nick === nick ? msg.dmTarget : msg.nick; - // Ensure DM tab exists - setTabs(prev => { - if (!prev.find(t => t.type === 'dm' && t.name === dmTab)) { - return [...prev, { type: 'dm', name: dmTab }]; - } - return prev; - }); - addMessage(dmTab, msg); - } else if (msg.channel) { - addMessage(msg.channel, msg); + while (alive) { + try { + const controller = new AbortController(); + pollAbortRef.current = controller; + const result = await api( + `/messages?after=${lastIdRef.current}&timeout=${POLL_TIMEOUT}`, + { signal: controller.signal } + ); + if (!alive) break; + setConnected(true); + if (result.messages) { + for (const m of result.messages) processMessage(m); } + if (result.last_id > lastIdRef.current) { + lastIdRef.current = result.last_id; + } + } catch (err) { + if (!alive) break; + if (err.name === 'AbortError') continue; + setConnected(false); + await new Promise(r => setTimeout(r, RECONNECT_DELAY)); } - if (maxId > lastMsgId) setLastMsgId(maxId); - } catch (err) { - // silent } }; - pollRef.current = setInterval(poll, 1500); - poll(); - return () => { alive = false; clearInterval(pollRef.current); }; - }, [loggedIn, lastMsgId, nick, addMessage]); - // Fetch members for active channel tab + poll(); + return () => { alive = false; pollAbortRef.current?.abort(); }; + }, [loggedIn, processMessage]); + + // Refresh members for active channel useEffect(() => { if (!loggedIn) return; const tab = tabs[activeTab]; if (!tab || tab.type !== 'channel') return; - const chName = tab.name.replace('#', ''); - api(`/channels/${chName}/members`).then(m => { - setMembers(prev => ({ ...prev, [tab.name]: m })); - }).catch(() => {}); - const iv = setInterval(() => { - api(`/channels/${chName}/members`).then(m => { - setMembers(prev => ({ ...prev, [tab.name]: m })); - }).catch(() => {}); - }, 5000); + refreshMembers(tab.name); + const iv = setInterval(() => refreshMembers(tab.name), MEMBER_REFRESH_INTERVAL); return () => clearInterval(iv); - }, [loggedIn, activeTab, tabs]); + }, [loggedIn, activeTab, tabs, refreshMembers]); // Auto-scroll useEffect(() => { @@ -192,9 +303,37 @@ function App() { }, [messages, activeTab]); // Focus input on tab change + useEffect(() => { inputRef.current?.focus(); }, [activeTab]); + + // Fetch topic for active channel useEffect(() => { - inputRef.current?.focus(); - }, [activeTab]); + if (!loggedIn) return; + const tab = tabs[activeTab]; + if (!tab || tab.type !== 'channel') return; + api('/channels').then(channels => { + const ch = channels.find(c => c.name === tab.name); + if (ch && ch.topic) setTopics(prev => ({ ...prev, [tab.name]: ch.topic })); + }).catch(() => {}); + }, [loggedIn, activeTab, tabs]); + + const onLogin = useCallback(async (userNick) => { + setNick(userNick); + setLoggedIn(true); + addSystemMessage('Server', `Connected as ${userNick}`); + // Auto-rejoin saved channels + const saved = JSON.parse(localStorage.getItem('chat_channels') || '[]'); + for (const ch of saved) { + try { + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) }); + setTabs(prev => { + if (prev.find(t => t.type === 'channel' && t.name === ch)) return prev; + return [...prev, { type: 'channel', name: ch }]; + }); + } catch (e) { + // Channel may not exist anymore + } + } + }, [addSystemMessage]); const joinChannel = async (name) => { if (!name) return; @@ -206,22 +345,29 @@ function App() { if (prev.find(t => t.type === 'channel' && t.name === name)) return prev; return [...prev, { type: 'channel', name }]; }); - setActiveTab(tabs.length); // switch to new tab - addSystemMessage(name, `Joined ${name}`); + setActiveTab(tabs.length); + // Load history + try { + const hist = await api(`/history?target=${encodeURIComponent(name)}&limit=50`); + if (Array.isArray(hist)) { + for (const m of hist) processMessage(m); + } + } catch (e) { + // History may be empty + } setJoinInput(''); } catch (err) { - addSystemMessage('server', `Failed to join ${name}: ${err.data?.error || 'error'}`); + addSystemMessage('Server', `Failed to join ${name}: ${err.data?.error || 'error'}`); } }; const partChannel = async (name) => { try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PART', to: name }) }); - } catch (err) { /* ignore */ } - setTabs(prev => { - const next = prev.filter(t => !(t.type === 'channel' && t.name === name)); - return next; - }); + } catch (e) { + // Ignore + } + setTabs(prev => prev.filter(t => !(t.type === 'channel' && t.name === name))); setActiveTab(0); }; @@ -240,7 +386,8 @@ function App() { if (prev.find(t => t.type === 'dm' && t.name === targetNick)) return prev; return [...prev, { type: 'dm', name: targetNick }]; }); - setActiveTab(tabs.findIndex(t => t.type === 'dm' && t.name === targetNick) || tabs.length); + const idx = tabs.findIndex(t => t.type === 'dm' && t.name === targetNick); + setActiveTab(idx >= 0 ? idx : tabs.length); }; const sendMessage = async () => { @@ -250,46 +397,45 @@ function App() { const tab = tabs[activeTab]; if (!tab || tab.type === 'server') return; - // Handle /commands if (text.startsWith('/')) { const parts = text.split(' '); const cmd = parts[0].toLowerCase(); - if (cmd === '/join' && parts[1]) { - joinChannel(parts[1]); - return; - } - if (cmd === '/part') { - if (tab.type === 'channel') partChannel(tab.name); - return; - } + if (cmd === '/join' && parts[1]) { joinChannel(parts[1]); return; } + if (cmd === '/part') { if (tab.type === 'channel') partChannel(tab.name); return; } if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) { const target = parts[1]; - const msg = parts.slice(2).join(' '); + const body = parts.slice(2).join(' '); try { - await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [msg] }) }); + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [body] }) }); openDM(target); } catch (err) { - addSystemMessage('server', `Failed to send DM: ${err.data?.error || 'error'}`); + addSystemMessage('Server', `DM failed: ${err.data?.error || 'error'}`); } return; } if (cmd === '/nick' && parts[1]) { try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'NICK', body: [parts[1]] }) }); - setNick(parts[1]); - addSystemMessage('server', `Nick changed to ${parts[1]}`); } catch (err) { - addSystemMessage('server', `Nick change failed: ${err.data?.error || 'error'}`); + addSystemMessage('Server', `Nick change failed: ${err.data?.error || 'error'}`); } return; } - addSystemMessage('server', `Unknown command: ${cmd}`); + if (cmd === '/topic' && tab.type === 'channel') { + const topicText = parts.slice(1).join(' '); + try { + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'TOPIC', to: tab.name, body: [topicText] }) }); + } catch (err) { + addSystemMessage('Server', `Topic failed: ${err.data?.error || 'error'}`); + } + return; + } + addSystemMessage('Server', `Unknown command: ${cmd}`); return; } - const to = tab.type === 'channel' ? tab.name : tab.name; try { - await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to, body: [text] }) }); + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: tab.name, body: [text] }) }); } catch (err) { addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); } @@ -300,16 +446,21 @@ function App() { const currentTab = tabs[activeTab] || tabs[0]; const currentMessages = messages[currentTab.name] || []; const currentMembers = members[currentTab.name] || []; + const currentTopic = topics[currentTab.name] || ''; return (
+ {!connected &&
⚠ Reconnecting...
} {tabs.map((tab, i) => (
setActiveTab(i)} > {tab.type === 'dm' ? `→${tab.name}` : tab.name} + {unread[tab.name] > 0 && i !== activeTab && ( + {unread[tab.name]} + )} {tab.type !== 'server' && ( { e.stopPropagation(); closeTab(i); }}>× )} @@ -326,30 +477,27 @@ function App() {
+ {currentTab.type === 'channel' && currentTopic && ( +
{currentTopic}
+ )} +
- {currentTab.type === 'server' ? ( -
- {currentMessages.map(m => )} -
+
+ {currentMessages.map(m => )} +
+
+ {currentTab.type !== 'server' && ( +
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && sendMessage()} + /> +
- ) : ( - <> -
- {currentMessages.map(m => )} -
-
-
- setInput(e.target.value)} - onKeyDown={e => e.key === 'Enter' && sendMessage()} - /> - -
- )}
diff --git a/web/src/style.css b/web/src/style.css index 929111c..ecf3ebb 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -14,6 +14,9 @@ --tab-active: #e94560; --tab-bg: #16213e; --tab-hover: #1a1a3e; + --topic-bg: #121a30; + --unread-bg: #e94560; + --warn: #f0ad4e; } html, body, #root { @@ -86,6 +89,7 @@ html, body, #root { border-bottom: 1px solid var(--border); overflow-x: auto; flex-shrink: 0; + align-items: center; } .tab { @@ -95,6 +99,7 @@ html, body, #root { white-space: nowrap; color: var(--text-muted); user-select: none; + position: relative; } .tab:hover { @@ -116,6 +121,43 @@ html, body, #root { color: var(--accent); } +.tab .unread-badge { + display: inline-block; + background: var(--unread-bg); + color: white; + font-size: 10px; + font-weight: bold; + padding: 1px 5px; + border-radius: 8px; + margin-left: 6px; + min-width: 16px; + text-align: center; +} + +/* Connection status */ +.connection-status { + padding: 4px 12px; + background: var(--warn); + color: #1a1a2e; + font-size: 12px; + font-weight: bold; + white-space: nowrap; + flex-shrink: 0; +} + +/* Topic bar */ +.topic-bar { + padding: 6px 12px; + background: var(--topic-bg); + border-bottom: 1px solid var(--border); + color: var(--text-muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; +} + /* Content area */ .content { display: flex; @@ -243,6 +285,7 @@ html, body, #root { gap: 8px; background: var(--bg-secondary); border-bottom: 1px solid var(--border); + margin-left: auto; } .join-dialog input { -- 2.49.1