Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf4009558d | ||
|
|
d09aa2b219 | ||
|
|
437deca6a0 | ||
|
|
851cfdc7e9 | ||
|
|
4a7a95fe9d | ||
|
|
32a6f46481 | ||
|
|
1d9c9d1524 | ||
|
|
f5fb0862ff | ||
|
|
dab5387cbd | ||
|
|
1d9827beb0 | ||
|
|
fcbd84f873 | ||
|
|
cef9374416 | ||
|
|
9ad3c96d43 | ||
|
|
48400440dd | ||
|
|
0e812a2479 | ||
|
|
f319721e01 | ||
|
|
9de625d617 |
@@ -68,7 +68,8 @@ jobs:
|
||||
- name: Build desktop app
|
||||
run: |
|
||||
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) ─────────────────────────────────────────
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ build-daemon.sh
|
||||
deploy-web.sh
|
||||
deploy-daemon.sh
|
||||
serve-web.sh
|
||||
push.sh
|
||||
web/public/config.js
|
||||
cmd/app/frontend/dist/*
|
||||
!cmd/app/frontend/dist/.gitkeep
|
||||
|
||||
@@ -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.
|
||||
|
||||
37
FUTURE.md
37
FUTURE.md
@@ -91,10 +91,10 @@ DM rooms (`dm:<peerId>`) appear automatically in both interfaces when messages a
|
||||
- Live progress bar per active transfer
|
||||
- Push (📎) sends directly to a peer without them needing to share a folder
|
||||
|
||||
**Not yet done:** daemon-side resume UX (IPC event to surface resumable transfers to the UI on reconnect).
|
||||
On daemon start, the download directory is scanned for `.tmp.meta` sidecars and a `resumable_transfers` IPC event is emitted so the UI can show pending transfers with a progress bar.
|
||||
|
||||
### Native UI
|
||||
Web frontend (React, already built) + [Wails v2](https://wails.io) shell for native packaging. Wails is Go-native — no Rust toolchain required. The daemon runs embedded in the same process; the webview connects to the existing WebSocket IPC at `ws://127.0.0.1:17338`. Scaffolded in `cmd/app/`; build with `./build-app.sh`. Remaining work: system tray, OS notifications.
|
||||
Web frontend (React, already built) + [Wails v2](https://wails.io) shell for native packaging. Wails is Go-native — no Rust toolchain required. The daemon runs embedded in the same process; the webview connects to the existing WebSocket IPC at `ws://127.0.0.1:17338`. Built in `cmd/app/` via `./build-app.sh`. System tray (Linux/Windows) and OS notifications are implemented. macOS menu-bar tray requires Cocoa main-thread integration — currently a stub.
|
||||
|
||||
---
|
||||
|
||||
@@ -133,6 +133,39 @@ Web frontend (React, already built) + [Wails v2](https://wails.io) shell for nat
|
||||
| ✅ shipped | PWA manifest — installable via "Add to Home Screen" on iOS and Android |
|
||||
| ✅ shipped | Native desktop app (Wails 2) — system tray (Linux/Windows), OS notifications, single binary |
|
||||
| ✅ shipped | Gitea Actions CI — server binaries (all platforms via cross-compile) + desktop app (Linux amd64) |
|
||||
| ✅ shipped | File transfer resume UX — resumable transfers surfaced in Transfers panel on reconnect |
|
||||
| ✅ 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) |
|
||||
|
||||
### 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:
|
||||
|
||||
1. **Service worker** — intercepts `push` events and shows OS notifications via `showNotification()`.
|
||||
2. **VAPID key pair** — generated once by the daemon (`-vapid-key` flag); the public key is served to the browser so it can subscribe.
|
||||
3. **Subscription persistence** — the browser's `PushSubscription` JSON is sent to the daemon over IPC (`register_push` command). The daemon stores it per-network-per-peer.
|
||||
4. **Daemon relay** — when a `message_received` event fires with no active IPC WebSocket connection, the daemon POSTs a Web Push notification to the stored subscription endpoint.
|
||||
|
||||
This keeps the architecture clean: the daemon already runs in the background; it becomes the notification relay. No third-party push server is required for self-hosted setups (coturn already in use for TURN; a lightweight Web Push POST is similar).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
# Proposal: P2P Message History Gossip
|
||||
|
||||
## Goals
|
||||
|
||||
- New peers joining a network can retrieve recent message history from existing peers
|
||||
- No central storage — history lives only in peer daemons (SQLite)
|
||||
- No new trust requirements — history is shared only over already-established encrypted DataChannels
|
||||
- No duplicates in the local store or UI
|
||||
- No conflicts — history gossip is append-only and idempotent
|
||||
|
||||
---
|
||||
|
||||
## Privacy Model
|
||||
|
||||
History is shared **peer-to-peer over the encrypted mesh**, never via the anchor. The anchor remains dumb — it sees only signaling blobs. A peer only receives history from peers they have successfully completed a YAW/2 handshake with, so the same trust boundary as live messages applies.
|
||||
|
||||
A peer can choose not to share history by ignoring `history_request` messages — the protocol is advisory, not mandatory.
|
||||
|
||||
---
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
Two new YAW/2 extension messages (added to EXTENSIONS.md):
|
||||
|
||||
### `history_request`
|
||||
|
||||
Sent by a newly-connected peer to one or more existing peers shortly after the handshake completes.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "history_request",
|
||||
"room": "general",
|
||||
"since": "2026-01-01T00:00:00Z",
|
||||
"limit": 200
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|-----------------|----------------------------------------------------------|
|
||||
| `room` | string | Room name to request history for. One request per room. |
|
||||
| `since` | ISO 8601 or "" | Only return messages newer than this timestamp. Empty = return up to `limit` most recent. |
|
||||
| `limit` | int (max 500) | Maximum number of messages to return. Responder may return fewer. |
|
||||
|
||||
### `history_chunk`
|
||||
|
||||
Response from an existing peer. May be sent in multiple chunks if `limit` is large.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "history_chunk",
|
||||
"room": "general",
|
||||
"messages": [
|
||||
{
|
||||
"id": "sha256:<hex>",
|
||||
"from": "<peer-alias>",
|
||||
"from_id": "<hex-pubkey>",
|
||||
"body": "hello",
|
||||
"ts": "2026-06-01T12:00:00Z",
|
||||
"room": "general"
|
||||
}
|
||||
],
|
||||
"done": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|---------|----------------------------------------------------------|
|
||||
| `messages` | array | Ordered oldest-first. |
|
||||
| `done` | bool | `true` on the final chunk. Receiver may display after this. |
|
||||
|
||||
---
|
||||
|
||||
## Message Identity and Deduplication
|
||||
|
||||
Each message has a **content-addressed ID**:
|
||||
|
||||
```
|
||||
id = "sha256:" + hex(SHA-256(from_id || room || ts || body))
|
||||
```
|
||||
|
||||
- Computed by the original sender and included in every live message going forward
|
||||
- The SQLite `messages` table gains an `id TEXT UNIQUE` column
|
||||
- On insert, use `INSERT OR IGNORE` — receiving the same message twice (live or via gossip) is a no-op
|
||||
- The UI sorts by `ts`, so late-arriving history slots in correctly without reordering visible messages
|
||||
|
||||
Legacy messages (before this feature) have no `id`. They are assigned a local-only ID on migration and are never gossipped (they have no canonical ID the receiver could deduplicate against).
|
||||
|
||||
---
|
||||
|
||||
## Daemon-Side Implementation
|
||||
|
||||
### SQLite schema change
|
||||
|
||||
```sql
|
||||
ALTER TABLE messages ADD COLUMN msg_id TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages(msg_id) WHERE msg_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### Handling `history_request`
|
||||
|
||||
```
|
||||
peer sends history_request{room, since, limit}
|
||||
→ query SQLite: SELECT * FROM messages WHERE room=? AND ts>? ORDER BY ts ASC LIMIT ?
|
||||
→ send history_chunk{room, messages, done:true}
|
||||
```
|
||||
|
||||
Responder enforces:
|
||||
- `limit` capped at 500
|
||||
- Only messages the responder itself received or sent (no re-gossipping of gossipped history to avoid amplification)
|
||||
- Rate limit: one `history_request` per peer per room per 60 seconds
|
||||
|
||||
### Handling `history_chunk`
|
||||
|
||||
```
|
||||
for each message in chunk:
|
||||
INSERT OR IGNORE INTO messages (msg_id, room, from_alias, from_id, body, ts) VALUES (...)
|
||||
emit IPC event: history_loaded{room, count}
|
||||
```
|
||||
|
||||
### When to request
|
||||
|
||||
- After handshake completes with the **first** peer in a network (only ask one peer — avoids fan-out)
|
||||
- Request rooms the local peer knows about (from its own SQLite `rooms` table)
|
||||
- If no rooms known yet: request `"general"` only; discover others from incoming live messages
|
||||
|
||||
---
|
||||
|
||||
## IPC / UI Integration
|
||||
|
||||
New IPC event emitted after history is loaded:
|
||||
|
||||
```json
|
||||
{ "type": "history_loaded", "network_id": "...", "room": "general", "count": 47 }
|
||||
```
|
||||
|
||||
The web UI and TUI insert a visual separator above the first gossipped message:
|
||||
|
||||
```
|
||||
── 47 earlier messages ─────────────────────────────
|
||||
[12:03] alice: hey
|
||||
[12:04] bob: yo
|
||||
── live ─────────────────────────────────────────────
|
||||
[14:22] you joined
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What This Does Not Do
|
||||
|
||||
- **No conflict resolution** — messages are immutable append-only records; there is nothing to conflict
|
||||
- **No ordering guarantee beyond timestamp** — if two peers sent messages at the same millisecond, both are stored; the UI sorts by `ts` then `msg_id` for a stable tiebreak
|
||||
- **No full sync** — gossip is bounded by `limit` and `since`; it is not a replication protocol
|
||||
- **No anchor involvement** — the anchor never sees history
|
||||
- **No history from peers who were offline** — if no peer with history is online when you join, you get nothing (acceptable given the trust model)
|
||||
58
README.md
58
README.md
@@ -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
|
||||
|
||||
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 |
|
||||
| `-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":"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 +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":"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":"..."}
|
||||
```
|
||||
|
||||
641
cmd/tui/main.go
641
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"
|
||||
|
||||
@@ -28,18 +30,22 @@ 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"))
|
||||
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
|
||||
@@ -93,7 +131,7 @@ type entry struct {
|
||||
|
||||
type model struct {
|
||||
ipcPort int
|
||||
networkName string
|
||||
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
|
||||
// keyed by "netId:room"
|
||||
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
|
||||
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"},
|
||||
initialNetwork: network,
|
||||
messages: make(map[string][]entry),
|
||||
unread: make(map[string]bool),
|
||||
peers: make(map[proto.PeerID]string),
|
||||
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())
|
||||
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())
|
||||
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)
|
||||
}
|
||||
m.peers = make(map[proto.PeerID]string)
|
||||
m.peerOrder = nil
|
||||
if ni.LocalPeer != 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 {
|
||||
m.peers[p.ID] = p.Alias
|
||||
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 {
|
||||
m = m.addRoom(r)
|
||||
n.addRoom(r)
|
||||
}
|
||||
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
||||
|
||||
case proto.EvtRoomCreated:
|
||||
m = m.addRoom(evt.Room)
|
||||
for _, p := range evt.KnownPeers {
|
||||
n.knownPeers[p.ID] = p.Alias
|
||||
}
|
||||
}
|
||||
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 {
|
||||
pid := evt.Peer.ID
|
||||
if _, ok := m.peers[pid]; !ok {
|
||||
m.peerOrder = append(m.peerOrder, pid)
|
||||
netID := evt.NetworkID
|
||||
if netID == "" && len(m.nets) > 0 {
|
||||
netID = m.nets[0].id
|
||||
}
|
||||
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:
|
||||
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,42 +713,39 @@ 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] {
|
||||
ts := styleMsgTime.Render(e.at.Format("15:04"))
|
||||
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 {
|
||||
from = styleMsgMe.Render(e.from)
|
||||
} 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 ""
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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, 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, 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[room] {
|
||||
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,17 +863,19 @@ 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)"))
|
||||
n := m.activeNetData()
|
||||
if n != nil {
|
||||
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 {
|
||||
alias := n.peers[pid]
|
||||
if alias == "" {
|
||||
alias = shortID(pid)
|
||||
}
|
||||
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,26 +930,18 @@ func roomLabel(room string, peers map[proto.PeerID]string) string {
|
||||
}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||
if peers != nil {
|
||||
if a, ok := peers[pid]; ok && a != "" {
|
||||
return "@ " + a
|
||||
}
|
||||
}
|
||||
return "@ " + shortID(pid)
|
||||
}
|
||||
return "#" + room
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -614,6 +954,14 @@ func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
|
||||
return out
|
||||
}
|
||||
|
||||
func formatMsgTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
return t.Format("Jan 2 15:04")
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -625,11 +973,10 @@ func min(a, b int) int {
|
||||
|
||||
func main() {
|
||||
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")
|
||||
flag.Parse()
|
||||
|
||||
// --join overrides --network.
|
||||
if *joinInvite != "" {
|
||||
inv, err := invite.Decode(*joinInvite)
|
||||
if err != nil {
|
||||
@@ -639,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(),
|
||||
|
||||
@@ -127,6 +127,8 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
|
||||
// Send initial state snapshot.
|
||||
send(stateSnapshot(mgr))
|
||||
// Send stored history for each room so the UI is populated on connect.
|
||||
sendStoredHistory(mgr, send)
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
@@ -242,6 +244,34 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
})
|
||||
}
|
||||
|
||||
case proto.CmdSendReaction:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("send_reaction: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
if cmd.ReactionMID == "" || cmd.ReactionEmoji == "" {
|
||||
send(errMsg("send_reaction: reaction_mid and reaction_emoji are required"))
|
||||
continue
|
||||
}
|
||||
wire, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgReaction,
|
||||
ReactionMID: cmd.ReactionMID,
|
||||
ReactionEmoji: cmd.ReactionEmoji,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n.Mesh.Broadcast(wire)
|
||||
n.Mesh.SaveReaction(cmd.ReactionMID, cmd.ReactionEmoji, string(n.Identity.PeerID()))
|
||||
n.Mesh.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtReaction,
|
||||
NetworkID: n.ID,
|
||||
PeerID: ptr(n.Identity.PeerID()),
|
||||
ReactionMID: cmd.ReactionMID,
|
||||
ReactionEmoji: cmd.ReactionEmoji,
|
||||
})
|
||||
|
||||
case proto.CmdCreateRoom:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
@@ -457,11 +487,74 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
|
||||
msg.Rooms = append(msg.Rooms, r)
|
||||
}
|
||||
}
|
||||
// Include all historically-known peers so the UI can resolve aliases in history.
|
||||
if known, err := all[0].Store.KnownPeers(); err == nil {
|
||||
connected := map[proto.PeerID]bool{}
|
||||
for _, p := range msg.ConnectedPeers {
|
||||
connected[p.ID] = true
|
||||
}
|
||||
for id, alias := range known {
|
||||
if connected[id] {
|
||||
continue // already in ConnectedPeers
|
||||
}
|
||||
msg.KnownPeers = append(msg.KnownPeers, proto.PeerInfo{
|
||||
ID: id,
|
||||
Alias: alias,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// sendStoredHistory pushes recent messages for all known rooms to a newly-connected IPC client.
|
||||
func sendStoredHistory(mgr *netmgr.Manager, send func(proto.IpcMessage)) {
|
||||
all := mgr.All()
|
||||
if len(all) == 0 {
|
||||
return
|
||||
}
|
||||
n := all[0] // use first network; multi-network history follows same pattern
|
||||
if n.Store == nil {
|
||||
return
|
||||
}
|
||||
rooms := []string{"general"}
|
||||
if extra, err := n.Store.Rooms(); err == nil {
|
||||
rooms = append(rooms, extra...)
|
||||
}
|
||||
for _, room := range rooms {
|
||||
msgs, err := n.Store.RecentMessagesSince(room, 0, 200)
|
||||
if err != nil || len(msgs) == 0 {
|
||||
continue
|
||||
}
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtHistoryLoaded,
|
||||
NetworkID: n.ID,
|
||||
Room: room,
|
||||
Messages: msgs,
|
||||
})
|
||||
// Send stored reactions for this room's messages.
|
||||
rxns, err := n.Store.ReactionsForRoom(room)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for mid, byEmoji := range rxns {
|
||||
for emoji, fromPeers := range byEmoji {
|
||||
for _, fromPeer := range fromPeers {
|
||||
pid := proto.PeerID(fromPeer)
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtReaction,
|
||||
NetworkID: n.ID,
|
||||
PeerID: &pid,
|
||||
ReactionMID: mid,
|
||||
ReactionEmoji: emoji,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errMsg(s string) proto.IpcMessage {
|
||||
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,17 @@ func (m *Mesh) AddPeer(conn *PeerConn) {
|
||||
})
|
||||
}
|
||||
|
||||
// SaveReaction persists a reaction if a store is configured.
|
||||
// Duplicate (mid, emoji, fromPeer) triples are silently dropped.
|
||||
func (m *Mesh) SaveReaction(mid, emoji, fromPeer string) {
|
||||
if m.Store == nil {
|
||||
return
|
||||
}
|
||||
if err := m.Store.SaveReaction(mid, emoji, fromPeer); err != nil {
|
||||
log.Printf("mesh: store reaction %s/%s: %v", mid, emoji, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SaveMessage persists a chat message if a store is configured.
|
||||
// Duplicate mids are silently dropped.
|
||||
func (m *Mesh) SaveMessage(msg *proto.ChatMessage) {
|
||||
|
||||
@@ -307,6 +307,18 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
||||
case proto.MsgHistoryChunk:
|
||||
go m.HandleHistoryChunk(msg.Room, msg.History)
|
||||
|
||||
case proto.MsgReaction:
|
||||
if msg.ReactionMID == "" || msg.ReactionEmoji == "" {
|
||||
return
|
||||
}
|
||||
m.SaveReaction(msg.ReactionMID, msg.ReactionEmoji, string(from))
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtReaction,
|
||||
PeerID: peerIDPtr(from),
|
||||
ReactionMID: msg.ReactionMID,
|
||||
ReactionEmoji: msg.ReactionEmoji,
|
||||
})
|
||||
|
||||
case proto.MsgPing:
|
||||
log.Printf("mesh: ping from %s", from.Short())
|
||||
case proto.MsgPong:
|
||||
|
||||
@@ -91,6 +91,47 @@ func writePartialMeta(path string, t *inboundTransfer) {
|
||||
os.WriteFile(path, data, 0o644) //nolint:errcheck
|
||||
}
|
||||
|
||||
// ScanResumable scans the download directory for .tmp.meta sidecars left by
|
||||
// interrupted transfers and emits a resumable_transfers IPC event listing them.
|
||||
// Called once after a network is joined so the UI can show pending transfers.
|
||||
func (m *Mesh) ScanResumable() {
|
||||
if m.DownloadDir == "" {
|
||||
return
|
||||
}
|
||||
metas, _ := filepath.Glob(filepath.Join(m.DownloadDir, "*.tmp.meta"))
|
||||
var files []proto.ResumableFile
|
||||
for _, mp := range metas {
|
||||
data, err := os.ReadFile(mp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var meta partialMeta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
continue
|
||||
}
|
||||
tp := strings.TrimSuffix(mp, ".meta")
|
||||
info, err := os.Stat(tp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
files = append(files, proto.ResumableFile{
|
||||
Name: meta.Name,
|
||||
SHA256: meta.SHA256,
|
||||
From: meta.From,
|
||||
Size: meta.Size,
|
||||
Offset: info.Size(),
|
||||
})
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return
|
||||
}
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtResumableTransfers,
|
||||
ResumableFiles: files,
|
||||
})
|
||||
log.Printf("transfer: %d resumable transfer(s) found in %s", len(files), m.DownloadDir)
|
||||
}
|
||||
|
||||
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
|
||||
// file-offer to peerID over the existing "yaw" DataChannel.
|
||||
func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error {
|
||||
|
||||
@@ -162,6 +162,8 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
||||
}()
|
||||
}
|
||||
|
||||
go m.ScanResumable()
|
||||
|
||||
mgr.emit(proto.IpcMessage{
|
||||
Type: proto.EvtNetworkJoined,
|
||||
NetworkID: netID,
|
||||
@@ -249,6 +251,8 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
||||
}()
|
||||
}
|
||||
|
||||
go m.ScanResumable()
|
||||
|
||||
mgr.emit(proto.IpcMessage{
|
||||
Type: proto.EvtNetworkJoined,
|
||||
NetworkID: netID,
|
||||
|
||||
@@ -51,6 +51,7 @@ const (
|
||||
MsgPong MsgType = "pong"
|
||||
MsgHistoryRequest MsgType = "history_request"
|
||||
MsgHistoryChunk MsgType = "history_chunk"
|
||||
MsgReaction MsgType = "reaction"
|
||||
)
|
||||
|
||||
// PmMessage is a private message sent directly over a single peer link (§8 "pm").
|
||||
@@ -97,6 +98,19 @@ type PeerMessage struct {
|
||||
// history_chunk fields
|
||||
History []HistoryEntry `json:"history,omitempty"`
|
||||
HistoryDone bool `json:"history_done,omitempty"`
|
||||
|
||||
// reaction fields
|
||||
ReactionMID string `json:"reaction_mid,omitempty"`
|
||||
ReactionEmoji string `json:"reaction_emoji,omitempty"`
|
||||
}
|
||||
|
||||
// ResumableFile describes a partially-downloaded file found on daemon startup.
|
||||
type ResumableFile struct {
|
||||
Name string `json:"name"`
|
||||
SHA256 string `json:"sha256"`
|
||||
From string `json:"from"` // peer ID hex
|
||||
Size int64 `json:"size"`
|
||||
Offset int64 `json:"offset"` // bytes already received
|
||||
}
|
||||
|
||||
// HistoryEntry is one message in a history_chunk response.
|
||||
@@ -271,6 +285,7 @@ const (
|
||||
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
||||
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
|
||||
CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path
|
||||
CmdSendReaction IpcMsgType = "send_reaction" // fields: network_id, reaction_mid, reaction_emoji
|
||||
|
||||
// Events (daemon → UI)
|
||||
EvtMessageReceived IpcMsgType = "message_received"
|
||||
@@ -292,6 +307,8 @@ const (
|
||||
EvtSharesList IpcMsgType = "shares_list"
|
||||
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
|
||||
EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages
|
||||
EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files
|
||||
EvtReaction IpcMsgType = "reaction" // fields: reaction_mid, reaction_emoji, peer_id
|
||||
)
|
||||
|
||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
||||
@@ -340,6 +357,7 @@ type IpcMessage struct {
|
||||
MasterID string `json:"master_id,omitempty"` // daemon's master public key hex
|
||||
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
|
||||
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
||||
KnownPeers []PeerInfo `json:"known_peers,omitempty"` // historically seen, not currently connected
|
||||
Rooms []string `json:"rooms,omitempty"`
|
||||
// multi-network: all joined networks (additive)
|
||||
Networks []NetworkInfo `json:"networks,omitempty"`
|
||||
@@ -347,6 +365,9 @@ type IpcMessage struct {
|
||||
InviteGenerated string `json:"invite,omitempty"`
|
||||
Files []FileEntry `json:"files,omitempty"`
|
||||
Messages []ChatMessage `json:"messages,omitempty"` // history_loaded
|
||||
ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers
|
||||
ReactionMID string `json:"reaction_mid,omitempty"` // reaction
|
||||
ReactionEmoji string `json:"reaction_emoji,omitempty"` // reaction
|
||||
Shares []ShareEntry `json:"shares,omitempty"`
|
||||
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
|
||||
// export_identity / import_identity
|
||||
|
||||
@@ -38,10 +38,19 @@ CREATE TABLE IF NOT EXISTS rooms (
|
||||
|
||||
// migrations run after the base schema. ALTER TABLE ADD COLUMN fails with
|
||||
// "duplicate column name" on subsequent opens — we swallow that error.
|
||||
// CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS are idempotent.
|
||||
var migrations = []string{
|
||||
// EXT-007: canonical message ID for history dedup (NULL for pre-feature messages).
|
||||
`ALTER TABLE messages ADD COLUMN msg_id TEXT`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages (msg_id) WHERE msg_id IS NOT NULL`,
|
||||
// Reactions: (mid, emoji, from_peer) triple is the unique dedup key.
|
||||
`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)
|
||||
)`,
|
||||
}
|
||||
|
||||
// Store is a local SQLite-backed message and peer store.
|
||||
@@ -107,7 +116,7 @@ func (s *Store) PeerAlias(peerID proto.PeerID) string {
|
||||
// RecentMessages returns up to limit messages for a room, oldest first.
|
||||
func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) {
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, body, sent_at FROM messages
|
||||
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||
WHERE room = ?
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, limit,
|
||||
@@ -115,15 +124,23 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
||||
}
|
||||
|
||||
// RecentMessagesSince returns up to limit messages for a room with ts > sinceMs, oldest first.
|
||||
// Only messages that have a msg_id (i.e. gossip-safe) are returned.
|
||||
// sinceMs == 0 returns the most recent messages regardless of timestamp.
|
||||
func (s *Store) RecentMessagesSince(room string, sinceMs int64, limit int) ([]proto.ChatMessage, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
if sinceMs == 0 {
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||
WHERE room = ?
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, limit,
|
||||
)
|
||||
}
|
||||
since := time.UnixMilli(sinceMs).UTC()
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, body, sent_at FROM messages
|
||||
WHERE room = ? AND sent_at > ? AND msg_id IS NOT NULL
|
||||
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||
WHERE room = ? AND sent_at > ?
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, since, limit,
|
||||
)
|
||||
@@ -141,7 +158,7 @@ func (s *Store) queryMessages(q string, args ...any) ([]proto.ChatMessage, error
|
||||
var m proto.ChatMessage
|
||||
var from string
|
||||
var sentAt time.Time
|
||||
if err := rows.Scan(&m.Mid, &from, &m.Text, &sentAt); err != nil {
|
||||
if err := rows.Scan(&m.Mid, &from, &m.Room, &m.Text, &sentAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.From = proto.PeerID(from)
|
||||
@@ -200,6 +217,43 @@ func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SaveReaction persists a reaction. Duplicate (mid, emoji, from_peer) triples are silently ignored.
|
||||
func (s *Store) SaveReaction(mid, emoji, fromPeer string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO reactions (mid, emoji, from_peer, reacted_at) VALUES (?, ?, ?, ?)`,
|
||||
mid, emoji, fromPeer, time.Now().UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReactionsForRoom returns all reactions for messages in a given room.
|
||||
// Result: mid → emoji → []fromPeer (ordered by reaction time).
|
||||
func (s *Store) ReactionsForRoom(room string) (map[string]map[string][]string, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT r.mid, r.emoji, r.from_peer
|
||||
FROM reactions r
|
||||
JOIN messages m ON m.mid = r.mid
|
||||
WHERE m.room = ?
|
||||
ORDER BY r.reacted_at
|
||||
`, room)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]map[string][]string)
|
||||
for rows.Next() {
|
||||
var mid, emoji, from string
|
||||
if err := rows.Scan(&mid, &emoji, &from); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out[mid] == nil {
|
||||
out[mid] = make(map[string][]string)
|
||||
}
|
||||
out[mid][emoji] = append(out[mid][emoji], from)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullableString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
|
||||
@@ -33,6 +33,10 @@ input:focus { border-color: var(--accent); }
|
||||
.onboarding-section { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.join-form { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
||||
.join-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
|
||||
.saved-networks { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||
.saved-network-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.network-chip { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 20px; padding: 4px 14px; font-size: 13px; }
|
||||
.network-chip:hover { border-color: var(--accent); color: var(--accent); background: var(--surface); }
|
||||
button.primary { background: var(--accent); width: 100%; padding: 8px; font-size: 14px; }
|
||||
.toggle-link { background: none; color: var(--muted); font-size: 12px; padding: 4px 0; text-align: left; }
|
||||
.toggle-link:hover { color: var(--text); }
|
||||
@@ -88,7 +92,7 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
||||
.messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; }
|
||||
.message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
|
||||
.message:hover { background: rgba(255,255,255,0.02); }
|
||||
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 52px; }
|
||||
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 72px; }
|
||||
.message-alias { font-weight: 600; font-size: 13px; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; width: 90px; padding-right: 10px; }
|
||||
.message.mine .message-alias { color: var(--accent); }
|
||||
.message-text { word-break: break-word; font-size: 14px; color: var(--text); }
|
||||
@@ -158,3 +162,86 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
||||
.file-entry-dir { cursor: pointer; }
|
||||
.file-entry-dir:hover { background: rgba(255,255,255,0.04); }
|
||||
.file-entry-icon { font-size: 12px; flex-shrink: 0; }
|
||||
.history-divider { display: flex; align-items: center; gap: 8px; margin: 10px 0 6px; color: var(--muted); font-size: 11px; }
|
||||
.history-divider::before, .history-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
/* ── message links + image preview ── */
|
||||
.msg-link { color: var(--accent); text-decoration: underline; word-break: break-all; }
|
||||
.msg-link:hover { opacity: 0.8; }
|
||||
.message-text { display: flex; flex-direction: column; gap: 4px; }
|
||||
.msg-image-preview { max-width: 320px; max-height: 200px; border-radius: 6px; border: 1px solid var(--border); margin-top: 4px; object-fit: contain; display: block; }
|
||||
|
||||
/* ── reactions ── */
|
||||
.message-wrapper { display: flex; flex-direction: column; padding: 0; }
|
||||
.message-wrapper .message { padding: 2px 16px; }
|
||||
.reaction-add { background: none; color: var(--muted); font-size: 13px; padding: 0 4px; line-height: 1; opacity: 0; transition: opacity 0.1s; margin-left: 4px; flex-shrink: 0; }
|
||||
.message-wrapper:hover .reaction-add { opacity: 1; }
|
||||
.reaction-add:hover { color: var(--accent); background: none; }
|
||||
.reaction-picker { display: flex; gap: 4px; padding: 4px 16px 2px; }
|
||||
.reaction-picker-btn { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-size: 18px; padding: 2px 6px; line-height: 1.4; cursor: pointer; }
|
||||
.reaction-picker-btn:hover { border-color: var(--accent); background: rgba(124,106,247,0.12); }
|
||||
.reaction-bar { display: flex; flex-wrap: wrap; gap: 4px; padding: 2px 16px 4px; }
|
||||
.reaction-chip { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; font-size: 13px; padding: 1px 8px; cursor: pointer; color: var(--text); }
|
||||
.reaction-chip:hover { border-color: var(--accent); background: rgba(124,106,247,0.1); }
|
||||
.reaction-chip.mine { border-color: var(--accent); background: rgba(124,106,247,0.18); }
|
||||
|
||||
/* ── mobile hamburger / close buttons ── */
|
||||
.menu-btn-mobile { display: none; background: none; color: var(--muted); font-size: 18px; padding: 0 8px 0 0; line-height: 1; }
|
||||
.menu-btn-mobile:hover { color: var(--text); background: none; }
|
||||
.sidebar-close-mobile { display: none; background: none; color: var(--muted); font-size: 14px; padding: 2px 4px; }
|
||||
.sidebar-close-mobile:hover { color: var(--text); background: none; }
|
||||
|
||||
/* ── responsive layout ── */
|
||||
@media (max-width: 600px) {
|
||||
:root { --sidebar-w: 80vw; }
|
||||
|
||||
.chat-layout {
|
||||
grid-template-columns: 1fr;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* sidebar slides in over the top */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: var(--sidebar-w);
|
||||
height: 100%;
|
||||
z-index: 100;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.22s ease;
|
||||
box-shadow: 4px 0 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
.chat-layout.sidebar-open .sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* dim overlay behind open sidebar */
|
||||
.chat-layout.sidebar-open::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.menu-btn-mobile { display: inline-block; }
|
||||
.sidebar-close-mobile { display: inline-block; }
|
||||
|
||||
/* message pane fills full width */
|
||||
.chat-layout > .message-pane { grid-column: 1; }
|
||||
|
||||
/* file browser stacks below on mobile */
|
||||
.chat-layout.has-file-browser { grid-template-columns: 1fr; }
|
||||
.chat-layout.has-file-browser > .file-browser { border-left: none; border-top: 1px solid var(--border); max-height: 40vh; overflow-y: auto; }
|
||||
|
||||
/* slightly larger tap targets */
|
||||
.sidebar-item { padding: 8px 12px; font-size: 14px; }
|
||||
.peer-row { padding: 6px 12px; }
|
||||
.peer-row-actions { opacity: 1; }
|
||||
.peer-action { font-size: 18px; padding: 2px 6px; }
|
||||
|
||||
/* message layout: stack alias above text on very narrow screens */
|
||||
.message { flex-wrap: wrap; }
|
||||
.message-ts { width: auto; min-width: 56px; }
|
||||
.message-alias { width: auto; }
|
||||
}
|
||||
|
||||
@@ -433,9 +433,17 @@ class PeerConn {
|
||||
nick: m['nick'] as string || '', caps: m['caps'] || []
|
||||
})
|
||||
} else if (m['type'] === 'chat') {
|
||||
const ts = (m['ts'] as number) || Date.now()
|
||||
this.on('chat', {
|
||||
peer: this.peerId, room: m['room'] as string || 'general',
|
||||
text: m['text'] as string, ts: m['ts'] as number || Date.now()
|
||||
text: m['text'] as string, ts,
|
||||
mid: (m['mid'] as string) || `${this.peerId}-${ts}`,
|
||||
})
|
||||
} else if (m['type'] === 'reaction') {
|
||||
this.on('reaction', {
|
||||
peer: this.peerId,
|
||||
mid: m['reaction_mid'] as string,
|
||||
emoji: m['reaction_emoji'] as string,
|
||||
})
|
||||
} else if (m['type'] === 'pm') {
|
||||
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
|
||||
@@ -556,14 +564,18 @@ class PeerConn {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
sendChat(room: string, text: string) {
|
||||
this._dc({ type: 'chat', room, text, ts: Date.now() })
|
||||
sendChat(room: string, text: string, mid: string) {
|
||||
this._dc({ type: 'chat', room, text, ts: Date.now(), mid })
|
||||
}
|
||||
|
||||
sendPm(text: string) {
|
||||
this._dc({ type: 'pm', text, ts: Date.now() })
|
||||
}
|
||||
|
||||
sendReaction(mid: string, emoji: string) {
|
||||
this._dc({ type: 'reaction', reaction_mid: mid, reaction_emoji: emoji })
|
||||
}
|
||||
|
||||
private _dc(obj: object) {
|
||||
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
|
||||
}
|
||||
@@ -755,9 +767,17 @@ export class BrowserAdapter {
|
||||
public_key: data['peer'] as string, created_at: new Date().toISOString()
|
||||
},
|
||||
})
|
||||
} else if (event === 'reaction') {
|
||||
this.emit({
|
||||
type: 'reaction',
|
||||
network_id: this.networkId,
|
||||
peer_id: data['peer'] as unknown as import('../types').PeerID,
|
||||
reaction_mid: data['mid'] as string,
|
||||
reaction_emoji: data['emoji'] as string,
|
||||
})
|
||||
} else if (event === 'chat') {
|
||||
const ts = (data['ts'] as number) || Date.now()
|
||||
const mid = `${data['peer']}-${ts}`
|
||||
const mid = (data['mid'] as string) || `${data['peer']}-${ts}`
|
||||
this.emit({
|
||||
type: 'message_received',
|
||||
network_id: this.networkId,
|
||||
@@ -910,8 +930,8 @@ export class BrowserAdapter {
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Broadcast
|
||||
this.peers.forEach(p => p.sendChat(room, text))
|
||||
// Broadcast — include mid on the wire so recipients can reference it in reactions
|
||||
this.peers.forEach(p => p.sendChat(room, text, mid))
|
||||
this.emit({
|
||||
type: 'message_received',
|
||||
network_id: this.networkId,
|
||||
@@ -924,6 +944,22 @@ export class BrowserAdapter {
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'send_reaction') {
|
||||
const mid = msg.reaction_mid
|
||||
const emoji = msg.reaction_emoji
|
||||
if (!mid || !emoji) return
|
||||
this.peers.forEach(p => p.sendReaction(mid, emoji))
|
||||
// Emit locally so the sender sees their own reaction immediately
|
||||
this.emit({
|
||||
type: 'reaction',
|
||||
network_id: this.networkId,
|
||||
peer_id: this.identity.id as unknown as import('../types').PeerID,
|
||||
reaction_mid: mid,
|
||||
reaction_emoji: emoji,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'export_identity') {
|
||||
if (!msg.passphrase) return
|
||||
try {
|
||||
|
||||
@@ -1,16 +1,75 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function MessagePane() {
|
||||
const { messages, activeRoom, activeNetworkId, localPeer, connectedPeers, send } = useWaste()
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const todayMs = today.getTime()
|
||||
|
||||
function formatTs(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
if (ts >= todayMs) return time
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time
|
||||
}
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"']+/g
|
||||
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|svg)(\?[^\s]*)?$/i
|
||||
|
||||
function renderText(text: string): React.ReactNode {
|
||||
const parts: React.ReactNode[] = []
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
URL_RE.lastIndex = 0
|
||||
while ((m = URL_RE.exec(text)) !== null) {
|
||||
if (m.index > last) parts.push(text.slice(last, m.index))
|
||||
const url = m[0]
|
||||
const isImage = IMAGE_EXT_RE.test(url) || url.startsWith('blob:') || url.startsWith('data:image')
|
||||
parts.push(
|
||||
<a key={m.index} href={url} target="_blank" rel="noopener noreferrer" className="msg-link">
|
||||
{url}
|
||||
</a>
|
||||
)
|
||||
if (isImage) {
|
||||
parts.push(
|
||||
<img key={`img-${m.index}`} src={url} alt="" className="msg-image-preview" loading="lazy" />
|
||||
)
|
||||
}
|
||||
last = m.index + url.length
|
||||
}
|
||||
if (last < text.length) parts.push(text.slice(last))
|
||||
return parts.length > 1 ? <>{parts}</> : text
|
||||
}
|
||||
|
||||
const EMOJI_SET = ['👍', '❤️', '😂', '😮', '😢', '🙏']
|
||||
|
||||
export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
|
||||
const { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, knownPeers, reactions, sendReaction, send } = useWaste()
|
||||
const [draft, setDraft] = useState('')
|
||||
const [pickerMid, setPickerMid] = useState<string | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const roomMessages = messages[activeRoom] ?? []
|
||||
const msgKey = activeNetworkId ? `${activeNetworkId}:${activeRoom}` : activeRoom
|
||||
const roomMessages = messages[msgKey] ?? []
|
||||
const cutoff = historyCutoff[msgKey] ?? 0
|
||||
|
||||
const firstLiveIdx = cutoff > 0
|
||||
? roomMessages.findIndex(m => m.ts > cutoff)
|
||||
: -1
|
||||
const dividerIdx = cutoff > 0
|
||||
? (firstLiveIdx === -1 ? 0 : firstLiveIdx)
|
||||
: -1
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [roomMessages.length])
|
||||
|
||||
// Close picker when clicking outside
|
||||
useEffect(() => {
|
||||
if (!pickerMid) return
|
||||
const handler = () => setPickerMid(null)
|
||||
document.addEventListener('click', handler)
|
||||
return () => document.removeEventListener('click', handler)
|
||||
}, [pickerMid])
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const text = draft.trim()
|
||||
@@ -30,7 +89,21 @@ export function MessagePane() {
|
||||
|
||||
function aliasFor(fromId: string) {
|
||||
if (fromId === localPeer?.id) return localPeer.alias
|
||||
return connectedPeers.find(p => p.id === fromId)?.alias ?? fromId.slice(0, 8)
|
||||
return connectedPeers.find(p => p.id === fromId)?.alias
|
||||
?? knownPeers[fromId]
|
||||
?? fromId.slice(0, 8)
|
||||
}
|
||||
|
||||
function toggleReaction(mid: string, emoji: string, e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
if (!activeNetworkId || !mid) return
|
||||
sendReaction(activeNetworkId, mid, emoji)
|
||||
setPickerMid(null)
|
||||
}
|
||||
|
||||
function openPicker(mid: string, e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
setPickerMid(prev => prev === mid ? null : mid)
|
||||
}
|
||||
|
||||
const roomLabel = activeRoom.startsWith('dm:')
|
||||
@@ -39,18 +112,67 @@ export function MessagePane() {
|
||||
|
||||
return (
|
||||
<main className="message-pane">
|
||||
<div className="message-pane-header">{roomLabel}</div>
|
||||
<div className="message-pane-header">
|
||||
<button className="menu-btn-mobile" onClick={onMenuClick} aria-label="Menu">☰</button>
|
||||
{roomLabel}
|
||||
</div>
|
||||
|
||||
<div className="messages">
|
||||
{dividerIdx === 0 && (
|
||||
<div className="history-divider"><span>earlier messages</span></div>
|
||||
)}
|
||||
{roomMessages.map((msg, i) => {
|
||||
const mine = msg.from === localPeer?.id
|
||||
const alias = aliasFor(msg.from)
|
||||
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
const alias = aliasFor(String(msg.from))
|
||||
const ts = formatTs(msg.ts)
|
||||
const mid = msg.mid ?? ''
|
||||
const msgReactions = mid ? reactions[mid] : undefined
|
||||
const hasReactions = msgReactions && Object.keys(msgReactions).length > 0
|
||||
return (
|
||||
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}>
|
||||
<span className="message-ts">{time}</span>
|
||||
<div key={mid || i} className="message-wrapper">
|
||||
{i === dividerIdx && dividerIdx > 0 && (
|
||||
<div className="history-divider"><span>earlier messages</span></div>
|
||||
)}
|
||||
<div className={`message ${mine ? 'mine' : ''}`}>
|
||||
<span className="message-ts">{ts}</span>
|
||||
<span className="message-alias">{alias}</span>
|
||||
<span className="message-text">{msg.text}</span>
|
||||
<span className="message-text">
|
||||
{renderText(msg.text)}
|
||||
</span>
|
||||
{mid && (
|
||||
<button
|
||||
className="reaction-add"
|
||||
onClick={e => openPicker(mid, e)}
|
||||
title="React"
|
||||
>+</button>
|
||||
)}
|
||||
</div>
|
||||
{pickerMid === mid && (
|
||||
<div className="reaction-picker" onClick={e => e.stopPropagation()}>
|
||||
{EMOJI_SET.map(emoji => (
|
||||
<button key={emoji} className="reaction-picker-btn" onClick={e => toggleReaction(mid, emoji, e)}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasReactions && (
|
||||
<div className="reaction-bar">
|
||||
{Object.entries(msgReactions!).map(([emoji, fromIds]) => {
|
||||
const iMine = fromIds.includes(localPeer?.id ?? '')
|
||||
return (
|
||||
<button
|
||||
key={emoji}
|
||||
className={`reaction-chip ${iMine ? 'mine' : ''}`}
|
||||
onClick={e => toggleReaction(mid, emoji, e)}
|
||||
title={fromIds.map(aliasFor).join(', ')}
|
||||
>
|
||||
{emoji} {fromIds.length}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -26,22 +26,31 @@ function formatTs(ts: number | undefined): string {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
export function Sidebar({ onClose }: { onClose: () => void }) {
|
||||
const {
|
||||
localPeer, masterId, masterAlias,
|
||||
networks, activeNetworkId, activeRoom,
|
||||
connectedPeers, peerStatus,
|
||||
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
|
||||
customRooms, createRoom, logout,
|
||||
customRooms, createRoom, logout, send,
|
||||
} = useWaste()
|
||||
const [addingRoom, setAddingRoom] = useState(false)
|
||||
const [newRoomName, setNewRoomName] = useState('')
|
||||
const [addingNetwork, setAddingNetwork] = useState(false)
|
||||
const [newNetName, setNewNetName] = useState('')
|
||||
const [newNetAnchor, setNewNetAnchor] = useState('')
|
||||
|
||||
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
|
||||
const rooms = ['general', ...netCustomRooms]
|
||||
Object.keys(messages).forEach(r => {
|
||||
if (r.startsWith('dm:') && !rooms.includes(r)) rooms.push(r)
|
||||
if (activeNetworkId) {
|
||||
const prefix = `${activeNetworkId}:dm:`
|
||||
Object.keys(messages).forEach(k => {
|
||||
if (k.startsWith(prefix)) {
|
||||
const r = k.slice(activeNetworkId.length + 1)
|
||||
if (!rooms.includes(r)) rooms.push(r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function submitNewRoom(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -50,6 +59,25 @@ export function Sidebar() {
|
||||
setAddingRoom(false)
|
||||
}
|
||||
|
||||
function submitNewNetwork(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = newNetName.trim()
|
||||
if (!name) return
|
||||
if (adapterMode === 'browser') {
|
||||
// Persist to saved networks list in localStorage.
|
||||
const anchor = newNetAnchor.trim() || localStorage.getItem('waste_anchor_url') || ''
|
||||
const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||
if (!saved.some(n => n.name === name && n.anchor === anchor)) {
|
||||
saved.push({ name, anchor })
|
||||
localStorage.setItem('waste_saved_networks', JSON.stringify(saved))
|
||||
}
|
||||
}
|
||||
send({ type: 'join_network', network_name: name })
|
||||
setNewNetName('')
|
||||
setNewNetAnchor('')
|
||||
setAddingNetwork(false)
|
||||
}
|
||||
|
||||
const displayAlias = localPeer?.alias ?? masterAlias ?? ''
|
||||
const displayId = localPeer?.id ?? masterId ?? ''
|
||||
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
||||
@@ -90,24 +118,51 @@ export function Sidebar() {
|
||||
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
|
||||
</div>
|
||||
<button className="sidebar-logout" onClick={handleLogout} title="Leave network">⏻</button>
|
||||
<button className="sidebar-close-mobile" onClick={onClose} title="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label-row">
|
||||
<span className="sidebar-label">Networks</span>
|
||||
<span style={{ display: 'flex', gap: 2 }}>
|
||||
{activeNetworkId && (
|
||||
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link (pre-fills join form, no invite required)">🔗</button>
|
||||
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link">🔗</button>
|
||||
)}
|
||||
<button className="sidebar-add" onClick={() => setAddingNetwork(v => !v)} title="Join network">+</button>
|
||||
</span>
|
||||
</div>
|
||||
{networks.map(n => (
|
||||
<button
|
||||
key={n.network_id}
|
||||
className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`}
|
||||
onClick={() => setActiveNetwork(n.network_id)}
|
||||
onClick={() => { setActiveNetwork(n.network_id); onClose() }}
|
||||
>
|
||||
{n.network_name}
|
||||
</button>
|
||||
))}
|
||||
{addingNetwork && (
|
||||
<form className="sidebar-new-room" onSubmit={submitNewNetwork}>
|
||||
<input
|
||||
autoFocus
|
||||
value={newNetName}
|
||||
onChange={e => setNewNetName(e.target.value)}
|
||||
placeholder="network name"
|
||||
onKeyDown={e => e.key === 'Escape' && (setAddingNetwork(false), setNewNetName(''))}
|
||||
/>
|
||||
{adapterMode === 'browser' && (
|
||||
<input
|
||||
value={newNetAnchor}
|
||||
onChange={e => setNewNetAnchor(e.target.value)}
|
||||
placeholder="anchor URL (blank = current)"
|
||||
className="mono"
|
||||
style={{ fontSize: '0.75rem', marginTop: 4 }}
|
||||
/>
|
||||
)}
|
||||
<button type="submit" disabled={!newNetName.trim()} style={{ marginTop: 4, width: '100%', fontSize: '12px', padding: '3px 8px' }}>
|
||||
Join
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
@@ -119,7 +174,7 @@ export function Sidebar() {
|
||||
<button
|
||||
key={r}
|
||||
className={`sidebar-item ${r === activeRoom ? 'active' : ''}`}
|
||||
onClick={() => setActiveRoom(r)}
|
||||
onClick={() => { setActiveRoom(r); onClose() }}
|
||||
>
|
||||
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}…` : `# ${r}`}
|
||||
</button>
|
||||
|
||||
@@ -7,12 +7,13 @@ function fmt(bytes: number): string {
|
||||
}
|
||||
|
||||
export function Transfers() {
|
||||
const { pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
|
||||
const { pendingOffers, fileProgress, resumableFiles, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
|
||||
|
||||
const hasPending = Object.keys(pendingOffers).length > 0
|
||||
const hasActive = Object.keys(fileProgress).length > 0
|
||||
const hasResumable = Object.keys(resumableFiles).length > 0
|
||||
|
||||
if (!hasPending && !hasActive) return null
|
||||
if (!hasPending && !hasActive && !hasResumable) return null
|
||||
|
||||
function alias(peerId: string) {
|
||||
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
|
||||
@@ -22,6 +23,24 @@ export function Transfers() {
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-label">Transfers</span>
|
||||
|
||||
{hasResumable && (
|
||||
<>
|
||||
<span className="sidebar-label" style={{ fontSize: 10, opacity: 0.6 }}>resumable</span>
|
||||
{Object.entries(resumableFiles).map(([sha256, f]) => {
|
||||
const pct = f.size > 0 ? Math.round((f.offset / f.size) * 100) : 0
|
||||
return (
|
||||
<div key={sha256} className="transfer-row">
|
||||
<span className="transfer-name" title={f.name}>{f.name}</span>
|
||||
<span className="transfer-meta">{fmt(f.offset)} / {fmt(f.size)} · {alias(f.from)} · will resume on reconnect</span>
|
||||
<div className="transfer-progress">
|
||||
<div className="transfer-progress-bar" style={{ width: `${pct}%`, opacity: 0.5 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{Object.entries(pendingOffers).map(([xid, offer]) => (
|
||||
<div key={xid} className="transfer-row">
|
||||
<span className="transfer-name" title={offer.name}>{offer.name}</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Sidebar } from '../components/Sidebar'
|
||||
import { MessagePane } from '../components/MessagePane'
|
||||
import { FileBrowser } from '../components/FileBrowser'
|
||||
@@ -5,10 +6,11 @@ import { useWaste } from '../store'
|
||||
|
||||
export function Chat() {
|
||||
const { activeFilePeer } = useWaste()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
return (
|
||||
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}`}>
|
||||
<Sidebar />
|
||||
<MessagePane />
|
||||
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}${sidebarOpen ? ' sidebar-open' : ''}`}>
|
||||
<Sidebar onClose={() => setSidebarOpen(false)} />
|
||||
<MessagePane onMenuClick={() => setSidebarOpen(v => !v)} />
|
||||
{activeFilePeer && <FileBrowser />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -72,8 +72,15 @@ export function Onboarding({ status }: Props) {
|
||||
if (adapterMode !== 'browser' || status !== 'connected') return
|
||||
const { network: n, netHash: nh } = parseInviteParams()
|
||||
if (n || nh) return // explicit invite — don't auto-join, show form
|
||||
// Rejoin all saved networks.
|
||||
const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||
if (saved.length > 0) {
|
||||
saved.forEach(s => doJoin(s.name, ''))
|
||||
} else {
|
||||
// Legacy single-network fallback.
|
||||
const savedNetwork = localStorage.getItem('waste_last_network')
|
||||
if (savedNetwork) doJoin(savedNetwork, '')
|
||||
}
|
||||
}, [adapterMode, status]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function joinNetwork(e: React.FormEvent) {
|
||||
@@ -87,8 +94,16 @@ export function Onboarding({ status }: Props) {
|
||||
if (adapterMode === 'browser') {
|
||||
localStorage.setItem('waste_anchor_url', anchorUrl)
|
||||
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
|
||||
if (name) localStorage.setItem('waste_last_network', name)
|
||||
else localStorage.removeItem('waste_last_network')
|
||||
if (name) {
|
||||
// Persist to saved networks list.
|
||||
const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||
if (!saved.some(n => n.name === name)) {
|
||||
saved.push({ name, anchor: anchorUrl })
|
||||
localStorage.setItem('waste_saved_networks', JSON.stringify(saved))
|
||||
}
|
||||
// Keep legacy key for backward compat.
|
||||
localStorage.setItem('waste_last_network', name)
|
||||
}
|
||||
}
|
||||
|
||||
if (hash.length === 64 && !name) {
|
||||
@@ -121,6 +136,7 @@ export function Onboarding({ status }: Props) {
|
||||
}
|
||||
|
||||
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
|
||||
const savedNetworks: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||
|
||||
// ── disconnected / connecting ────────────────────────────────────────────────
|
||||
if (status !== 'connected') {
|
||||
@@ -159,8 +175,21 @@ export function Onboarding({ status }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adapterMode === 'browser' && savedNetworks.length > 0 && (
|
||||
<div className="saved-networks">
|
||||
<span className="join-label">Saved networks</span>
|
||||
<div className="saved-network-chips">
|
||||
{savedNetworks.map(n => (
|
||||
<button key={n.name} className="network-chip" type="button" onClick={() => doJoin(n.name, '')}>
|
||||
{n.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={joinNetwork} className="join-form">
|
||||
<label className="join-label">Join a network</label>
|
||||
<label className="join-label">{savedNetworks.length > 0 ? 'Join another network' : 'Join a network'}</label>
|
||||
|
||||
{adapterMode === 'browser' && (
|
||||
<input
|
||||
|
||||
@@ -29,9 +29,12 @@ interface WasteState {
|
||||
|
||||
// peers
|
||||
connectedPeers: PeerInfo[]
|
||||
knownPeers: Record<string, string> // id → alias for historical peers
|
||||
|
||||
// chat — keyed by room
|
||||
messages: Record<string, ChatMessage[]>
|
||||
// rooms for which we have received history: room → ts of last history message
|
||||
historyCutoff: Record<string, number>
|
||||
activeRoom: string
|
||||
// user-created rooms, keyed by networkId
|
||||
customRooms: Record<string, string[]>
|
||||
@@ -53,6 +56,10 @@ interface WasteState {
|
||||
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
|
||||
// active in-progress transfers: xid → progress
|
||||
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
|
||||
// partial downloads found on daemon startup: sha256 → info
|
||||
resumableFiles: Record<string, { name: string; from: string; size: number; offset: number }>
|
||||
// reactions: mid → emoji → [fromId, ...]
|
||||
reactions: Record<string, Record<string, string[]>>
|
||||
|
||||
// actions
|
||||
connect: (url: string) => void
|
||||
@@ -69,6 +76,7 @@ interface WasteState {
|
||||
rejectOffer: (peerId: string, xid: string) => void
|
||||
cancelTransfer: (peerId: string, xid: string, direction: 'recv' | 'send') => void
|
||||
createRoom: (name: string) => void
|
||||
sendReaction: (networkId: string, mid: string, emoji: string) => void
|
||||
logout: (clearIdentity: boolean) => void
|
||||
handleEvent: (msg: IpcMessage) => void
|
||||
}
|
||||
@@ -83,7 +91,9 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
networks: [],
|
||||
activeNetworkId: null,
|
||||
connectedPeers: [],
|
||||
knownPeers: {},
|
||||
messages: {},
|
||||
historyCutoff: {},
|
||||
activeRoom: 'general',
|
||||
customRooms: {},
|
||||
fileLists: {},
|
||||
@@ -93,6 +103,8 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
sharedFilesByNetwork: {},
|
||||
pendingOffers: {},
|
||||
fileProgress: {},
|
||||
resumableFiles: {},
|
||||
reactions: {},
|
||||
|
||||
connect(url: string) {
|
||||
const adapter = new DaemonAdapter(url)
|
||||
@@ -181,6 +193,10 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
window.location.reload()
|
||||
},
|
||||
|
||||
sendReaction(networkId, mid, emoji) {
|
||||
get().send({ type: 'send_reaction', network_id: networkId, reaction_mid: mid, reaction_emoji: emoji })
|
||||
},
|
||||
|
||||
createRoom(name) {
|
||||
const netId = get().activeNetworkId
|
||||
if (!netId || !name.trim()) return
|
||||
@@ -209,6 +225,8 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
switch (msg.type) {
|
||||
case 'state_snapshot': {
|
||||
const networks = msg.networks ?? []
|
||||
const knownPeers: Record<string, string> = {}
|
||||
for (const p of msg.known_peers ?? []) knownPeers[p.id] = p.alias
|
||||
set({
|
||||
masterAlias: msg.master_alias ?? null,
|
||||
masterId: msg.master_id ?? null,
|
||||
@@ -216,6 +234,7 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
networks,
|
||||
connectedPeers: msg.connected_peers ?? [],
|
||||
activeNetworkId: networks[0]?.network_id ?? null,
|
||||
knownPeers,
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -272,14 +291,14 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
case 'message_received': {
|
||||
if (msg.message) {
|
||||
const m = msg.message
|
||||
const room = m.room
|
||||
const key = `${msg.network_id}:${m.room}`
|
||||
const fromId = String(m.from)
|
||||
set(s => {
|
||||
const existing = s.messages[room] ?? []
|
||||
const existing = s.messages[key] ?? []
|
||||
if (m.mid && existing.some(e => e.mid === m.mid)) return s
|
||||
const prev = s.peerStatus[fromId] ?? {}
|
||||
return {
|
||||
messages: { ...s.messages, [room]: [...existing, m] },
|
||||
messages: { ...s.messages, [key]: [...existing, m] },
|
||||
peerStatus: { ...s.peerStatus, [fromId]: { ...prev, lastSeen: m.ts } },
|
||||
}
|
||||
})
|
||||
@@ -341,10 +360,13 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
break
|
||||
}
|
||||
case 'file_complete': {
|
||||
if (msg.path && msg.offer?.name) {
|
||||
// clear progress entry
|
||||
const xid = msg.offer.xid
|
||||
// Always clear progress — transfer_id is the xid in daemon mode; offer.xid in browser mode.
|
||||
const xid = msg.transfer_id ?? msg.offer?.xid
|
||||
if (xid) {
|
||||
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
|
||||
}
|
||||
// Browser mode: trigger download via anchor click.
|
||||
if (msg.path && msg.offer?.name) {
|
||||
const a = document.createElement('a')
|
||||
a.href = msg.path
|
||||
a.download = msg.offer.name
|
||||
@@ -352,6 +374,46 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'resumable_transfers': {
|
||||
const files = (msg.resumable_files ?? []) as Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
|
||||
if (files.length === 0) break
|
||||
const byHash: Record<string, { name: string; from: string; size: number; offset: number }> = {}
|
||||
for (const f of files) byHash[f.sha256] = { name: f.name, from: f.from, size: f.size, offset: f.offset }
|
||||
set(s => ({ resumableFiles: { ...s.resumableFiles, ...byHash } }))
|
||||
break
|
||||
}
|
||||
case 'reaction': {
|
||||
const mid = msg.reaction_mid
|
||||
const emoji = msg.reaction_emoji
|
||||
const from = msg.peer_id
|
||||
if (!mid || !emoji || !from) break
|
||||
set(s => {
|
||||
const byEmoji = { ...(s.reactions[mid] ?? {}) }
|
||||
const existing = byEmoji[emoji] ?? []
|
||||
if (existing.includes(from)) return s
|
||||
return { reactions: { ...s.reactions, [mid]: { ...byEmoji, [emoji]: [...existing, from] } } }
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'history_loaded': {
|
||||
const room = msg.room
|
||||
const incoming = (msg.messages ?? []) as ChatMessage[]
|
||||
if (!room || incoming.length === 0) break
|
||||
const key = `${msg.network_id}:${room}`
|
||||
set(s => {
|
||||
const existing = s.messages[key] ?? []
|
||||
const existingMids = new Set(existing.map(m => m.mid).filter(Boolean))
|
||||
const fresh = incoming.filter(m => !m.mid || !existingMids.has(m.mid))
|
||||
if (fresh.length === 0) return s
|
||||
const merged = [...fresh, ...existing].sort((a, b) => a.ts - b.ts)
|
||||
const cutoff = fresh[fresh.length - 1]?.ts ?? 0
|
||||
return {
|
||||
messages: { ...s.messages, [key]: merged },
|
||||
historyCutoff: { ...s.historyCutoff, [key]: cutoff },
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -82,6 +82,12 @@ export type IpcMsgType =
|
||||
| 'shares_list'
|
||||
| 'peer_status'
|
||||
| 'error'
|
||||
| 'history_loaded'
|
||||
| 'room_created'
|
||||
| 'create_room'
|
||||
| 'resumable_transfers'
|
||||
| 'send_reaction'
|
||||
| 'reaction'
|
||||
|
||||
export interface IpcMessage {
|
||||
type: IpcMsgType
|
||||
@@ -113,6 +119,7 @@ export interface IpcMessage {
|
||||
master_id?: string
|
||||
local_peer?: PeerInfo
|
||||
connected_peers?: PeerInfo[]
|
||||
known_peers?: PeerInfo[]
|
||||
rooms?: string[]
|
||||
networks?: NetworkInfo[]
|
||||
error_message?: string
|
||||
@@ -124,4 +131,11 @@ export interface IpcMessage {
|
||||
conn_state?: PeerConnState
|
||||
candidate_type?: CandidateType
|
||||
remote_address?: string
|
||||
// history_loaded
|
||||
messages?: ChatMessage[]
|
||||
// resumable_transfers
|
||||
resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
|
||||
// reaction
|
||||
reaction_mid?: string
|
||||
reaction_emoji?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user