From 340735f992695c140de476e5264c10dea5719bdd Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Fri, 26 Jun 2026 22:26:12 +0200 Subject: [PATCH] feat: TUI room creation + daemon-side room persistence /room 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 --- cmd/tui/main.go | 17 ++++++++++++++++- internal/ipc/ipc.go | 23 +++++++++++++++++++++++ internal/proto/proto.go | 2 ++ internal/store/store.go | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 2bc4eef..c4f8118 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -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 : new room · ctrl+i: invite · ctrl+c: quit" statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint) } diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 5cc48cb..890dd6b 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -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 diff --git a/internal/proto/proto.go b/internal/proto/proto.go index deede0f..2fb263c 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -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. diff --git a/internal/store/store.go b/internal/store/store.go index ab9759e..0753c16 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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`)