3 Commits

Author SHA1 Message Date
Fredrik Johansson
bf4009558d fix: desktop app binary missing from CI release; add prebuilt binary docs
All checks were successful
Build / Build & release (push) Successful in 13m42s
wails build -o with a relative path doesn't resolve against the shell's
CWD — Wails joins it onto its own build/bin/ output dir, so the binary
landed in cmd/app/dist/ instead of the top-level dist/ the release step
globs. Build with the default name and cp it into dist/ explicitly.

Also document how to download and run the prebuilt daemon/anchor
release binaries on Linux, macOS, and Windows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:36:34 +02:00
Fredrik Johansson
d09aa2b219 feat: reactions, link previews, mobile layout, TUI multi-network + /react usage hints
- TUI /react shows usage hint on bad/missing args instead of silently no-oping
- README: document /join /net /react slash commands and Ctrl+N keybinding
- README: add send_reaction IPC command and reaction IPC event to protocol reference
- FUTURE.md: mark reactions, link previews, responsive mobile layout, TUI
  multi-network and TUI reactions as shipped; add prose sections for each
- EXTENSIONS.md: add EXT-008 documenting reaction wire protocol, SQLite
  schema, IPC commands/events, history replay, and browser-mode implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 20:08:52 +02:00
Fredrik Johansson
437deca6a0 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>
2026-06-29 20:02:50 +02:00
5 changed files with 667 additions and 195 deletions

View File

@@ -68,7 +68,8 @@ jobs:
- name: Build desktop app - name: Build desktop app
run: | run: |
cd cmd/app cd cmd/app
wails build -trimpath -ldflags="-s -w" -tags webkit2_41 -o ../../dist/waste-linux-amd64 wails build -trimpath -ldflags="-s -w" -tags webkit2_41
cp build/bin/waste ../../dist/waste-linux-amd64
# ── Publish release (tags only) ───────────────────────────────────────── # ── Publish release (tags only) ─────────────────────────────────────────

View File

@@ -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 Emitted once per room after a `history_chunk` is fully processed. The UI
should render these messages with a visual separator from live messages. 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.

View File

@@ -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 | 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 | Date-aware timestamps in TUI and web UI |
| ✅ shipped | Historical peer alias resolution in 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 | 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) ### Push Notifications (planned)
The web UI is already a PWA (installable, has manifest). The missing half is a service worker + Web Push subscription: The web UI is already a PWA (installable, has manifest). The missing half is a service worker + Web Push subscription:

View File

@@ -21,6 +21,51 @@ waste-go/
--- ---
## Prebuilt binaries
Every tagged release publishes cross-compiled binaries — no Go toolchain required. Grab them from the repo's **Releases** page:
```
https://repo.explewd.com/explewd/waste-go/releases
```
| File | What it is | Run it on |
|---|---|---|
| `waste-daemon-<os>-<arch>` | The peer process — your identity, mesh connections, file shares | Each friend's own machine |
| `waste-anchor-<os>-<arch>` | The signaling relay (no message content ever passes through it) | One server you control (VPS) |
`<os>` is `linux`, `darwin` (macOS), or `windows`; `<arch>` is `amd64` or `arm64` (Windows builds are amd64 only). Windows binaries have a `.exe` suffix.
**Linux / macOS:**
```bash
curl -LO https://repo.explewd.com/explewd/waste-go/releases/download/<tag>/waste-daemon-linux-amd64
chmod +x waste-daemon-linux-amd64
./waste-daemon-linux-amd64 -alias yourname -data-dir ~/.waste --join 'waste:eyJ...'
```
> **macOS Gatekeeper:** unsigned binaries downloaded from a browser get a quarantine flag and macOS will refuse to run them ("cannot be opened because the developer cannot be verified"). Clear it once after downloading: `xattr -d com.apple.quarantine waste-daemon-darwin-arm64` (or right-click → Open the first time, which prompts for an override).
**Windows:** download the `.exe`, then run it from PowerShell or `cmd.exe`:
```powershell
.\waste-daemon-windows-amd64.exe -alias yourname -data-dir C:\waste --join "waste:eyJ..."
```
SmartScreen may warn about an unrecognized publisher on first run — these binaries aren't code-signed. Click "More info" → "Run anyway".
Once the daemon is running, drive it with the [TUI](#terminal-ui) (`./cmd/tui` built locally, or the web UI in [daemon mode](#daemon-mode-for-users-running-the-daemon-locally)) — the daemon itself has no UI of its own, it just exposes the local IPC API on port 17337.
The `waste-anchor` binary is for whoever is hosting a network — see [Hosting on a VPS](#hosting-on-a-vps) below for the full anchor + web UI setup. For a quick local anchor (e.g. testing on a LAN), just run:
```bash
./waste-anchor-linux-amd64 -bind 0.0.0.0:8080
```
> **No desktop app build in the current release.** A native Wails desktop app (`cmd/app/`) exists in the source tree, but the CI step that packages it for releases was broken (wrong output path) until this fix — earlier tagged releases only have daemon/anchor binaries. The next tag will include `waste-linux-amd64`. Until then, build it yourself with `./build-app.sh` (see [Desktop app (Wails)](#desktop-app-wails) below).
---
## Hosting on a VPS ## Hosting on a VPS
You need two things on the server: the **anchor** (signaling process) and the **web UI** (static files). Both are served through the same domain via Nginx Proxy Manager. You need two things on the server: the **anchor** (signaling process) and the **web UI** (static files). Both are served through the same domain via Nginx Proxy Manager.
@@ -392,9 +437,16 @@ go run ./cmd/tui -network friends
| `-join` | — | `waste:` invite string | | `-join` | — | `waste:` invite string |
| `-ipc` | `17337` | Daemon IPC port | | `-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 +471,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
{"type":"create_room","room":"dev"} {"type":"create_room","room":"dev"}
{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network {"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":"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":"export_identity","passphrase":"..."}
{"type":"import_identity","passphrase":"...","backup":"..."} {"type":"import_identity","passphrase":"...","backup":"..."}
``` ```
@@ -435,6 +488,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":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"} {"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
{"type":"room_created","network_id":"...","room":"dev"} {"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":"identity_exported","backup":"..."}
{"type":"error","error_message":"..."} {"type":"error","error_message":"..."}
``` ```

View File

@@ -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"
@@ -28,18 +30,22 @@ 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"))
styleNet = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
styleNetActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("51"))
stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72")) stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72"))
styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86")) styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
styleMsgTime = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) 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")) styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
styleBorder = lipgloss.Color("238") styleBorder = lipgloss.Color("238")
styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) 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
@@ -93,7 +131,7 @@ 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 // rooms with messages since last viewed unread map[string]bool
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),
peers: make(map[proto.PeerID]string), reactions: make(map[string]map[string][]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)
delete(m.unread, m.msgKey())
m = m.refreshViewport() 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)
delete(m.unread, m.msgKey())
m = m.refreshViewport() 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)
} }
m.peers = make(map[proto.PeerID]string) if ni.LocalPeer != nil {
m.peerOrder = nil n.localID = ni.LocalPeer.ID
n.localAlias = ni.LocalPeer.Alias
}
}
// 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 { for _, p := range evt.ConnectedPeers {
m.peers[p.ID] = p.Alias if _, ok := n.peers[p.ID]; !ok {
m.peerOrder = append(m.peerOrder, p.ID) n.peerOrder = append(n.peerOrder, p.ID)
}
n.peers[p.ID] = p.Alias
} }
for _, r := range evt.Rooms { for _, r := range evt.Rooms {
m = m.addRoom(r) n.addRoom(r)
} }
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName) for _, p := range evt.KnownPeers {
n.knownPeers[p.ID] = p.Alias
case proto.EvtRoomCreated: }
m = m.addRoom(evt.Room) }
m = m.updateStatus()
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
pid := evt.Peer.ID if netID == "" && len(m.nets) > 0 {
if _, ok := m.peers[pid]; !ok { netID = m.nets[0].id
m.peerOrder = append(m.peerOrder, pid)
} }
m.peers[pid] = evt.Peer.Alias if n := m.netByID(netID); n != nil && evt.Peer != nil {
pid := evt.Peer.ID
if _, ok := n.peers[pid]; !ok {
n.peerOrder = append(n.peerOrder, pid)
}
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,106 @@ 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]
}
}
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:") { 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 +682,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 +690,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 +713,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 +724,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
func (m model) addRoom(room string) model { for emoji, froms := range byEmoji {
for _, r := range m.rooms { parts = append(parts, fmt.Sprintf("%s %d", emoji, len(froms)))
if r == room {
return m
} }
} return styleReaction.Render(" " + strings.Join(parts, " "))
m.rooms = append(m.rooms, room)
return m
} }
// ── view ────────────────────────────────────────────────────────────────────── // ── view ──────────────────────────────────────────────────────────────────────
@@ -424,41 +755,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 +802,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
// 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 {
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, styleHeader.Width(innerW).Render("Rooms"))
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))) lines = append(lines, sep)
for i, room := range m.rooms { n := m.activeNetData()
label := roomLabel(room, m.peers) if n == nil {
if i == m.activeRoom { 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)) lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
} else { } else {
prefix := " " prefix := " "
if m.unread[room] { if m.unread[key] {
prefix = "* " prefix = "* "
} }
lines = append(lines, styleRoom.Width(innerW).Render(prefix+label)) 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,17 +863,19 @@ 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 { for _, pid := range n.peerOrder {
alias := m.peers[pid] alias := n.peers[pid]
if alias == "" { if alias == "" {
alias = shortID(pid) 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 +888,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 +908,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 +916,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,26 +930,18 @@ 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 peers != nil {
if a, ok := peers[pid]; ok && a != "" { if a, ok := peers[pid]; ok && a != "" {
return "@ " + a return "@ " + a
} }
}
return "@ " + shortID(pid) return "@ " + shortID(pid)
} }
return "#" + room return "#" + room
} }
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 +959,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")
} }
@@ -636,11 +973,10 @@ func min(a, b int) int {
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 +986,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(),