Compare commits
2 Commits
851cfdc7e9
...
d09aa2b219
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d09aa2b219 | ||
|
|
437deca6a0 |
@@ -304,3 +304,74 @@ without a `mid` are assigned one at receive time and are not gossipped.
|
||||
|
||||
Emitted once per room after a `history_chunk` is fully processed. The UI
|
||||
should render these messages with a visual separator from live messages.
|
||||
|
||||
---
|
||||
|
||||
## EXT-008 — Message Reactions
|
||||
|
||||
### Wire message (`PeerMessage`)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "reaction",
|
||||
"reaction_mid": "<32-hex mid of the target message>",
|
||||
"reaction_emoji": "👍"
|
||||
}
|
||||
```
|
||||
|
||||
Sent on the normal mesh DataChannel (same as `chat`). No signing beyond
|
||||
the existing channel-level encryption.
|
||||
|
||||
### Semantics
|
||||
|
||||
- A reaction is idempotent: the same `(mid, emoji, from_peer)` triple is
|
||||
stored with `INSERT OR IGNORE` — receiving a duplicate is a no-op.
|
||||
- There is no "un-react" wire message. Toggling off a reaction in the UI
|
||||
is a local-only operation in the current implementation.
|
||||
- `reaction_mid` must reference a message that exists in the local store;
|
||||
unknown mids are silently ignored.
|
||||
|
||||
### Storage
|
||||
|
||||
SQLite table added as a migration:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
mid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
from_peer TEXT NOT NULL,
|
||||
reacted_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (mid, emoji, from_peer)
|
||||
)
|
||||
```
|
||||
|
||||
### IPC
|
||||
|
||||
**Command** — send a reaction (daemon and browser mode):
|
||||
```json
|
||||
{ "type": "send_reaction", "network_id": "...", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
|
||||
```
|
||||
|
||||
**Event** — reaction received or replayed from history:
|
||||
```json
|
||||
{ "type": "reaction", "network_id": "...", "peer_id": "<64-hex>", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
|
||||
```
|
||||
|
||||
Stored reactions are replayed as `reaction` IPC events when history is
|
||||
loaded (`sendStoredHistory`), so the UI always sees reactions alongside
|
||||
their messages.
|
||||
|
||||
### History replay
|
||||
|
||||
When `sendStoredHistory` sends a `history_chunk`, it also queries
|
||||
`ReactionsForRoom` and emits one `reaction` event per stored reaction so
|
||||
clients receive the full reaction state on reconnect.
|
||||
|
||||
### Browser mode
|
||||
|
||||
`browser.ts` mirrors the daemon behaviour independently:
|
||||
|
||||
- `PeerConn.sendReaction(mid, emoji)` broadcasts `{ type: "reaction", reaction_mid, reaction_emoji }` over the DataChannel.
|
||||
- Incoming `reaction` wire frames are dispatched as `reaction` IPC events.
|
||||
- `BrowserAdapter.send()` handles `send_reaction` commands and both broadcasts to all peers and emits a local `reaction` event.
|
||||
- `sendChat` includes the `mid` in the wire frame so reactions can reference it correctly across peers.
|
||||
|
||||
20
FUTURE.md
20
FUTURE.md
@@ -137,9 +137,25 @@ Web frontend (React, already built) + [Wails v2](https://wails.io) shell for nat
|
||||
| ✅ shipped | P2P message history gossip (EXT-007) — new peers receive recent history from first connected peer |
|
||||
| ✅ shipped | Date-aware timestamps in TUI and web UI |
|
||||
| ✅ shipped | Historical peer alias resolution in web UI |
|
||||
| ✅ shipped | Message reactions (emoji picker, full-stack: wire protocol, SQLite, IPC, web UI, TUI) |
|
||||
| ✅ shipped | Link rendering + image preview in web UI messages |
|
||||
| ✅ shipped | Responsive mobile layout (slide-over sidebar, hamburger button) |
|
||||
| ✅ shipped | TUI multi-network (join/switch networks at runtime, `ctrl+n`, `/join`, `/net`) |
|
||||
| ✅ shipped | TUI message reactions (`/react <emoji>` or `/react <n> <emoji>`) |
|
||||
| 🔜 planned | Push notifications (PWA Web Push + service worker) |
|
||||
| 🔜 planned | Message reactions (emoji, full-stack gossip) |
|
||||
| 🔜 planned | Link rendering + image preview in messages |
|
||||
|
||||
### Message Reactions ✅ (shipped)
|
||||
Full-stack emoji reactions. The web UI shows a `+` button on hover that opens a six-emoji picker (👍 ❤️ 😂 😮 😢 🙏). Reactions render as chips below each message; clicking an existing chip toggles your own reaction. Reactions are stored in SQLite (`reactions` table), gossiped over the mesh as `reaction` wire messages (EXT-008), and replayed via IPC on reconnect.
|
||||
|
||||
Browser mode: `browser.ts` independently mirrors the daemon — reactions flow over the DataChannel and are broadcast to all connected peers.
|
||||
|
||||
TUI: messages show `[n]` line numbers. Use `/react <emoji>` (reacts to last message) or `/react <n> <emoji>` (reacts to message `n`). Reactions render inline below the target message.
|
||||
|
||||
### Link Rendering + Image Preview ✅ (shipped)
|
||||
URLs in messages are auto-linked. URLs ending in a recognized image extension (`.jpg`, `.png`, `.gif`, `.webp`, `.svg`) render an inline `<img>` preview (max 320×200px). `blob:` and `data:image` URLs are also treated as images.
|
||||
|
||||
### Responsive Mobile Layout ✅ (shipped)
|
||||
At viewport width ≤ 600px the sidebar becomes a fixed-position slide-over drawer, hidden off-screen by default (`transform: translateX(-100%)`). A `☰` hamburger button in the message pane header toggles it open. Clicking any room or network in the sidebar closes it automatically. The layout is dimmed while the sidebar is open via a `::before` overlay.
|
||||
|
||||
### Push Notifications (planned)
|
||||
The web UI is already a PWA (installable, has manifest). The missing half is a service worker + Web Push subscription:
|
||||
|
||||
13
README.md
13
README.md
@@ -392,9 +392,16 @@ go run ./cmd/tui -network friends
|
||||
| `-join` | — | `waste:` invite string |
|
||||
| `-ipc` | `17337` | Daemon IPC port |
|
||||
|
||||
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
|
||||
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `Ctrl+N` — cycle networks · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
|
||||
|
||||
**Slash commands:** `/room <name>` — create a new room (persisted in SQLite, restored on reconnect). Rooms with unread messages show a `*` prefix in the sidebar.
|
||||
**Slash commands:**
|
||||
- `/room <name>` — create a new room (persisted in SQLite, restored on reconnect)
|
||||
- `/join <name>` — join a new network at runtime (the `-network` flag is optional; start idle and `/join` to connect)
|
||||
- `/net <n|name>` — switch active network by index or name
|
||||
- `/react <emoji>` — react to the last message (use actual emoji characters, e.g. `/react 👍`)
|
||||
- `/react <n> <emoji>` — react to message `[n]` (line numbers shown next to each message)
|
||||
|
||||
Rooms with unread messages show a `*` prefix in the sidebar.
|
||||
|
||||
---
|
||||
|
||||
@@ -419,6 +426,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
||||
{"type":"create_room","room":"dev"}
|
||||
{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network
|
||||
{"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"}
|
||||
{"type":"send_reaction","network_id":"...","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
|
||||
{"type":"export_identity","passphrase":"..."}
|
||||
{"type":"import_identity","passphrase":"...","backup":"..."}
|
||||
```
|
||||
@@ -435,6 +443,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
||||
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
|
||||
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
|
||||
{"type":"room_created","network_id":"...","room":"dev"}
|
||||
{"type":"reaction","network_id":"...","peer_id":"<64-hex>","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
|
||||
{"type":"identity_exported","backup":"..."}
|
||||
{"type":"error","error_message":"..."}
|
||||
```
|
||||
|
||||
710
cmd/tui/main.go
710
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 <network> 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 <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) {
|
||||
body := strings.TrimSpace(m.input.Value())
|
||||
if body == "" || m.enc == nil {
|
||||
@@ -323,16 +572,106 @@ 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 <network-name>
|
||||
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 <number|name> — 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 <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]
|
||||
}
|
||||
}
|
||||
if emoji == "" {
|
||||
m.errMsg = "usage: /react <emoji> or /react <n> <emoji> (e.g. /react 👍 or /react 3 ❤️)"
|
||||
return m, cmds
|
||||
}
|
||||
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 == "" {
|
||||
m.errMsg = "usage: /react <emoji> or /react <n> <emoji> (e.g. /react 👍 or /react 3 ❤️)"
|
||||
return m, cmds
|
||||
}
|
||||
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 +682,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 +690,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 +713,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 +724,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 +755,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 <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)
|
||||
}
|
||||
|
||||
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 +802,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 +863,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 +888,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 +908,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 +916,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 +930,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 +941,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 +959,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 +972,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 +986,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(),
|
||||
|
||||
Reference in New Issue
Block a user