feat: TUI multi-network + reactions + history
Multi-network: - Left panel now shows Networks section above Rooms - /join <name> joins a new network at runtime; -network flag now optional - ctrl+n cycles between joined networks; /net <n|name> 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 <emoji> — react to the last message in the current room - /react <n> <emoji> — 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 <noreply@anthropic.com>
This commit is contained in:
704
cmd/tui/main.go
704
cmd/tui/main.go
@@ -1,6 +1,7 @@
|
|||||||
// Package main is the waste-go terminal UI.
|
// Package main is the waste-go terminal UI.
|
||||||
// It connects to a running daemon's IPC port, joins a named network, and
|
// It connects to a running daemon's IPC port and renders a three-pane layout:
|
||||||
// renders a three-pane layout: rooms (left), messages (centre), peers (right).
|
// rooms/networks (left), messages with line numbers (centre), peers (right).
|
||||||
|
// Multiple networks are supported at runtime via /join; switch with ctrl+n.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,21 +27,25 @@ import (
|
|||||||
// ── styles ────────────────────────────────────────────────────────────────────
|
// ── styles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var (
|
var (
|
||||||
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
||||||
styleActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
|
styleActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
|
||||||
styleRoom = lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
|
styleRoom = lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
|
||||||
stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72"))
|
styleNet = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
|
||||||
styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
|
styleNetActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("51"))
|
||||||
styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72"))
|
||||||
styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
|
styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
|
||||||
styleMsgTime = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
||||||
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
|
||||||
styleBorder = lipgloss.Color("238")
|
styleMsgTime = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||||
styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
styleLineNum = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||||
styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
|
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 ──────────────────────────────────────────────────────────────
|
// ── tea messages ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,9 +57,6 @@ type ipcLineMsg struct{ line []byte }
|
|||||||
type connectErrMsg struct{ err error }
|
type connectErrMsg struct{ err error }
|
||||||
type readErrMsg 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 {
|
type lineReader struct {
|
||||||
ch chan []byte
|
ch chan []byte
|
||||||
}
|
}
|
||||||
@@ -84,7 +87,42 @@ func (lr *lineReader) next() tea.Cmd {
|
|||||||
|
|
||||||
// ── model ─────────────────────────────────────────────────────────────────────
|
// ── 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 {
|
type entry struct {
|
||||||
|
mid string
|
||||||
from string
|
from string
|
||||||
body string
|
body string
|
||||||
at time.Time
|
at time.Time
|
||||||
@@ -92,8 +130,8 @@ type entry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
ipcPort int
|
ipcPort int
|
||||||
networkName string
|
initialNetwork string // from -network flag; joined on first connect
|
||||||
|
|
||||||
width, height int
|
width, height int
|
||||||
|
|
||||||
@@ -101,16 +139,15 @@ type model struct {
|
|||||||
enc *json.Encoder
|
enc *json.Encoder
|
||||||
reader *lineReader
|
reader *lineReader
|
||||||
|
|
||||||
localID proto.PeerID
|
nets []*netData
|
||||||
localAlias string
|
activeNet int
|
||||||
|
|
||||||
rooms []string // "general" always first; DM rooms appended
|
// keyed by "netId:room"
|
||||||
activeRoom int
|
messages map[string][]entry
|
||||||
messages map[string][]entry
|
unread map[string]bool
|
||||||
unread map[string]bool // rooms with messages since last viewed
|
|
||||||
|
|
||||||
peers map[proto.PeerID]string // connected peers: id → alias
|
// mid → emoji → []alias
|
||||||
peerOrder []proto.PeerID
|
reactions map[string]map[string][]string
|
||||||
|
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
@@ -118,27 +155,89 @@ type model struct {
|
|||||||
|
|
||||||
status string
|
status string
|
||||||
errMsg string
|
errMsg string
|
||||||
invitePopup string // non-empty = show invite overlay
|
invitePopup string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newModel(ipcPort int, network string) model {
|
func newModel(ipcPort int, network string) model {
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = "Type a message…"
|
ti.Placeholder = "Type a message, or /join /net /room /react…"
|
||||||
ti.Focus()
|
ti.Focus()
|
||||||
ti.CharLimit = 2000
|
ti.CharLimit = 2000
|
||||||
|
|
||||||
return model{
|
return model{
|
||||||
ipcPort: ipcPort,
|
ipcPort: ipcPort,
|
||||||
networkName: network,
|
initialNetwork: network,
|
||||||
rooms: []string{"general"},
|
messages: make(map[string][]entry),
|
||||||
messages: make(map[string][]entry),
|
unread: make(map[string]bool),
|
||||||
unread: make(map[string]bool),
|
reactions: make(map[string]map[string][]string),
|
||||||
peers: make(map[proto.PeerID]string),
|
input: ti,
|
||||||
input: ti,
|
status: "connecting…",
|
||||||
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 {
|
func (m model) Init() tea.Cmd {
|
||||||
return tea.Batch(connectCmd(m.ipcPort), textinput.Blink)
|
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) {
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []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.conn = msg.conn
|
||||||
m.enc = json.NewEncoder(msg.conn)
|
m.enc = json.NewEncoder(msg.conn)
|
||||||
m.reader = msg.reader
|
m.reader = msg.reader
|
||||||
m.status = "joining " + m.networkName + "…"
|
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}), m.reader.next())
|
||||||
cmds = append(cmds,
|
if m.initialNetwork != "" {
|
||||||
sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.networkName}),
|
m.status = "joining " + m.initialNetwork + "…"
|
||||||
sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}),
|
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.initialNetwork}))
|
||||||
m.reader.next(),
|
} else {
|
||||||
)
|
m.status = "connected — /join <network> to start"
|
||||||
|
}
|
||||||
|
|
||||||
case ipcLineMsg:
|
case ipcLineMsg:
|
||||||
var evt proto.IpcMessage
|
var evt proto.IpcMessage
|
||||||
@@ -211,18 +311,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case msg.String() == "ctrl+i":
|
case msg.String() == "ctrl+i":
|
||||||
if m.enc != nil {
|
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:
|
case msg.Type == tea.KeyEnter:
|
||||||
m, cmds = m.doSend(cmds)
|
m, cmds = m.doSend(cmds)
|
||||||
case msg.Type == tea.KeyTab:
|
case msg.Type == tea.KeyTab:
|
||||||
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
if n := m.activeNetData(); n != nil {
|
||||||
delete(m.unread, m.activeRoomName())
|
n.activeRoom = (n.activeRoom + 1) % len(n.rooms)
|
||||||
m = m.refreshViewport()
|
delete(m.unread, m.msgKey())
|
||||||
|
m = m.refreshViewport()
|
||||||
|
}
|
||||||
case msg.Type == tea.KeyShiftTab:
|
case msg.Type == tea.KeyShiftTab:
|
||||||
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
if n := m.activeNetData(); n != nil {
|
||||||
delete(m.unread, m.activeRoomName())
|
n.activeRoom = (n.activeRoom - 1 + len(n.rooms)) % len(n.rooms)
|
||||||
m = m.refreshViewport()
|
delete(m.unread, m.msgKey())
|
||||||
|
m = m.refreshViewport()
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
var tiCmd tea.Cmd
|
var tiCmd tea.Cmd
|
||||||
m.input, tiCmd = m.input.Update(msg)
|
m.input, tiCmd = m.input.Update(msg)
|
||||||
@@ -230,7 +342,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Let viewport handle scroll events.
|
|
||||||
if m.vpReady {
|
if m.vpReady {
|
||||||
var vpCmd tea.Cmd
|
var vpCmd tea.Cmd
|
||||||
m.viewport, vpCmd = m.viewport.Update(msg)
|
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...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── applyEvent ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (m model) applyEvent(evt proto.IpcMessage) model {
|
func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||||
switch evt.Type {
|
switch evt.Type {
|
||||||
|
|
||||||
case proto.EvtStateSnapshot:
|
case proto.EvtStateSnapshot:
|
||||||
if evt.LocalPeer != nil {
|
// Populate nets from snapshot.
|
||||||
m.localID = evt.LocalPeer.ID
|
for _, ni := range evt.Networks {
|
||||||
m.localAlias = evt.LocalPeer.Alias
|
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)
|
// Backward-compat: connected_peers and rooms are from first network.
|
||||||
m.peerOrder = nil
|
if len(evt.Networks) > 0 && len(m.nets) > 0 {
|
||||||
for _, p := range evt.ConnectedPeers {
|
n := m.nets[0]
|
||||||
m.peers[p.ID] = p.Alias
|
for _, p := range evt.ConnectedPeers {
|
||||||
m.peerOrder = append(m.peerOrder, p.ID)
|
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.updateStatus()
|
||||||
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()
|
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:
|
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
|
pid := *evt.PeerID
|
||||||
if _, ok := m.peers[pid]; !ok {
|
if _, ok := n.peers[pid]; !ok {
|
||||||
m.peerOrder = append(m.peerOrder, pid)
|
n.peerOrder = append(n.peerOrder, pid)
|
||||||
}
|
}
|
||||||
alias := evt.Nick
|
alias := evt.Nick
|
||||||
if alias == "" {
|
if alias == "" {
|
||||||
alias = shortID(pid)
|
alias = shortID(pid)
|
||||||
}
|
}
|
||||||
m.peers[pid] = alias
|
n.peers[pid] = alias
|
||||||
}
|
}
|
||||||
|
|
||||||
case proto.EvtPeerConnected:
|
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
|
pid := evt.Peer.ID
|
||||||
if _, ok := m.peers[pid]; !ok {
|
if _, ok := n.peers[pid]; !ok {
|
||||||
m.peerOrder = append(m.peerOrder, pid)
|
n.peerOrder = append(n.peerOrder, pid)
|
||||||
}
|
}
|
||||||
m.peers[pid] = evt.Peer.Alias
|
n.peers[pid] = evt.Peer.Alias
|
||||||
}
|
}
|
||||||
|
|
||||||
case proto.EvtPeerDisconnected:
|
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
|
pid := *evt.PeerID
|
||||||
delete(m.peers, pid)
|
delete(n.peers, pid)
|
||||||
m.peerOrder = filterIDs(m.peerOrder, pid)
|
n.peerOrder = filterIDs(n.peerOrder, pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
case proto.EvtInviteGenerated:
|
case proto.EvtInviteGenerated:
|
||||||
@@ -299,23 +460,111 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
|||||||
case proto.EvtMessageReceived:
|
case proto.EvtMessageReceived:
|
||||||
if evt.Message != nil {
|
if evt.Message != nil {
|
||||||
msg := evt.Message
|
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{
|
e := entry{
|
||||||
from: m.aliasOf(msg.From),
|
mid: msg.Mid,
|
||||||
|
from: m.aliasOf(netID, msg.From),
|
||||||
body: msg.Text,
|
body: msg.Text,
|
||||||
at: time.UnixMilli(msg.Ts),
|
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)
|
key := netID + ":" + msg.Room
|
||||||
m = m.addRoom(msg.Room)
|
m.messages[key] = append(m.messages[key], e)
|
||||||
if msg.Room != m.activeRoomName() {
|
if key != m.msgKey() {
|
||||||
m.unread[msg.Room] = true
|
m.unread[key] = true
|
||||||
}
|
}
|
||||||
m = m.refreshViewport()
|
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
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) updateStatus() model {
|
||||||
|
n := m.activeNetData()
|
||||||
|
if n == nil {
|
||||||
|
m.status = "connected — /join <network> 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) {
|
func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
||||||
body := strings.TrimSpace(m.input.Value())
|
body := strings.TrimSpace(m.input.Value())
|
||||||
if body == "" || m.enc == nil {
|
if body == "" || m.enc == nil {
|
||||||
@@ -323,16 +572,100 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.input.SetValue("")
|
m.input.SetValue("")
|
||||||
|
|
||||||
if strings.HasPrefix(body, "/room ") {
|
// /join <network-name>
|
||||||
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
|
if strings.HasPrefix(body, "/join ") {
|
||||||
|
name := strings.TrimSpace(strings.TrimPrefix(body, "/join "))
|
||||||
if name != "" {
|
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
|
return m, cmds
|
||||||
}
|
}
|
||||||
|
|
||||||
room := m.rooms[m.activeRoom]
|
// /net <number|name> — switch active network
|
||||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
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 <name>
|
||||||
|
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 [<n>] <emoji>
|
||||||
|
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:") {
|
if strings.HasPrefix(room, "dm:") {
|
||||||
recipID := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
recipID := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||||
ipcMsg.To = &recipID
|
ipcMsg.To = &recipID
|
||||||
@@ -343,9 +676,7 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
|||||||
|
|
||||||
// ── layout helpers ────────────────────────────────────────────────────────────
|
// ── layout helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// vpContentWidth returns the inner width of the centre pane (available to the viewport).
|
|
||||||
func (m model) vpContentWidth() int {
|
func (m model) vpContentWidth() int {
|
||||||
// Two sidebar boxes (sideW total each) + centre box (borders 2 = -2 from inner).
|
|
||||||
w := m.width - sideW*2 - 2
|
w := m.width - sideW*2 - 2
|
||||||
if w < 10 {
|
if w < 10 {
|
||||||
w = 10
|
w = 10
|
||||||
@@ -353,10 +684,7 @@ func (m model) vpContentWidth() int {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// vpHeight returns the viewport height (lines of messages shown).
|
|
||||||
func (m model) vpHeight() int {
|
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
|
h := m.height - 8
|
||||||
if h < 1 {
|
if h < 1 {
|
||||||
h = 1
|
h = 1
|
||||||
@@ -379,10 +707,10 @@ func (m model) refreshViewport() model {
|
|||||||
if !m.vpReady {
|
if !m.vpReady {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
room := m.activeRoomName()
|
key := m.msgKey()
|
||||||
w := m.vpContentWidth()
|
|
||||||
var sb strings.Builder
|
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))
|
ts := styleMsgTime.Render(formatMsgTime(e.at))
|
||||||
var from string
|
var from string
|
||||||
if e.fromMe {
|
if e.fromMe {
|
||||||
@@ -390,31 +718,28 @@ func (m model) refreshViewport() model {
|
|||||||
} else {
|
} else {
|
||||||
from = styleMsgFrom.Render(e.from)
|
from = styleMsgFrom.Render(e.from)
|
||||||
}
|
}
|
||||||
line := fmt.Sprintf("%s %s %s", ts, from, e.body)
|
sb.WriteString(fmt.Sprintf("%s %s %s %s\n", lineNum, ts, from, e.body))
|
||||||
// Crude wrap: if line > w, just truncate (viewport handles horizontal scroll).
|
if e.mid != "" {
|
||||||
_ = w
|
if rxn := m.renderReactions(e.mid); rxn != "" {
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(rxn + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.viewport.SetContent(sb.String())
|
m.viewport.SetContent(sb.String())
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) activeRoomName() string {
|
func (m model) renderReactions(mid string) string {
|
||||||
if m.activeRoom < len(m.rooms) {
|
byEmoji := m.reactions[mid]
|
||||||
return m.rooms[m.activeRoom]
|
if len(byEmoji) == 0 {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return ""
|
var parts []string
|
||||||
}
|
for emoji, froms := range byEmoji {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %d", emoji, len(froms)))
|
||||||
func (m model) addRoom(room string) model {
|
|
||||||
for _, r := range m.rooms {
|
|
||||||
if r == room {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
m.rooms = append(m.rooms, room)
|
return styleReaction.Render(" " + strings.Join(parts, " "))
|
||||||
return m
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── view ──────────────────────────────────────────────────────────────────────
|
// ── view ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -424,41 +749,33 @@ func (m model) View() string {
|
|||||||
return "loading…\n"
|
return "loading…\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
innerH := m.height - 3 - 1 // 3 = input box, 1 = status bar
|
innerH := m.height - 3 - 1
|
||||||
if innerH < 4 {
|
if innerH < 4 {
|
||||||
innerH = 4
|
innerH = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── left: rooms ───────────────────────────────────────────────────────────
|
leftBox := m.renderLeft(innerH)
|
||||||
leftBox := m.renderRooms(innerH)
|
|
||||||
|
|
||||||
// ── right: peers ──────────────────────────────────────────────────────────
|
|
||||||
rightBox := m.renderPeers(innerH)
|
rightBox := m.renderPeers(innerH)
|
||||||
|
|
||||||
// ── centre: title + messages ──────────────────────────────────────────────
|
|
||||||
centreBox := m.renderCentre(innerH)
|
centreBox := m.renderCentre(innerH)
|
||||||
|
|
||||||
mainRow := lipgloss.JoinHorizontal(lipgloss.Top, leftBox, centreBox, rightBox)
|
mainRow := lipgloss.JoinHorizontal(lipgloss.Top, leftBox, centreBox, rightBox)
|
||||||
|
|
||||||
// ── input ─────────────────────────────────────────────────────────────────
|
|
||||||
inputBox := lipgloss.NewStyle().
|
inputBox := lipgloss.NewStyle().
|
||||||
Width(m.width - 2).
|
Width(m.width - 2).
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(styleBorder).
|
BorderForeground(styleBorder).
|
||||||
Render(m.input.View())
|
Render(m.input.View())
|
||||||
|
|
||||||
// ── status bar ────────────────────────────────────────────────────────────
|
|
||||||
var statusLine string
|
var statusLine string
|
||||||
if m.errMsg != "" {
|
if m.errMsg != "" {
|
||||||
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
||||||
} else {
|
} else {
|
||||||
hint := " tab: rooms · /room <name>: 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)
|
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
|
view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
|
||||||
|
|
||||||
// ── invite popup (full-screen overlay) ───────────────────────────────────
|
|
||||||
if m.invitePopup != "" {
|
if m.invitePopup != "" {
|
||||||
label := styleActive.Render("Invite — share this with anyone you want to add:")
|
label := styleActive.Render("Invite — share this with anyone you want to add:")
|
||||||
code := lipgloss.NewStyle().
|
code := lipgloss.NewStyle().
|
||||||
@@ -479,24 +796,51 @@ func (m model) View() string {
|
|||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) renderRooms(boxH int) string {
|
func (m model) renderLeft(boxH int) string {
|
||||||
innerW := sideW - 2
|
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
|
var lines []string
|
||||||
lines = append(lines, styleHeader.Width(innerW).Render("Rooms"))
|
|
||||||
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)))
|
// Networks section
|
||||||
for i, room := range m.rooms {
|
lines = append(lines, styleHeader.Width(innerW).Render("Networks"))
|
||||||
label := roomLabel(room, m.peers)
|
lines = append(lines, sep)
|
||||||
if i == m.activeRoom {
|
if len(m.nets) == 0 {
|
||||||
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
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 {
|
} else {
|
||||||
prefix := " "
|
lines = append(lines, styleNet.Width(innerW).Render(fmt.Sprintf(" [%d] %s", i+1, label)))
|
||||||
if m.unread[room] {
|
|
||||||
prefix = "* "
|
|
||||||
}
|
|
||||||
lines = append(lines, styleRoom.Width(innerW).Render(prefix+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 {
|
for len(lines) < contentH {
|
||||||
lines = append(lines, strings.Repeat(" ", innerW))
|
lines = append(lines, strings.Repeat(" ", innerW))
|
||||||
}
|
}
|
||||||
@@ -513,16 +857,18 @@ func (m model) renderPeers(boxH int) string {
|
|||||||
var lines []string
|
var lines []string
|
||||||
lines = append(lines, styleHeader.Width(innerW).Render("Peers"))
|
lines = append(lines, styleHeader.Width(innerW).Render("Peers"))
|
||||||
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)))
|
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)))
|
||||||
// Local peer first
|
n := m.activeNetData()
|
||||||
if m.localAlias != "" {
|
if n != nil {
|
||||||
lines = append(lines, styleSelf.Width(innerW).Render("◉ "+m.localAlias+" (me)"))
|
if n.localAlias != "" {
|
||||||
}
|
lines = append(lines, styleSelf.Width(innerW).Render("◉ "+n.localAlias+" (me)"))
|
||||||
for _, pid := range m.peerOrder {
|
}
|
||||||
alias := m.peers[pid]
|
for _, pid := range n.peerOrder {
|
||||||
if alias == "" {
|
alias := n.peers[pid]
|
||||||
alias = shortID(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 {
|
for len(lines) < contentH {
|
||||||
lines = append(lines, strings.Repeat(" ", innerW))
|
lines = append(lines, strings.Repeat(" ", innerW))
|
||||||
@@ -536,8 +882,18 @@ func (m model) renderPeers(boxH int) string {
|
|||||||
|
|
||||||
func (m model) renderCentre(boxH int) string {
|
func (m model) renderCentre(boxH int) string {
|
||||||
innerW := m.vpContentWidth()
|
innerW := m.vpContentWidth()
|
||||||
room := m.activeRoomName()
|
n := m.activeNetData()
|
||||||
title := styleTitle.Width(innerW).Render(" " + roomTitle(room, m.peers))
|
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))
|
divider := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))
|
||||||
|
|
||||||
vpView := ""
|
vpView := ""
|
||||||
@@ -546,7 +902,6 @@ func (m model) renderCentre(boxH int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left, title, divider, vpView)
|
content := lipgloss.JoinVertical(lipgloss.Left, title, divider, vpView)
|
||||||
|
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Width(innerW).Height(boxH - 2).
|
Width(innerW).Height(boxH - 2).
|
||||||
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
|
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
|
||||||
@@ -555,19 +910,6 @@ func (m model) renderCentre(boxH int) string {
|
|||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── 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 {
|
func shortID(id proto.PeerID) string {
|
||||||
s := string(id)
|
s := string(id)
|
||||||
if len(s) > 8 {
|
if len(s) > 8 {
|
||||||
@@ -582,8 +924,10 @@ func roomLabel(room string, peers map[proto.PeerID]string) string {
|
|||||||
}
|
}
|
||||||
if strings.HasPrefix(room, "dm:") {
|
if strings.HasPrefix(room, "dm:") {
|
||||||
pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||||
if a, ok := peers[pid]; ok && a != "" {
|
if peers != nil {
|
||||||
return "@ " + a
|
if a, ok := peers[pid]; ok && a != "" {
|
||||||
|
return "@ " + a
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "@ " + shortID(pid)
|
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 {
|
func roomTitle(room string, peers map[proto.PeerID]string) string {
|
||||||
if room == "general" {
|
return roomLabel(room, peers)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
|
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() {
|
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||||||
return t.Format("15:04")
|
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")
|
return t.Format("Jan 2 15:04")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,12 +966,11 @@ func min(a, b int) int {
|
|||||||
// ── main ──────────────────────────────────────────────────────────────────────
|
// ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
|
ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
|
||||||
network := flag.String("network", "", "network name to join on startup")
|
network := flag.String("network", "", "network name to join on startup (optional)")
|
||||||
joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name")
|
joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// --join overrides --network.
|
|
||||||
if *joinInvite != "" {
|
if *joinInvite != "" {
|
||||||
inv, err := invite.Decode(*joinInvite)
|
inv, err := invite.Decode(*joinInvite)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -650,12 +980,6 @@ func main() {
|
|||||||
*network = inv.Network
|
*network = inv.Network
|
||||||
}
|
}
|
||||||
|
|
||||||
if *network == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "error: -network or -join is required")
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
p := tea.NewProgram(
|
p := tea.NewProgram(
|
||||||
newModel(*ipcPort, *network),
|
newModel(*ipcPort, *network),
|
||||||
tea.WithAltScreen(),
|
tea.WithAltScreen(),
|
||||||
|
|||||||
Reference in New Issue
Block a user