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:
@@ -251,8 +251,15 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
|||||||
m.peers[p.ID] = p.Alias
|
m.peers[p.ID] = p.Alias
|
||||||
m.peerOrder = append(m.peerOrder, p.ID)
|
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)
|
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
||||||
|
|
||||||
|
case proto.EvtRoomCreated:
|
||||||
|
m = m.addRoom(evt.Room)
|
||||||
|
m = m.refreshViewport()
|
||||||
|
|
||||||
case proto.EvtSessionReady:
|
case proto.EvtSessionReady:
|
||||||
if evt.PeerID != nil {
|
if evt.PeerID != nil {
|
||||||
pid := *evt.PeerID
|
pid := *evt.PeerID
|
||||||
@@ -309,6 +316,14 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.input.SetValue("")
|
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]
|
room := m.rooms[m.activeRoom]
|
||||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
||||||
if strings.HasPrefix(room, "dm:") {
|
if strings.HasPrefix(room, "dm:") {
|
||||||
@@ -430,7 +445,7 @@ func (m model) View() string {
|
|||||||
if m.errMsg != "" {
|
if m.errMsg != "" {
|
||||||
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
||||||
} else {
|
} 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)
|
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nhooyr.io/websocket"
|
"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:
|
case proto.CmdGetState:
|
||||||
send(stateSnapshot(mgr))
|
send(stateSnapshot(mgr))
|
||||||
|
|
||||||
@@ -416,6 +434,11 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
|
|||||||
pi := all[0].Identity.PeerInfo()
|
pi := all[0].Identity.PeerInfo()
|
||||||
msg.LocalPeer = &pi
|
msg.LocalPeer = &pi
|
||||||
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
|
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
|
return msg
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ const (
|
|||||||
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
||||||
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
||||||
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
||||||
|
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
|
||||||
|
|
||||||
// Events (daemon → UI)
|
// Events (daemon → UI)
|
||||||
EvtMessageReceived IpcMsgType = "message_received"
|
EvtMessageReceived IpcMsgType = "message_received"
|
||||||
@@ -255,6 +256,7 @@ const (
|
|||||||
EvtIdentityExported IpcMsgType = "identity_exported"
|
EvtIdentityExported IpcMsgType = "identity_exported"
|
||||||
EvtIdentityImported IpcMsgType = "identity_imported"
|
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||||
EvtSharesList IpcMsgType = "shares_list"
|
EvtSharesList IpcMsgType = "shares_list"
|
||||||
|
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
|
||||||
)
|
)
|
||||||
|
|
||||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ CREATE TABLE IF NOT EXISTS peers (
|
|||||||
alias TEXT NOT NULL,
|
alias TEXT NOT NULL,
|
||||||
last_seen DATETIME 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.
|
// 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()
|
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.
|
// KnownPeers returns all peers seen since this daemon started storing data.
|
||||||
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||||
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
||||||
|
|||||||
Reference in New Issue
Block a user