feat: TUI room creation + daemon-side room persistence

/room <name> in the TUI sends create_room to the daemon, which persists
it in the rooms SQLite table and echoes room_created back. state_snapshot
now includes persisted rooms so they survive reconnects. Tab navigation
and room rendering pick them up automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-26 22:26:12 +02:00
parent 1308082c7b
commit 340735f992
4 changed files with 73 additions and 1 deletions

View File

@@ -251,8 +251,15 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
m.peers[p.ID] = p.Alias
m.peerOrder = append(m.peerOrder, p.ID)
}
for _, r := range evt.Rooms {
m = m.addRoom(r)
}
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
case proto.EvtRoomCreated:
m = m.addRoom(evt.Room)
m = m.refreshViewport()
case proto.EvtSessionReady:
if evt.PeerID != nil {
pid := *evt.PeerID
@@ -309,6 +316,14 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
}
m.input.SetValue("")
if strings.HasPrefix(body, "/room ") {
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
if name != "" {
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name}))
}
return m, cmds
}
room := m.rooms[m.activeRoom]
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
if strings.HasPrefix(room, "dm:") {
@@ -430,7 +445,7 @@ func (m model) View() string {
if m.errMsg != "" {
statusLine = styleErr.Render(" ✗ " + m.errMsg)
} else {
hint := " tab: rooms · ctrl+i: invite · ctrl+c: quit"
hint := " tab: rooms · /room <name>: new room · ctrl+i: invite · ctrl+c: quit"
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
}

View File

@@ -15,6 +15,7 @@ import (
"log"
"net"
"net/http"
"strings"
"time"
"nhooyr.io/websocket"
@@ -239,6 +240,23 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
})
}
case proto.CmdCreateRoom:
n := mgr.Resolve(cmd.NetworkID)
if n == nil {
send(errMsg("create_room: not joined to any network"))
continue
}
name := strings.TrimSpace(cmd.Room)
if name == "" || name == "general" {
send(errMsg("create_room: room name is required and cannot be 'general'"))
continue
}
if err := n.Store.SaveRoom(name); err != nil {
send(errMsg(fmt.Sprintf("create_room: %v", err)))
continue
}
send(proto.IpcMessage{Type: proto.EvtRoomCreated, NetworkID: n.ID, Room: name})
case proto.CmdGetState:
send(stateSnapshot(mgr))
@@ -416,6 +434,11 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
pi := all[0].Identity.PeerInfo()
msg.LocalPeer = &pi
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
if extra, err := all[0].Store.Rooms(); err == nil {
for _, r := range extra {
msg.Rooms = append(msg.Rooms, r)
}
}
}
return msg

View File

@@ -236,6 +236,7 @@ const (
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
// Events (daemon → UI)
EvtMessageReceived IpcMsgType = "message_received"
@@ -255,6 +256,7 @@ const (
EvtIdentityExported IpcMsgType = "identity_exported"
EvtIdentityImported IpcMsgType = "identity_imported"
EvtSharesList IpcMsgType = "shares_list"
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
)
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.

View File

@@ -28,6 +28,11 @@ CREATE TABLE IF NOT EXISTS peers (
alias TEXT NOT NULL,
last_seen DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS rooms (
name TEXT PRIMARY KEY,
created_at DATETIME NOT NULL
);
`
// Store is a local SQLite-backed message and peer store.
@@ -119,6 +124,33 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
return msgs, rows.Err()
}
// SaveRoom persists a room name. Duplicate names are silently ignored.
func (s *Store) SaveRoom(name string) error {
_, err := s.db.Exec(
`INSERT OR IGNORE INTO rooms (name, created_at) VALUES (?, ?)`,
name, time.Now().UTC(),
)
return err
}
// Rooms returns all persisted room names, ordered by creation time.
func (s *Store) Rooms() ([]string, error) {
rows, err := s.db.Query(`SELECT name FROM rooms ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
// KnownPeers returns all peers seen since this daemon started storing data.
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)