feat: MVP two-user chat via embedded SPA (#9)
All checks were successful
check / check (push) Successful in 1m51s

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
This commit is contained in:
clawbot
2026-02-27 02:21:48 -08:00
parent 2d08a8476f
commit 32419fb1f7
8 changed files with 921 additions and 880 deletions

View File

@@ -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,
)
}