24 Commits

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 20:08:52 +02:00
Fredrik Johansson
437deca6a0 feat: TUI multi-network + reactions + history
Multi-network:
- Left panel now shows Networks section above Rooms
- /join <name> joins a new network at runtime; -network flag now optional
- ctrl+n cycles between joined networks; /net <n|name> switches by number or name
- All state (rooms, peers, messages) scoped per network via netData struct
- Messages keyed by "netId:room" (same as web store)

Reactions:
- /react <emoji>  — react to the last message in the current room
- /react <n> <emoji> — react to message number n
- Reactions displayed as a dimmed line below each message: 👍 2  ❤️ 1
- EvtReaction handler updates in-place and refreshes viewport

History:
- EvtHistoryLoaded now handled: historical messages prepended, deduped by mid, sorted by time

UX:
- Each message prefixed with [n] line number so /react targets are unambiguous
- -network flag is now optional (start idle, /join to connect)
- Status bar hint updated with new commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 20:02:50 +02:00
Fredrik Johansson
851cfdc7e9 fix: reactions in browser mode (BrowserAdapter)
- Include mid on the wire in sendChat() so recipients can reference it
- Handle incoming 'reaction' wire message in PeerConn._onControl
- Add PeerConn.sendReaction() to send the reaction wire message
- Handle 'send_reaction' IPC command in BrowserAdapter.send()
- Emit 'reaction' IPC event on receive and on local send
- Use wire mid (not synthetic peer-ts mid) when receiving chat messages

Reactions now work across all combinations: daemon↔daemon,
browser↔browser, and daemon↔browser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 19:27:44 +02:00
Fredrik Johansson
4a7a95fe9d feat: message reactions + link/image previews
Reactions (full stack):
- Wire: MsgReaction peer message (reaction_mid, reaction_emoji)
- Store: reactions table (mid, emoji, from_peer), SaveReaction, ReactionsForRoom
- Daemon: CmdSendReaction IPC command; EvtReaction IPC event; stored reactions
  replayed to newly-connected IPC clients alongside history
- Web UI: reactions state (mid → emoji → [fromId]); hover a message to reveal
  a + button; click opens a 6-emoji picker (👍❤️😂😮😢🙏); reaction chips
  appear below the message with count and tooltip; own reactions highlighted

Link/image previews:
- URLs in message text rendered as clickable <a> links
- Image URLs (.jpg/.jpeg/.png/.gif/.webp/blob:/data:image) rendered as inline
  thumbnails (max 320×200px) below the link

Also scoped push notifications architecture in FUTURE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 19:17:16 +02:00
Fredrik Johansson
32a6f46481 feat: responsive mobile layout with slide-over sidebar
On screens ≤600px the sidebar collapses off-screen. A hamburger button
in the message header opens it as a slide-over drawer with a dim
overlay; tapping a room/network or the ✕ button closes it. Desktop
layout is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 18:53:18 +02:00
Fredrik Johansson
1d9c9d1524 fix: add explicit Join button to sidebar add-network form
All checks were successful
Build / Build & release (push) Successful in 13m37s
The form had no submit button so there was no visible way to confirm.
Enter should have worked but was unreliable with two inputs. Added a
disabled-until-valid Join button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 18:40:41 +02:00
Fredrik Johansson
f5fb0862ff fix: key messages by networkId:room to prevent collision across networks
Two networks sharing a room name (e.g. "general") were clobbering each
other's message lists. Now keyed by `${networkId}:${room}` throughout
the web store, MessagePane, and Sidebar DM detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:55:16 +02:00
Fredrik Johansson
dab5387cbd feat: multi-network join, persistence, and switching in web UI
- Sidebar: "+" button next to Networks opens an inline join form;
  in browser mode shows anchor URL field; saves to waste_saved_networks
- Onboarding: saved networks shown as quick-join chips above the join
  form; auto-rejoin all saved networks on reconnect (not just one)
- Browser mode: networks persisted as [{name, anchor}] array in
  localStorage (waste_saved_networks); legacy waste_last_network kept
  for backward compat
- Daemon mode: daemon already tracks joined networks; web UI just needs
  the "+" to send join_network for additional ones

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:38:46 +02:00
Fredrik Johansson
1d9827beb0 docs: mark resume UX and history gossip shipped; remove proposal doc
- FUTURE.md: remove "not yet done" note on resume UX, update Native UI
  section to reflect tray/notifications shipped, add new roadmap entries
- Delete PROPOSAL-history-gossip.md (implemented, documented in EXTENSIONS.md)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:47:14 +02:00
Fredrik Johansson
fcbd84f873 fix: compact timestamps, historical alias resolution, wider ts column
- Timestamps now show "Jun 28 10:58" for older messages (dropped
  "Yesterday" which overflowed the fixed-width column)
- Timestamp column widened from 52px to 72px to fit date+time
- state_snapshot now includes known_peers (historically seen, not
  currently connected) so the UI can resolve aliases in history
- MessagePane aliasFor falls back to knownPeers map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 13:43:29 +02:00
Fredrik Johansson
cef9374416 fix: show date in timestamps; fix history divider appearing immediately
- MessagePane: show date in timestamps (Yesterday/MMM D) for messages
  not from today; divider now appears at the top of history on load
  rather than waiting for a live message to create the boundary
- TUI: same date-aware timestamp formatting (Yesterday / Jan 2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:45:51 +02:00
Fredrik Johansson
9ad3c96d43 fix: send stored history to IPC client on connect
history_loaded events were fired when peers exchanged history gossip,
but the browser UI often connects to the daemon after that handshake
has already happened. Now the daemon pushes recent messages for all
known rooms immediately after the state_snapshot on each new IPC
connection, so the browser always gets history regardless of timing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:30:33 +02:00
Fredrik Johansson
48400440dd fix: history gossip not triggering for pre-feature messages
Two bugs:
1. RecentMessagesSince had msg_id IS NOT NULL filter — messages sent
   before EXT-007 deployment all have msg_id=NULL so nothing was returned.
   Removed the filter; mid-based INSERT OR IGNORE dedup is sufficient.
2. queryMessages didn't SELECT room, so gossipped messages had empty room
   field. Added room to SELECT and Scan in queryMessages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:14:43 +02:00
Fredrik Johansson
0e812a2479 fix: clear file progress on completion in daemon mode
file_complete in daemon mode carries transfer_id but no offer field.
The old condition required msg.offer, so the progress/cancel row was
never removed. Now clears using transfer_id first, offer.xid as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:55:00 +02:00
Fredrik Johansson
f319721e01 feat: resumable transfer UX — surface partial downloads to UI on reconnect
Daemon scans the download directory for .tmp.meta sidecars on network join
and emits resumable_transfers IPC event. Web UI shows them in the Transfers
panel with a dimmed progress bar and "will resume on reconnect" note.

- proto: ResumableFile type, EvtResumableTransfers, resumable_files IpcMessage field
- mesh: ScanResumable() scans download dir and emits the event
- netmgr: call ScanResumable() after join (both Join and JoinByHash paths)
- web: resumableFiles store state, resumable_transfers handler, Transfers UI section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:02:49 +02:00
Fredrik Johansson
9de625d617 feat: render history_loaded in web UI with earlier messages divider
- store: handle history_loaded event — prepend gossipped messages,
  dedup by mid, sort by ts, record cutoff timestamp per room
- MessagePane: show "earlier messages" divider between history and
  live messages based on the cutoff timestamp
- types: add history_loaded, room_created, create_room to IpcMsgType;
  add messages field to IpcMessage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:50:48 +02:00
Fredrik Johansson
15306dc0c2 ci: use npm install instead of npm ci to avoid lockfile sync issues
All checks were successful
Build / Build & release (push) Successful in 13m34s
The CI runner resolves @emnapi deps differently than local npm, causing
npm ci to fail regardless of Node version. npm install is more forgiving.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:08:49 +02:00
Fredrik Johansson
7c3cedc549 feat: EXT-007 P2P message history gossip
After hello verification, the connecting peer sends history_request to
the first peer it meets (one per room, no fan-out). The responder queries
SQLite and replies with a history_chunk. Received history is stored via
INSERT OR IGNORE (mid dedup) and emitted as history_loaded IPC events.

- proto: MsgHistoryRequest/Chunk types, HistoryEntry, EvtHistoryLoaded,
  ComputeMsgID (sha256 content-addressed ID), MsgID field on ChatMessage
- store: ALTER TABLE ADD COLUMN msg_id + unique index migration (idempotent);
  RecentMessagesSince query (msg_id IS NOT NULL filter); msg_id persisted on save
- mesh: RequestHistoryFrom, HandleHistoryRequest, HandleHistoryChunk methods;
  historyRequested/historyFirstPeer state to ensure single-peer requests
- peer: dispatch history_request/history_chunk; RequestHistoryFrom after hello;
  stamp MsgID on incoming chat messages
- ipc: stamp MsgID on outgoing group chat messages
- EXTENSIONS.md: EXT-007 documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:08:17 +02:00
Fredrik Johansson
1c73f1b1ef ci: bump Node to 24 to match local lockfile; add history gossip proposal
Some checks failed
Build / Build & release (push) Failing after 7m6s
package-lock.json was generated with npm 11 (Node 24). CI was running
Node 20 which resolves @emnapi deps differently, causing npm ci to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 22:53:35 +02:00
Fredrik Johansson
b2b5c8c7cb ci: fix webkit dep for Ubuntu 24.04 (Noble)
Some checks failed
Build / Build & release (push) Failing after 7m5s
libwebkit2gtk-4.0-dev was dropped in Noble; use 4.1 and pass
-tags webkit2_41 to wails build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 22:28:04 +02:00
Fredrik Johansson
b6ff30de78 ci: rewrite workflow as single job to avoid upload-artifact
Some checks failed
Build / Build & release (push) Failing after 5m15s
Gitea act_runner intercepts actions/upload-artifact regardless of version
tag and uses an incompatible built-in. Restructured as one job that builds
all server binaries (cross-compiled) and the Linux desktop app, then
publishes directly to the release — no artifact handoff needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 22:02:43 +02:00
Fredrik Johansson
1bd719fa58 ci: downgrade artifact actions to v3 for Gitea GHES compatibility
upload-artifact@v4 and download-artifact@v4 are not supported on GHES.
Also drop merge-multiple (v4-only) and adjust release glob to artifacts/*/*.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 21:53:07 +02:00
Fredrik Johansson
be297d3a49 docs: add QUICKSTART.md for non-technical users
Three paths: browser-only, desktop app download, headless daemon.
Covers invites, anchor server basics, and mobile install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 21:46:37 +02:00
23 changed files with 1845 additions and 345 deletions

View File

@@ -7,55 +7,8 @@ on:
workflow_dispatch:
jobs:
# ── Server binaries (no CGo, cross-compile freely) ───────────────────────────
server:
name: Server binaries
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build daemon + anchor
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
[ "${{ matrix.goos }}" = "windows" ] && EXT=".exe" || EXT=""
go build -trimpath -ldflags="-s -w" -o "dist/waste-daemon-${SUFFIX}${EXT}" ./cmd/daemon
go build -trimpath -ldflags="-s -w" -o "dist/waste-anchor-${SUFFIX}${EXT}" ./cmd/anchor
- uses: actions/upload-artifact@v4
with:
name: server-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/
# ── Desktop app (Wails, requires CGo + webview libs) ─────────────────────────
# Runs only on Linux amd64 with the default runner.
# For macOS/Windows desktop builds, add self-hosted runners with those platforms
# and duplicate this job (adjusting the runs-on and platform deps).
desktop-linux:
name: Desktop app (Linux amd64)
build:
name: Build & release
runs-on: ubuntu-latest
steps:
@@ -67,7 +20,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24'
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
@@ -77,44 +30,53 @@ jobs:
sudo apt-get update -q
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.0-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev
# ── Server binaries (CGO_ENABLED=0, cross-compile freely) ──────────────
- name: Build server binaries
run: |
mkdir -p dist
build() {
local GOOS=$1 GOARCH=$2
local SUFFIX="${GOOS}-${GOARCH}"
local EXT=""
[ "$GOOS" = "windows" ] && EXT=".exe"
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
go build -trimpath -ldflags="-s -w" \
-o "dist/waste-daemon-${SUFFIX}${EXT}" ./cmd/daemon
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
go build -trimpath -ldflags="-s -w" \
-o "dist/waste-anchor-${SUFFIX}${EXT}" ./cmd/anchor
}
build linux amd64
build linux arm64
build darwin amd64
build darwin arm64
build windows amd64
# ── Desktop app (Linux amd64, CGo + Wails) ─────────────────────────────
- name: Build frontend
run: |
cd web
npm ci
npm install
npm run build
cp -r dist ../cmd/app/frontend/dist
- name: Build desktop app
run: |
mkdir -p dist
cd cmd/app
wails build -trimpath -ldflags="-s -w" -o ../../dist/waste-linux-amd64
wails build -trimpath -ldflags="-s -w" -tags webkit2_41
cp build/bin/waste ../../dist/waste-linux-amd64
- uses: actions/upload-artifact@v4
with:
name: desktop-linux-amd64
path: dist/waste-linux-amd64
# ── Release: collect all artifacts and publish ────────────────────────────────
release:
name: Publish release
needs: [server, desktop-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts/
merge-multiple: true
# ── Publish release (tags only) ─────────────────────────────────────────
- name: Create release
if: startsWith(github.ref, 'refs/tags/')
uses: https://gitea.com/actions/gitea-release-action@main
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: artifacts/*
files: dist/*
prerelease: ${{ contains(github.ref_name, '-') }}

1
.gitignore vendored
View File

@@ -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

View File

@@ -224,3 +224,154 @@ Each in-progress `.tmp` file has a corresponding `.tmp.meta` JSON sidecar:
The sidecar is written when the transfer starts and removed on completion or
corruption. Interrupted transfers keep the sidecar indefinitely.
---
## EXT-007 — P2P Message History Gossip
**Status:** implemented (daemon mode)
**Affects:** peer-to-peer wire (two new message types); IPC (new event)
### Motivation
When a peer joins a network for the first time (or reconnects after an
absence), they have no history. This extension lets them request recent
messages from an existing peer over the already-established encrypted
DataChannel, without involving the anchor.
### Wire messages
#### `history_request`
Sent by the newly-connected peer to the first peer whose hello is verified.
One request per room.
```json
{
"type": "history_request",
"room": "general",
"since": 1700000000000,
"limit": 200
}
```
| Field | Type | Description |
|---------|---------------|-------------|
| `room` | string | Room to request history for. |
| `since` | int64 (ms) | Only return messages with `ts > since`. 0 = return up to `limit` most recent. |
| `limit` | int (max 500) | Maximum messages to return. Responder may return fewer. |
#### `history_chunk`
```json
{
"type": "history_chunk",
"room": "general",
"history": [
{ "mid": "...", "from": "<peer-id>", "from_alias": "alice", "text": "hello", "ts": 1700000001000 }
],
"history_done": true
}
```
| Field | Type | Description |
|----------------|--------|-------------|
| `history` | array | Messages, oldest-first. |
| `history_done` | bool | Always `true` (single-chunk response). |
### Deduplication
`mid` is the deduplication key. The store uses `INSERT OR IGNORE` on `mid`,
so receiving a message twice (live or via gossip) is a no-op. Messages
without a `mid` are assigned one at receive time and are not gossipped.
### Behaviour
- The **receiver** sends one `history_request` per known room immediately
after hello verification with the **first** peer it connects to. Requesting
only the first peer avoids fan-out amplification.
- The **responder** queries its SQLite store and replies with a single
`history_chunk`. `limit` is capped at 500 server-side. Rate-limited to one
request per (peer, room) per 60 seconds.
- Received history messages are saved to the local store (`INSERT OR IGNORE`)
and emitted as `history_loaded` IPC events so the UI can display them.
### IPC event
```json
{ "type": "history_loaded", "room": "general", "messages": [...] }
```
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.

View File

@@ -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).
---

89
QUICKSTART.md Normal file
View File

@@ -0,0 +1,89 @@
# waste — quick start
waste is a private, encrypted chat and file sharing app for people you trust.
No accounts, no phone numbers, no central server that knows your messages.
Pick the option that fits you best.
---
## Option 1 — Just open it in your browser
If someone is running a waste anchor server and has shared the URL with you:
1. Open the URL in any modern browser
2. Enter your name and a network name your group has agreed on
3. Done — you're in
On mobile, tap **Share → Add to Home Screen** to install it as an app icon.
To invite someone: click the 🔗 button in the sidebar and share the link.
> Your identity and messages stay in your browser. Nothing is stored on the server — the server only helps peers find each other.
---
## Option 2 — Desktop app (recommended for regular use)
Download the latest `waste` binary for your platform from the [releases page](../../releases).
**Linux / macOS:**
```bash
chmod +x waste-linux-amd64 # or waste-darwin-arm64, etc.
./waste-linux-amd64
```
**Windows:** double-click `waste-windows-amd64.exe`.
The app opens a window with the waste UI. Enter your name, the anchor URL, and a network name to join. Your identity is saved between sessions in your config directory (`~/.config/waste` on Linux, `~/Library/Application Support/waste` on macOS, `%APPDATA%\waste` on Windows).
On Linux and Windows a tray icon appears — closing the window hides to tray rather than quitting. Right-click the tray icon to reopen or quit.
---
## Option 3 — Run the daemon manually (headless / power users)
If you want the daemon running in the background without the desktop UI — on a server, over SSH, or with the web UI in a browser pointed at your local machine:
```bash
# Download waste-daemon from the releases page, then:
./waste-daemon -alias yourname -anchor wss://your-anchor-server/ws
```
Then open the web UI in a browser at the anchor URL, or point the web UI's daemon mode at `ws://127.0.0.1:17338`.
Full flag reference:
| Flag | Default | Description |
|---|---|---|
| `-alias` | `anon` | Your display name |
| `-anchor` | — | Anchor server WebSocket URL |
| `-data-dir` | `~/.waste` | Where identity and messages are stored |
| `-download-dir` | same as data-dir | Where received files are saved |
| `-ipc-port` | `17337` | Local TCP IPC port |
| `-ws-port` | `0` (off) | WebSocket IPC port (needed for web UI) |
| `-turn-url` | — | TURN relay URL (fixes mobile/CGNAT) |
| `-turn-secret` | — | TURN shared secret |
---
## Inviting someone
1. Click `Ctrl+I` in the TUI, or click **Generate invite** in the web UI
2. Share the `waste:...` link with your friend (Signal, email, anything)
3. They open it in a browser or pass it to `waste-daemon --join 'waste:...'`
Invite links encode the anchor URL and network name. The anchor never sees your messages.
---
## Running your own anchor server
The anchor is a tiny signaling server that helps peers find each other — it never sees plaintext messages or file contents. You need a VPS with a domain and TLS.
```bash
# On your VPS:
./waste-anchor -bind 127.0.0.1:8080
```
Put it behind nginx with a `/ws` WebSocket proxy and serve the web UI static files at `/`. See [README.md](README.md#hosting-on-a-vps) for the full nginx setup.

View File

@@ -21,6 +21,51 @@ waste-go/
---
## Prebuilt binaries
Every tagged release publishes cross-compiled binaries — no Go toolchain required. Grab them from the repo's **Releases** page:
```
https://repo.explewd.com/explewd/waste-go/releases
```
| File | What it is | Run it on |
|---|---|---|
| `waste-daemon-<os>-<arch>` | The peer process — your identity, mesh connections, file shares | Each friend's own machine |
| `waste-anchor-<os>-<arch>` | The signaling relay (no message content ever passes through it) | One server you control (VPS) |
`<os>` is `linux`, `darwin` (macOS), or `windows`; `<arch>` is `amd64` or `arm64` (Windows builds are amd64 only). Windows binaries have a `.exe` suffix.
**Linux / macOS:**
```bash
curl -LO https://repo.explewd.com/explewd/waste-go/releases/download/<tag>/waste-daemon-linux-amd64
chmod +x waste-daemon-linux-amd64
./waste-daemon-linux-amd64 -alias yourname -data-dir ~/.waste --join 'waste:eyJ...'
```
> **macOS Gatekeeper:** unsigned binaries downloaded from a browser get a quarantine flag and macOS will refuse to run them ("cannot be opened because the developer cannot be verified"). Clear it once after downloading: `xattr -d com.apple.quarantine waste-daemon-darwin-arm64` (or right-click → Open the first time, which prompts for an override).
**Windows:** download the `.exe`, then run it from PowerShell or `cmd.exe`:
```powershell
.\waste-daemon-windows-amd64.exe -alias yourname -data-dir C:\waste --join "waste:eyJ..."
```
SmartScreen may warn about an unrecognized publisher on first run — these binaries aren't code-signed. Click "More info" → "Run anyway".
Once the daemon is running, drive it with the [TUI](#terminal-ui) (`./cmd/tui` built locally, or the web UI in [daemon mode](#daemon-mode-for-users-running-the-daemon-locally)) — the daemon itself has no UI of its own, it just exposes the local IPC API on port 17337.
The `waste-anchor` binary is for whoever is hosting a network — see [Hosting on a VPS](#hosting-on-a-vps) below for the full anchor + web UI setup. For a quick local anchor (e.g. testing on a LAN), just run:
```bash
./waste-anchor-linux-amd64 -bind 0.0.0.0:8080
```
> **No desktop app build in the current release.** A native Wails desktop app (`cmd/app/`) exists in the source tree, but the CI step that packages it for releases was broken (wrong output path) until this fix — earlier tagged releases only have daemon/anchor binaries. The next tag will include `waste-linux-amd64`. Until then, build it yourself with `./build-app.sh` (see [Desktop app (Wails)](#desktop-app-wails) below).
---
## Hosting on a VPS
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":"..."}
```

View File

@@ -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(),

View File

@@ -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() {
@@ -214,6 +216,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
} else {
// Group chat → spec "chat" type: flat {type, mid, room, text, ts}
mid := randomHex(16)
msgID := proto.ComputeMsgID(n.Identity.PeerID(), cmd.Room, ts, cmd.Body)
wire, err := json.Marshal(proto.PeerMessage{
Type: proto.MsgChat,
Mid: mid,
@@ -227,6 +230,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
n.Mesh.Broadcast(wire)
local := &proto.ChatMessage{
Mid: mid,
MsgID: msgID,
From: n.Identity.PeerID(),
Room: cmd.Room,
Text: cmd.Body,
@@ -240,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 {
@@ -455,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}
}

View File

@@ -2,6 +2,7 @@
package mesh
import (
"encoding/json"
"log"
"os"
"sync"
@@ -51,6 +52,12 @@ type Mesh struct {
// attempt to connect to. Drained by the anchor client's runOnce loop.
PendingConnect chan proto.PeerID
// historyRequested tracks rooms for which we have already sent a history_request
// this session. Reset on reconnect is intentional (new peers may have newer history).
historyMu sync.Mutex
historyRequested map[string]bool // room → true
historyFirstPeer proto.PeerID // ID of the peer we requested history from
// subscribers receive a copy of every event (fan-out to IPC clients)
subMu sync.Mutex
subs []chan proto.IpcMessage
@@ -66,6 +73,7 @@ func New(id *crypto.Identity, st *store.Store) *Mesh {
outbound: make(map[string]*outboundTransfer),
inbound: make(map[string]*inboundTransfer),
PendingConnect: make(chan proto.PeerID, 32),
historyRequested: make(map[string]bool),
}
}
@@ -138,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) {
@@ -235,6 +254,122 @@ func (m *Mesh) Unsubscribe(ch <-chan proto.IpcMessage) {
}
}
// RequestHistoryFrom sends history_request messages to peerID for all rooms
// we know about but haven't yet requested this session. Only contacts the first
// peer we connect to, to avoid fan-out amplification.
func (m *Mesh) RequestHistoryFrom(peerID proto.PeerID) {
if m.Store == nil {
return
}
m.historyMu.Lock()
if m.historyFirstPeer != "" && m.historyFirstPeer != peerID {
m.historyMu.Unlock()
return // only request from the first peer
}
m.historyFirstPeer = peerID
m.historyMu.Unlock()
rooms, err := m.Store.Rooms()
if err != nil {
return
}
// Always include "general" even if not explicitly created.
roomSet := map[string]bool{"general": true}
for _, r := range rooms {
roomSet[r] = true
}
m.historyMu.Lock()
var toRequest []string
for r := range roomSet {
if !m.historyRequested[r] {
m.historyRequested[r] = true
toRequest = append(toRequest, r)
}
}
m.historyMu.Unlock()
for _, room := range toRequest {
req, err := json.Marshal(proto.PeerMessage{
Type: proto.MsgHistoryRequest,
Room: room,
Limit: 200,
})
if err != nil {
continue
}
m.SendTo(peerID, req)
log.Printf("mesh: sent history_request room=%s to %s", room, peerID.Short())
}
}
// HandleHistoryRequest responds to a history_request from a peer.
func (m *Mesh) HandleHistoryRequest(from proto.PeerID, room string, sinceMs int64, limit int) {
if m.Store == nil {
return
}
msgs, err := m.Store.RecentMessagesSince(room, sinceMs, limit)
if err != nil {
log.Printf("mesh: history_request from %s room=%s: %v", from.Short(), room, err)
return
}
// Look up aliases for from_peer values.
entries := make([]proto.HistoryEntry, 0, len(msgs))
for _, msg := range msgs {
entries = append(entries, proto.HistoryEntry{
Mid: msg.Mid,
From: string(msg.From),
FromAlias: m.Store.PeerAlias(msg.From),
Text: msg.Text,
Ts: msg.Ts,
})
}
chunk, err := json.Marshal(proto.PeerMessage{
Type: proto.MsgHistoryChunk,
Room: room,
History: entries,
HistoryDone: true,
})
if err != nil {
return
}
m.SendTo(from, chunk)
log.Printf("mesh: sent history_chunk room=%s to %s: %d msgs", room, from.Short(), len(entries))
}
// HandleHistoryChunk saves received history messages and emits history_loaded.
func (m *Mesh) HandleHistoryChunk(room string, entries []proto.HistoryEntry) {
if m.Store == nil || len(entries) == 0 {
return
}
var saved []proto.ChatMessage
for _, e := range entries {
msg := &proto.ChatMessage{
Mid: e.Mid,
MsgID: e.Mid, // mid is already content-addressed for gossipped messages
From: proto.PeerID(e.From),
Room: room,
Text: e.Text,
Ts: e.Ts,
}
if err := m.Store.SaveMessage(msg); err != nil {
continue
}
saved = append(saved, *msg)
}
if len(saved) == 0 {
return
}
m.emit(proto.IpcMessage{
Type: proto.EvtHistoryLoaded,
Room: room,
Messages: saved,
})
log.Printf("mesh: history_chunk room=%s: %d/%d new messages", room, len(saved), len(entries))
}
// Emit sends an event to all IPC subscribers (exported for ipc/nat packages).
func (m *Mesh) Emit(msg proto.IpcMessage) {
m.emit(msg)

View File

@@ -197,6 +197,8 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
})
// Tell the new peer about everyone we can currently see.
go m.sendGossipTo(from)
// Request message history from this peer (EXT-007).
go m.RequestHistoryFrom(from)
return
}
@@ -213,6 +215,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
case proto.MsgChat:
chat := &proto.ChatMessage{
Mid: midOrRandom(msg.Mid),
MsgID: proto.ComputeMsgID(from, msg.Room, msg.Ts, msg.Text),
From: from,
Room: msg.Room,
Text: msg.Text,
@@ -298,6 +301,24 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
}
}
log.Printf("mesh: gossip from %s: %d hints, %d new", from.Short(), len(msg.Gossip.Peers), newPeers)
case proto.MsgHistoryRequest:
go m.HandleHistoryRequest(from, msg.Room, msg.Since, msg.Limit)
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:

View File

@@ -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 {

View File

@@ -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,

View File

@@ -3,7 +3,11 @@
// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64.
package proto
import "time"
import (
"crypto/sha256"
"fmt"
"time"
)
// ── Identity ──────────────────────────────────────────────────────────────────
@@ -45,6 +49,9 @@ const (
MsgFileDone MsgType = "file-done"
MsgPing MsgType = "ping"
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").
@@ -83,12 +90,43 @@ type PeerMessage struct {
Reason string `json:"reason,omitempty"` // file-cancel
Seq *uint64 `json:"seq,omitempty"` // ping/pong
// history_request fields
Since int64 `json:"since,omitempty"` // Unix ms; 0 = no lower bound
Limit int `json:"limit,omitempty"`
// 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.
type HistoryEntry struct {
Mid string `json:"mid"`
From string `json:"from"` // peer ID hex
FromAlias string `json:"from_alias"` // advisory
Text string `json:"text"`
Ts int64 `json:"ts"` // Unix ms
}
// ChatMessage is a group chat message (wire type "chat", §8).
// Also used internally for persisting PMs after they are received.
type ChatMessage struct {
Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0)
MsgID string `json:"msg_id,omitempty"` // EXT-007: content-addressed gossip ID
From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm
To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs
Room string `json:"room"`
@@ -96,6 +134,14 @@ type ChatMessage struct {
Ts int64 `json:"ts"` // Unix milliseconds
}
// ComputeMsgID returns the EXT-007 content-addressed ID for a message.
// sha256(fromID \x00 room \x00 ts_decimal \x00 text)
func ComputeMsgID(fromID PeerID, room string, ts int64, text string) string {
h := sha256.New()
fmt.Fprintf(h, "%s\x00%s\x00%d\x00%s", string(fromID), room, ts, text)
return fmt.Sprintf("sha256:%x", h.Sum(nil))
}
// PeerGossip shares known peer addresses.
type PeerGossip struct {
Peers []GossipEntry `json:"peers"`
@@ -239,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"
@@ -259,6 +306,9 @@ const (
EvtIdentityImported IpcMsgType = "identity_imported"
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.
@@ -307,12 +357,17 @@ 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"`
ErrorMessage string `json:"error_message,omitempty"`
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

View File

@@ -5,6 +5,7 @@ package store
import (
"database/sql"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
@@ -35,6 +36,23 @@ 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.
type Store struct {
db *sql.DB
@@ -51,6 +69,12 @@ func Open(path string) (*Store, error) {
db.Close()
return nil, fmt.Errorf("migrate db: %w", err)
}
for _, m := range migrations {
if _, err := db.Exec(m); err != nil && !strings.Contains(err.Error(), "duplicate column") {
db.Close()
return nil, fmt.Errorf("migration %q: %w", m, err)
}
}
return &Store{db: db}, nil
}
@@ -64,9 +88,9 @@ func (s *Store) Close() error {
func (s *Store) SaveMessage(msg *proto.ChatMessage) error {
sentAt := time.UnixMilli(msg.Ts).UTC()
_, err := s.db.Exec(
`INSERT OR IGNORE INTO messages (mid, room, from_peer, body, sent_at)
VALUES (?, ?, ?, ?, ?)`,
msg.Mid, msg.Room, string(msg.From), msg.Text, sentAt,
`INSERT OR IGNORE INTO messages (mid, msg_id, room, from_peer, body, sent_at)
VALUES (?, ?, ?, ?, ?, ?)`,
msg.Mid, nullableString(msg.MsgID), msg.Room, string(msg.From), msg.Text, sentAt,
)
return err
}
@@ -91,14 +115,39 @@ 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) {
rows, err := s.db.Query(
`SELECT mid, from_peer, body, sent_at
FROM messages
return s.queryMessages(
`SELECT mid, from_peer, room, body, sent_at FROM messages
WHERE room = ?
ORDER BY sent_at DESC
LIMIT ?`,
ORDER BY sent_at DESC LIMIT ?`,
room, limit,
)
}
// RecentMessagesSince returns up to limit messages for a room with ts > sinceMs, oldest first.
// 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, room, body, sent_at FROM messages
WHERE room = ? AND sent_at > ?
ORDER BY sent_at DESC LIMIT ?`,
room, since, limit,
)
}
func (s *Store) queryMessages(q string, args ...any) ([]proto.ChatMessage, error) {
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
@@ -109,11 +158,10 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
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)
m.Room = room
m.Ts = sentAt.UnixMilli()
msgs = append(msgs, m)
}
@@ -168,3 +216,47 @@ 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
}
return s
}

View File

@@ -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; }
}

View File

@@ -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 {

View File

@@ -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>
)
})}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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

View File

@@ -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
}
}
},
}))

View File

@@ -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
}