From 437deca6a0bc017fc30595bda3670a119ed58818 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Mon, 29 Jun 2026 20:02:50 +0200 Subject: [PATCH] feat: TUI multi-network + reactions + history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-network: - Left panel now shows Networks section above Rooms - /join joins a new network at runtime; -network flag now optional - ctrl+n cycles between joined networks; /net switches by number or name - All state (rooms, peers, messages) scoped per network via netData struct - Messages keyed by "netId:room" (same as web store) Reactions: - /react β€” react to the last message in the current room - /react β€” react to message number n - Reactions displayed as a dimmed line below each message: πŸ‘ 2 ❀️ 1 - EvtReaction handler updates in-place and refreshes viewport History: - EvtHistoryLoaded now handled: historical messages prepended, deduped by mid, sorted by time UX: - Each message prefixed with [n] line number so /react targets are unambiguous - -network flag is now optional (start idle, /join to connect) - Status bar hint updated with new commands Co-Authored-By: Claude Sonnet 4.6 --- cmd/tui/main.go | 704 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 514 insertions(+), 190 deletions(-) diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 69d609d..f5fcdb4 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -1,6 +1,7 @@ // Package main is the waste-go terminal UI. -// It connects to a running daemon's IPC port, joins a named network, and -// renders a three-pane layout: rooms (left), messages (centre), peers (right). +// It connects to a running daemon's IPC port and renders a three-pane layout: +// rooms/networks (left), messages with line numbers (centre), peers (right). +// Multiple networks are supported at runtime via /join; switch with ctrl+n. package main import ( @@ -10,6 +11,7 @@ import ( "fmt" "net" "os" + "strconv" "strings" "time" @@ -25,21 +27,25 @@ import ( // ── styles ──────────────────────────────────────────────────────────────────── var ( - styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) - styleActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) - styleRoom = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) - stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72")) - styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86")) - styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) - styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) - styleMsgTime = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) - styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) - styleBorder = lipgloss.Color("238") - styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) - styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) + styleActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) + styleRoom = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) + styleNet = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + styleNetActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("51")) + stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72")) + styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86")) + styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) + styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) + styleMsgTime = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + styleLineNum = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + styleReaction = lipgloss.NewStyle().Foreground(lipgloss.Color("246")) + styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) + styleBorder = lipgloss.Color("238") + styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) ) -const sideW = 22 // total width of each sidebar box (inner = sideW-2) +const sideW = 22 // ── tea messages ────────────────────────────────────────────────────────────── @@ -51,9 +57,6 @@ type ipcLineMsg struct{ line []byte } type connectErrMsg struct{ err error } type readErrMsg struct{ err error } -// lineReader pumps a TCP connection through a channel so a single bufio.Scanner -// is alive for the lifetime of the connection (avoids read-ahead data loss when -// a new scanner is created on each call). type lineReader struct { ch chan []byte } @@ -84,7 +87,42 @@ func (lr *lineReader) next() tea.Cmd { // ── model ───────────────────────────────────────────────────────────────────── +// netData holds per-network state. +type netData struct { + id string + name string + localID proto.PeerID + localAlias string + rooms []string + activeRoom int + peers map[proto.PeerID]string + peerOrder []proto.PeerID + knownPeers map[proto.PeerID]string // historical peers (from store) +} + +func newNetData(id, name string) *netData { + return &netData{ + id: id, + name: name, + rooms: []string{"general"}, + peers: make(map[proto.PeerID]string), + knownPeers: make(map[proto.PeerID]string), + } +} + +func (n *netData) addRoom(room string) bool { + for _, r := range n.rooms { + if r == room { + return false + } + } + n.rooms = append(n.rooms, room) + return true +} + +// entry is a single chat message in the viewport. type entry struct { + mid string from string body string at time.Time @@ -92,8 +130,8 @@ type entry struct { } type model struct { - ipcPort int - networkName string + ipcPort int + initialNetwork string // from -network flag; joined on first connect width, height int @@ -101,16 +139,15 @@ type model struct { enc *json.Encoder reader *lineReader - localID proto.PeerID - localAlias string + nets []*netData + activeNet int - rooms []string // "general" always first; DM rooms appended - activeRoom int - messages map[string][]entry - unread map[string]bool // rooms with messages since last viewed + // keyed by "netId:room" + messages map[string][]entry + unread map[string]bool - peers map[proto.PeerID]string // connected peers: id β†’ alias - peerOrder []proto.PeerID + // mid β†’ emoji β†’ []alias + reactions map[string]map[string][]string input textinput.Model viewport viewport.Model @@ -118,27 +155,89 @@ type model struct { status string errMsg string - invitePopup string // non-empty = show invite overlay + invitePopup string } func newModel(ipcPort int, network string) model { ti := textinput.New() - ti.Placeholder = "Type a message…" + ti.Placeholder = "Type a message, or /join /net /room /react…" ti.Focus() ti.CharLimit = 2000 return model{ - ipcPort: ipcPort, - networkName: network, - rooms: []string{"general"}, - messages: make(map[string][]entry), - unread: make(map[string]bool), - peers: make(map[proto.PeerID]string), - input: ti, - status: "connecting…", + ipcPort: ipcPort, + initialNetwork: network, + messages: make(map[string][]entry), + unread: make(map[string]bool), + reactions: make(map[string]map[string][]string), + input: ti, + status: "connecting…", } } +// ── accessors ───────────────────────────────────────────────────────────────── + +func (m model) activeNetData() *netData { + if m.activeNet < len(m.nets) { + return m.nets[m.activeNet] + } + return nil +} + +func (m model) activeNetworkID() string { + if n := m.activeNetData(); n != nil { + return n.id + } + return "" +} + +func (m model) activeRoomName() string { + n := m.activeNetData() + if n == nil { + return "general" + } + if n.activeRoom < len(n.rooms) { + return n.rooms[n.activeRoom] + } + return "general" +} + +func (m model) msgKey() string { + n := m.activeNetData() + if n == nil { + return ":general" + } + return n.id + ":" + n.rooms[n.activeRoom] +} + +func (m model) netByID(id string) *netData { + for _, n := range m.nets { + if n.id == id { + return n + } + } + return nil +} + +func (m model) aliasOf(netID string, id proto.PeerID) string { + n := m.netByID(netID) + if n == nil { + return shortID(id) + } + if id == n.localID && n.localAlias != "" { + return n.localAlias + } + if a, ok := n.peers[id]; ok && a != "" { + return a + } + if a, ok := n.knownPeers[id]; ok && a != "" { + return a + } + return shortID(id) +} + +// ── Init ────────────────────────────────────────────────────────────────────── + func (m model) Init() tea.Cmd { return tea.Batch(connectCmd(m.ipcPort), textinput.Blink) } @@ -162,7 +261,7 @@ func sendIPC(enc *json.Encoder, msg proto.IpcMessage) tea.Cmd { } } -// ── update ──────────────────────────────────────────────────────────────────── +// ── Update ──────────────────────────────────────────────────────────────────── func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -181,12 +280,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.conn = msg.conn m.enc = json.NewEncoder(msg.conn) m.reader = msg.reader - m.status = "joining " + m.networkName + "…" - cmds = append(cmds, - sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.networkName}), - sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}), - m.reader.next(), - ) + cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}), m.reader.next()) + if m.initialNetwork != "" { + m.status = "joining " + m.initialNetwork + "…" + cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.initialNetwork})) + } else { + m.status = "connected β€” /join to start" + } case ipcLineMsg: var evt proto.IpcMessage @@ -211,18 +311,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case msg.String() == "ctrl+i": if m.enc != nil { - cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGenerateInvite})) + cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{ + Type: proto.CmdGenerateInvite, + NetworkID: m.activeNetworkID(), + })) + } + case msg.String() == "ctrl+n": + if len(m.nets) > 1 { + m.activeNet = (m.activeNet + 1) % len(m.nets) + m = m.refreshViewport() } case msg.Type == tea.KeyEnter: m, cmds = m.doSend(cmds) case msg.Type == tea.KeyTab: - m.activeRoom = (m.activeRoom + 1) % len(m.rooms) - delete(m.unread, m.activeRoomName()) - m = m.refreshViewport() + if n := m.activeNetData(); n != nil { + n.activeRoom = (n.activeRoom + 1) % len(n.rooms) + delete(m.unread, m.msgKey()) + m = m.refreshViewport() + } case msg.Type == tea.KeyShiftTab: - m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms) - delete(m.unread, m.activeRoomName()) - m = m.refreshViewport() + if n := m.activeNetData(); n != nil { + n.activeRoom = (n.activeRoom - 1 + len(n.rooms)) % len(n.rooms) + delete(m.unread, m.msgKey()) + m = m.refreshViewport() + } default: var tiCmd tea.Cmd m.input, tiCmd = m.input.Update(msg) @@ -230,7 +342,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } default: - // Let viewport handle scroll events. if m.vpReady { var vpCmd tea.Cmd m.viewport, vpCmd = m.viewport.Update(msg) @@ -241,56 +352,106 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +// ── applyEvent ──────────────────────────────────────────────────────────────── + func (m model) applyEvent(evt proto.IpcMessage) model { switch evt.Type { case proto.EvtStateSnapshot: - if evt.LocalPeer != nil { - m.localID = evt.LocalPeer.ID - m.localAlias = evt.LocalPeer.Alias + // Populate nets from snapshot. + for _, ni := range evt.Networks { + n := m.netByID(ni.NetworkID) + if n == nil { + n = newNetData(ni.NetworkID, ni.NetworkName) + m.nets = append(m.nets, n) + } + if ni.LocalPeer != nil { + n.localID = ni.LocalPeer.ID + n.localAlias = ni.LocalPeer.Alias + } } - m.peers = make(map[proto.PeerID]string) - m.peerOrder = nil - for _, p := range evt.ConnectedPeers { - m.peers[p.ID] = p.Alias - m.peerOrder = append(m.peerOrder, p.ID) + // Backward-compat: connected_peers and rooms are from first network. + if len(evt.Networks) > 0 && len(m.nets) > 0 { + n := m.nets[0] + for _, p := range evt.ConnectedPeers { + if _, ok := n.peers[p.ID]; !ok { + n.peerOrder = append(n.peerOrder, p.ID) + } + n.peers[p.ID] = p.Alias + } + for _, r := range evt.Rooms { + n.addRoom(r) + } + for _, p := range evt.KnownPeers { + n.knownPeers[p.ID] = p.Alias + } } - 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.updateStatus() m = m.refreshViewport() + case proto.EvtNetworkJoined: + n := m.netByID(evt.NetworkID) + if n == nil { + name := evt.NetworkName + if name == "" { + name = evt.NetworkID + } + n = newNetData(evt.NetworkID, name) + m.nets = append(m.nets, n) + m.activeNet = len(m.nets) - 1 + } + if evt.LocalPeer != nil { + n.localID = evt.LocalPeer.ID + n.localAlias = evt.LocalPeer.Alias + } + m = m.updateStatus() + m = m.refreshViewport() + + case proto.EvtRoomCreated: + if n := m.netByID(evt.NetworkID); n != nil { + n.addRoom(evt.Room) + m = m.refreshViewport() + } + case proto.EvtSessionReady: - if evt.PeerID != nil { + netID := evt.NetworkID + if netID == "" && len(m.nets) > 0 { + netID = m.nets[0].id + } + if n := m.netByID(netID); n != nil && evt.PeerID != nil { pid := *evt.PeerID - if _, ok := m.peers[pid]; !ok { - m.peerOrder = append(m.peerOrder, pid) + if _, ok := n.peers[pid]; !ok { + n.peerOrder = append(n.peerOrder, pid) } alias := evt.Nick if alias == "" { alias = shortID(pid) } - m.peers[pid] = alias + n.peers[pid] = alias } case proto.EvtPeerConnected: - if evt.Peer != nil { + netID := evt.NetworkID + if netID == "" && len(m.nets) > 0 { + netID = m.nets[0].id + } + if n := m.netByID(netID); n != nil && evt.Peer != nil { pid := evt.Peer.ID - if _, ok := m.peers[pid]; !ok { - m.peerOrder = append(m.peerOrder, pid) + if _, ok := n.peers[pid]; !ok { + n.peerOrder = append(n.peerOrder, pid) } - m.peers[pid] = evt.Peer.Alias + n.peers[pid] = evt.Peer.Alias } case proto.EvtPeerDisconnected: - if evt.PeerID != nil { + netID := evt.NetworkID + if netID == "" && len(m.nets) > 0 { + netID = m.nets[0].id + } + if n := m.netByID(netID); n != nil && evt.PeerID != nil { pid := *evt.PeerID - delete(m.peers, pid) - m.peerOrder = filterIDs(m.peerOrder, pid) + delete(n.peers, pid) + n.peerOrder = filterIDs(n.peerOrder, pid) } case proto.EvtInviteGenerated: @@ -299,23 +460,111 @@ func (m model) applyEvent(evt proto.IpcMessage) model { case proto.EvtMessageReceived: if evt.Message != nil { msg := evt.Message + netID := evt.NetworkID + if netID == "" && len(m.nets) > 0 { + netID = m.nets[0].id + } + n := m.netByID(netID) + if n != nil { + n.addRoom(msg.Room) + } e := entry{ - from: m.aliasOf(msg.From), + mid: msg.Mid, + from: m.aliasOf(netID, msg.From), body: msg.Text, at: time.UnixMilli(msg.Ts), - fromMe: msg.From == m.localID, + fromMe: n != nil && msg.From == n.localID, } - m.messages[msg.Room] = append(m.messages[msg.Room], e) - m = m.addRoom(msg.Room) - if msg.Room != m.activeRoomName() { - m.unread[msg.Room] = true + key := netID + ":" + msg.Room + m.messages[key] = append(m.messages[key], e) + if key != m.msgKey() { + m.unread[key] = true } m = m.refreshViewport() } + + case proto.EvtHistoryLoaded: + netID := evt.NetworkID + if netID == "" && len(m.nets) > 0 { + netID = m.nets[0].id + } + n := m.netByID(netID) + key := netID + ":" + evt.Room + existing := m.messages[key] + existingMids := make(map[string]bool, len(existing)) + for _, e := range existing { + if e.mid != "" { + existingMids[e.mid] = true + } + } + var fresh []entry + for _, msg := range evt.Messages { + if msg.Mid != "" && existingMids[msg.Mid] { + continue + } + fromMe := n != nil && msg.From == n.localID + fresh = append(fresh, entry{ + mid: msg.Mid, + from: m.aliasOf(netID, msg.From), + body: msg.Text, + at: time.UnixMilli(msg.Ts), + fromMe: fromMe, + }) + } + if len(fresh) > 0 { + // Prepend history, then existing live messages; sort by time. + merged := append(fresh, existing...) + // Simple insertion sort (lists are already mostly sorted). + for i := 1; i < len(merged); i++ { + for j := i; j > 0 && merged[j].at.Before(merged[j-1].at); j-- { + merged[j], merged[j-1] = merged[j-1], merged[j] + } + } + m.messages[key] = merged + if n != nil { + n.addRoom(evt.Room) + } + m = m.refreshViewport() + } + + case proto.EvtReaction: + mid := evt.ReactionMID + emoji := evt.ReactionEmoji + if mid == "" || emoji == "" || evt.PeerID == nil { + break + } + netID := evt.NetworkID + if netID == "" && len(m.nets) > 0 { + netID = m.nets[0].id + } + alias := m.aliasOf(netID, *evt.PeerID) + if m.reactions[mid] == nil { + m.reactions[mid] = make(map[string][]string) + } + for _, a := range m.reactions[mid][emoji] { + if a == alias { + return m // already recorded + } + } + m.reactions[mid][emoji] = append(m.reactions[mid][emoji], alias) + m = m.refreshViewport() } + return m } +func (m model) updateStatus() model { + n := m.activeNetData() + if n == nil { + m.status = "connected β€” /join to start" + return m + } + m.status = fmt.Sprintf("● %s Β· %s", n.localAlias, n.name) + return m +} + +// ── doSend ──────────────────────────────────────────────────────────────────── + func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) { body := strings.TrimSpace(m.input.Value()) if body == "" || m.enc == nil { @@ -323,16 +572,100 @@ 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 ")) + // /join + if strings.HasPrefix(body, "/join ") { + name := strings.TrimSpace(strings.TrimPrefix(body, "/join ")) if name != "" { - cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name})) + cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: name})) } return m, cmds } - room := m.rooms[m.activeRoom] - ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body} + // /net β€” switch active network + if strings.HasPrefix(body, "/net ") { + arg := strings.TrimSpace(strings.TrimPrefix(body, "/net ")) + if n, err := strconv.Atoi(arg); err == nil { + idx := n - 1 + if idx >= 0 && idx < len(m.nets) { + m.activeNet = idx + m = m.updateStatus() + m = m.refreshViewport() + } + } else { + for i, net := range m.nets { + if strings.EqualFold(net.name, arg) { + m.activeNet = i + m = m.updateStatus() + m = m.refreshViewport() + break + } + } + } + return m, cmds + } + + // /room + 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, + NetworkID: m.activeNetworkID(), + Room: name, + })) + } + return m, cmds + } + + // /react [] + if strings.HasPrefix(body, "/react ") { + rest := strings.TrimSpace(strings.TrimPrefix(body, "/react ")) + parts := strings.Fields(rest) + var targetIdx int = -1 // -1 = last message + var emoji string + switch len(parts) { + case 1: + emoji = parts[0] + case 2: + if n, err := strconv.Atoi(parts[0]); err == nil { + targetIdx = n - 1 + } else { + emoji = parts[0] // fallback: treat first token as emoji + } + if emoji == "" { + emoji = parts[1] + } + } + msgs := m.messages[m.msgKey()] + var targetMid string + if targetIdx == -1 && len(msgs) > 0 { + targetMid = msgs[len(msgs)-1].mid + } else if targetIdx >= 0 && targetIdx < len(msgs) { + targetMid = msgs[targetIdx].mid + } + if targetMid != "" && emoji != "" { + cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{ + Type: proto.CmdSendReaction, + NetworkID: m.activeNetworkID(), + ReactionMID: targetMid, + ReactionEmoji: emoji, + })) + } + return m, cmds + } + + // Regular message + n := m.activeNetData() + if n == nil { + return m, cmds + } + room := n.rooms[n.activeRoom] + ipcMsg := proto.IpcMessage{ + Type: proto.CmdSendMessage, + NetworkID: n.id, + Room: room, + Body: body, + } if strings.HasPrefix(room, "dm:") { recipID := proto.PeerID(strings.TrimPrefix(room, "dm:")) ipcMsg.To = &recipID @@ -343,9 +676,7 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) { // ── layout helpers ──────────────────────────────────────────────────────────── -// vpContentWidth returns the inner width of the centre pane (available to the viewport). func (m model) vpContentWidth() int { - // Two sidebar boxes (sideW total each) + centre box (borders 2 = -2 from inner). w := m.width - sideW*2 - 2 if w < 10 { w = 10 @@ -353,10 +684,7 @@ func (m model) vpContentWidth() int { return w } -// vpHeight returns the viewport height (lines of messages shown). func (m model) vpHeight() int { - // Full height minus: top border(1) + title(1) + divider(1) + bottom border(1) + - // input box (3 lines incl borders) + status bar(1) = 8 total overhead. h := m.height - 8 if h < 1 { h = 1 @@ -379,10 +707,10 @@ func (m model) refreshViewport() model { if !m.vpReady { return m } - room := m.activeRoomName() - w := m.vpContentWidth() + key := m.msgKey() var sb strings.Builder - for _, e := range m.messages[room] { + for i, e := range m.messages[key] { + lineNum := styleLineNum.Render(fmt.Sprintf("[%d]", i+1)) ts := styleMsgTime.Render(formatMsgTime(e.at)) var from string if e.fromMe { @@ -390,31 +718,28 @@ func (m model) refreshViewport() model { } else { from = styleMsgFrom.Render(e.from) } - line := fmt.Sprintf("%s %s %s", ts, from, e.body) - // Crude wrap: if line > w, just truncate (viewport handles horizontal scroll). - _ = w - sb.WriteString(line + "\n") + sb.WriteString(fmt.Sprintf("%s %s %s %s\n", lineNum, ts, from, e.body)) + if e.mid != "" { + if rxn := m.renderReactions(e.mid); rxn != "" { + sb.WriteString(rxn + "\n") + } + } } m.viewport.SetContent(sb.String()) m.viewport.GotoBottom() return m } -func (m model) activeRoomName() string { - if m.activeRoom < len(m.rooms) { - return m.rooms[m.activeRoom] +func (m model) renderReactions(mid string) string { + byEmoji := m.reactions[mid] + if len(byEmoji) == 0 { + return "" } - return "" -} - -func (m model) addRoom(room string) model { - for _, r := range m.rooms { - if r == room { - return m - } + var parts []string + for emoji, froms := range byEmoji { + parts = append(parts, fmt.Sprintf("%s %d", emoji, len(froms))) } - m.rooms = append(m.rooms, room) - return m + return styleReaction.Render(" " + strings.Join(parts, " ")) } // ── view ────────────────────────────────────────────────────────────────────── @@ -424,41 +749,33 @@ func (m model) View() string { return "loading…\n" } - innerH := m.height - 3 - 1 // 3 = input box, 1 = status bar + innerH := m.height - 3 - 1 if innerH < 4 { innerH = 4 } - // ── left: rooms ─────────────────────────────────────────────────────────── - leftBox := m.renderRooms(innerH) - - // ── right: peers ────────────────────────────────────────────────────────── + leftBox := m.renderLeft(innerH) rightBox := m.renderPeers(innerH) - - // ── centre: title + messages ────────────────────────────────────────────── centreBox := m.renderCentre(innerH) mainRow := lipgloss.JoinHorizontal(lipgloss.Top, leftBox, centreBox, rightBox) - // ── input ───────────────────────────────────────────────────────────────── inputBox := lipgloss.NewStyle(). Width(m.width - 2). Border(lipgloss.RoundedBorder()). BorderForeground(styleBorder). Render(m.input.View()) - // ── status bar ──────────────────────────────────────────────────────────── var statusLine string if m.errMsg != "" { statusLine = styleErr.Render(" βœ— " + m.errMsg) } else { - hint := " tab: rooms Β· /room : new room Β· ctrl+i: invite Β· ctrl+c: quit" + hint := " tab: rooms Β· ctrl+n: nets Β· /join /net /room /react Β· ctrl+i: invite Β· ctrl+c: quit" statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint) } view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine) - // ── invite popup (full-screen overlay) ─────────────────────────────────── if m.invitePopup != "" { label := styleActive.Render("Invite β€” share this with anyone you want to add:") code := lipgloss.NewStyle(). @@ -479,24 +796,51 @@ func (m model) View() string { return view } -func (m model) renderRooms(boxH int) string { +func (m model) renderLeft(boxH int) string { innerW := sideW - 2 - contentH := boxH - 2 // subtract top+bottom border + contentH := boxH - 2 + sep := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)) var lines []string - lines = append(lines, styleHeader.Width(innerW).Render("Rooms")) - lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))) - for i, room := range m.rooms { - label := roomLabel(room, m.peers) - if i == m.activeRoom { - lines = append(lines, styleActive.Width(innerW).Render("β–Ά "+label)) + + // Networks section + lines = append(lines, styleHeader.Width(innerW).Render("Networks")) + lines = append(lines, sep) + if len(m.nets) == 0 { + lines = append(lines, styleRoom.Width(innerW).Render(" (none)")) + } + for i, n := range m.nets { + label := n.name + if i == m.activeNet { + lines = append(lines, styleNetActive.Width(innerW).Render("β–Ά "+label)) } else { - prefix := " " - if m.unread[room] { - prefix = "* " - } - lines = append(lines, styleRoom.Width(innerW).Render(prefix+label)) + lines = append(lines, styleNet.Width(innerW).Render(fmt.Sprintf(" [%d] %s", i+1, label))) } } + + lines = append(lines, sep) + + // Rooms section + lines = append(lines, styleHeader.Width(innerW).Render("Rooms")) + lines = append(lines, sep) + n := m.activeNetData() + if n == nil { + lines = append(lines, styleRoom.Width(innerW).Render(" (no network)")) + } else { + for i, room := range n.rooms { + label := roomLabel(room, n.peers) + key := n.id + ":" + room + if i == n.activeRoom { + lines = append(lines, styleActive.Width(innerW).Render("β–Ά "+label)) + } else { + prefix := " " + if m.unread[key] { + prefix = "* " + } + lines = append(lines, styleRoom.Width(innerW).Render(prefix+label)) + } + } + } + for len(lines) < contentH { lines = append(lines, strings.Repeat(" ", innerW)) } @@ -513,16 +857,18 @@ func (m model) renderPeers(boxH int) string { var lines []string lines = append(lines, styleHeader.Width(innerW).Render("Peers")) lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))) - // Local peer first - if m.localAlias != "" { - lines = append(lines, styleSelf.Width(innerW).Render("β—‰ "+m.localAlias+" (me)")) - } - for _, pid := range m.peerOrder { - alias := m.peers[pid] - if alias == "" { - alias = shortID(pid) + n := m.activeNetData() + if n != nil { + if n.localAlias != "" { + lines = append(lines, styleSelf.Width(innerW).Render("β—‰ "+n.localAlias+" (me)")) + } + for _, pid := range n.peerOrder { + alias := n.peers[pid] + if alias == "" { + alias = shortID(pid) + } + lines = append(lines, stylePeer.Width(innerW).Render("● "+alias)) } - lines = append(lines, stylePeer.Width(innerW).Render("● "+alias)) } for len(lines) < contentH { lines = append(lines, strings.Repeat(" ", innerW)) @@ -536,8 +882,18 @@ func (m model) renderPeers(boxH int) string { func (m model) renderCentre(boxH int) string { innerW := m.vpContentWidth() - room := m.activeRoomName() - title := styleTitle.Width(innerW).Render(" " + roomTitle(room, m.peers)) + n := m.activeNetData() + var roomName string + if n != nil { + roomName = n.rooms[n.activeRoom] + } else { + roomName = "general" + } + var peerMap map[proto.PeerID]string + if n != nil { + peerMap = n.peers + } + title := styleTitle.Width(innerW).Render(" " + roomTitle(roomName, peerMap)) divider := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)) vpView := "" @@ -546,7 +902,6 @@ func (m model) renderCentre(boxH int) string { } content := lipgloss.JoinVertical(lipgloss.Left, title, divider, vpView) - return lipgloss.NewStyle(). Width(innerW).Height(boxH - 2). Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder). @@ -555,19 +910,6 @@ func (m model) renderCentre(boxH int) string { // ── helpers ─────────────────────────────────────────────────────────────────── -func (m model) aliasOf(id proto.PeerID) string { - if id == m.localID { - if m.localAlias != "" { - return m.localAlias - } - return "me" - } - if a, ok := m.peers[id]; ok && a != "" { - return a - } - return shortID(id) -} - func shortID(id proto.PeerID) string { s := string(id) if len(s) > 8 { @@ -582,8 +924,10 @@ func roomLabel(room string, peers map[proto.PeerID]string) string { } if strings.HasPrefix(room, "dm:") { pid := proto.PeerID(strings.TrimPrefix(room, "dm:")) - if a, ok := peers[pid]; ok && a != "" { - return "@ " + a + if peers != nil { + if a, ok := peers[pid]; ok && a != "" { + return "@ " + a + } } return "@ " + shortID(pid) } @@ -591,17 +935,7 @@ func roomLabel(room string, peers map[proto.PeerID]string) string { } func roomTitle(room string, peers map[proto.PeerID]string) string { - if room == "general" { - return "#general" - } - if strings.HasPrefix(room, "dm:") { - pid := proto.PeerID(strings.TrimPrefix(room, "dm:")) - if a, ok := peers[pid]; ok && a != "" { - return "@ " + a - } - return "@ " + shortID(pid) - } - return "#" + room + return roomLabel(room, peers) } func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID { @@ -619,9 +953,6 @@ func formatMsgTime(t time.Time) string { if t.Year() == now.Year() && t.YearDay() == now.YearDay() { return t.Format("15:04") } - if t.Year() == now.Year() && t.YearDay() == now.YearDay()-1 { - return "Yesterday " + t.Format("15:04") - } return t.Format("Jan 2 15:04") } @@ -635,12 +966,11 @@ func min(a, b int) int { // ── main ────────────────────────────────────────────────────────────────────── func main() { - ipcPort := flag.Int("ipc", 17337, "daemon IPC port") - network := flag.String("network", "", "network name to join on startup") - joinInvite := flag.String("join", "", "waste: invite string β€” auto-sets the network name") + ipcPort := flag.Int("ipc", 17337, "daemon IPC port") + network := flag.String("network", "", "network name to join on startup (optional)") + joinInvite := flag.String("join", "", "waste: invite string β€” auto-sets the network name") flag.Parse() - // --join overrides --network. if *joinInvite != "" { inv, err := invite.Decode(*joinInvite) if err != nil { @@ -650,12 +980,6 @@ func main() { *network = inv.Network } - if *network == "" { - fmt.Fprintln(os.Stderr, "error: -network or -join is required") - flag.Usage() - os.Exit(1) - } - p := tea.NewProgram( newModel(*ipcPort, *network), tea.WithAltScreen(),