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>
This commit is contained in:
Fredrik Johansson
2026-06-29 20:08:52 +02:00
parent 437deca6a0
commit d09aa2b219
4 changed files with 113 additions and 11 deletions

View File

@@ -304,3 +304,74 @@ without a `mid` are assigned one at receive time and are not gossipped.
Emitted once per room after a `history_chunk` is fully processed. The UI Emitted once per room after a `history_chunk` is fully processed. The UI
should render these messages with a visual separator from live messages. should render these messages with a visual separator from live messages.
---
## EXT-008 — Message Reactions
### Wire message (`PeerMessage`)
```json
{
"type": "reaction",
"reaction_mid": "<32-hex mid of the target message>",
"reaction_emoji": "👍"
}
```
Sent on the normal mesh DataChannel (same as `chat`). No signing beyond
the existing channel-level encryption.
### Semantics
- A reaction is idempotent: the same `(mid, emoji, from_peer)` triple is
stored with `INSERT OR IGNORE` — receiving a duplicate is a no-op.
- There is no "un-react" wire message. Toggling off a reaction in the UI
is a local-only operation in the current implementation.
- `reaction_mid` must reference a message that exists in the local store;
unknown mids are silently ignored.
### Storage
SQLite table added as a migration:
```sql
CREATE TABLE IF NOT EXISTS reactions (
mid TEXT NOT NULL,
emoji TEXT NOT NULL,
from_peer TEXT NOT NULL,
reacted_at DATETIME NOT NULL,
PRIMARY KEY (mid, emoji, from_peer)
)
```
### IPC
**Command** — send a reaction (daemon and browser mode):
```json
{ "type": "send_reaction", "network_id": "...", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
```
**Event** — reaction received or replayed from history:
```json
{ "type": "reaction", "network_id": "...", "peer_id": "<64-hex>", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
```
Stored reactions are replayed as `reaction` IPC events when history is
loaded (`sendStoredHistory`), so the UI always sees reactions alongside
their messages.
### History replay
When `sendStoredHistory` sends a `history_chunk`, it also queries
`ReactionsForRoom` and emits one `reaction` event per stored reaction so
clients receive the full reaction state on reconnect.
### Browser mode
`browser.ts` mirrors the daemon behaviour independently:
- `PeerConn.sendReaction(mid, emoji)` broadcasts `{ type: "reaction", reaction_mid, reaction_emoji }` over the DataChannel.
- Incoming `reaction` wire frames are dispatched as `reaction` IPC events.
- `BrowserAdapter.send()` handles `send_reaction` commands and both broadcasts to all peers and emits a local `reaction` event.
- `sendChat` includes the `mid` in the wire frame so reactions can reference it correctly across peers.

View File

@@ -137,9 +137,25 @@ Web frontend (React, already built) + [Wails v2](https://wails.io) shell for nat
| ✅ shipped | P2P message history gossip (EXT-007) — new peers receive recent history from first connected peer | | ✅ shipped | P2P message history gossip (EXT-007) — new peers receive recent history from first connected peer |
| ✅ shipped | Date-aware timestamps in TUI and web UI | | ✅ shipped | Date-aware timestamps in TUI and web UI |
| ✅ shipped | Historical peer alias resolution in web UI | | ✅ shipped | Historical peer alias resolution in web UI |
| ✅ shipped | Message reactions (emoji picker, full-stack: wire protocol, SQLite, IPC, web UI, TUI) |
| ✅ shipped | Link rendering + image preview in web UI messages |
| ✅ shipped | Responsive mobile layout (slide-over sidebar, hamburger button) |
| ✅ shipped | TUI multi-network (join/switch networks at runtime, `ctrl+n`, `/join`, `/net`) |
| ✅ shipped | TUI message reactions (`/react <emoji>` or `/react <n> <emoji>`) |
| 🔜 planned | Push notifications (PWA Web Push + service worker) | | 🔜 planned | Push notifications (PWA Web Push + service worker) |
| 🔜 planned | Message reactions (emoji, full-stack gossip) |
| 🔜 planned | Link rendering + image preview in messages | ### Message Reactions ✅ (shipped)
Full-stack emoji reactions. The web UI shows a `+` button on hover that opens a six-emoji picker (👍 ❤️ 😂 😮 😢 🙏). Reactions render as chips below each message; clicking an existing chip toggles your own reaction. Reactions are stored in SQLite (`reactions` table), gossiped over the mesh as `reaction` wire messages (EXT-008), and replayed via IPC on reconnect.
Browser mode: `browser.ts` independently mirrors the daemon — reactions flow over the DataChannel and are broadcast to all connected peers.
TUI: messages show `[n]` line numbers. Use `/react <emoji>` (reacts to last message) or `/react <n> <emoji>` (reacts to message `n`). Reactions render inline below the target message.
### Link Rendering + Image Preview ✅ (shipped)
URLs in messages are auto-linked. URLs ending in a recognized image extension (`.jpg`, `.png`, `.gif`, `.webp`, `.svg`) render an inline `<img>` preview (max 320×200px). `blob:` and `data:image` URLs are also treated as images.
### Responsive Mobile Layout ✅ (shipped)
At viewport width ≤ 600px the sidebar becomes a fixed-position slide-over drawer, hidden off-screen by default (`transform: translateX(-100%)`). A `☰` hamburger button in the message pane header toggles it open. Clicking any room or network in the sidebar closes it automatically. The layout is dimmed while the sidebar is open via a `::before` overlay.
### Push Notifications (planned) ### Push Notifications (planned)
The web UI is already a PWA (installable, has manifest). The missing half is a service worker + Web Push subscription: The web UI is already a PWA (installable, has manifest). The missing half is a service worker + Web Push subscription:

View File

@@ -392,9 +392,16 @@ go run ./cmd/tui -network friends
| `-join` | — | `waste:` invite string | | `-join` | — | `waste:` invite string |
| `-ipc` | `17337` | Daemon IPC port | | `-ipc` | `17337` | Daemon IPC port |
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit **Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `Ctrl+N` — cycle networks · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
**Slash commands:** `/room <name>` — create a new room (persisted in SQLite, restored on reconnect). Rooms with unread messages show a `*` prefix in the sidebar. **Slash commands:**
- `/room <name>` — create a new room (persisted in SQLite, restored on reconnect)
- `/join <name>` — join a new network at runtime (the `-network` flag is optional; start idle and `/join` to connect)
- `/net <n|name>` — switch active network by index or name
- `/react <emoji>` — react to the last message (use actual emoji characters, e.g. `/react 👍`)
- `/react <n> <emoji>` — react to message `[n]` (line numbers shown next to each message)
Rooms with unread messages show a `*` prefix in the sidebar.
--- ---
@@ -419,6 +426,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
{"type":"create_room","room":"dev"} {"type":"create_room","room":"dev"}
{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network {"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network
{"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"} {"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"}
{"type":"send_reaction","network_id":"...","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
{"type":"export_identity","passphrase":"..."} {"type":"export_identity","passphrase":"..."}
{"type":"import_identity","passphrase":"...","backup":"..."} {"type":"import_identity","passphrase":"...","backup":"..."}
``` ```
@@ -435,6 +443,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}} {"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"} {"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
{"type":"room_created","network_id":"...","room":"dev"} {"type":"room_created","network_id":"...","room":"dev"}
{"type":"reaction","network_id":"...","peer_id":"<64-hex>","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
{"type":"identity_exported","backup":"..."} {"type":"identity_exported","backup":"..."}
{"type":"error","error_message":"..."} {"type":"error","error_message":"..."}
``` ```

View File

@@ -636,6 +636,10 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
emoji = parts[1] 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()] msgs := m.messages[m.msgKey()]
var targetMid string var targetMid string
if targetIdx == -1 && len(msgs) > 0 { if targetIdx == -1 && len(msgs) > 0 {
@@ -643,14 +647,16 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
} else if targetIdx >= 0 && targetIdx < len(msgs) { } else if targetIdx >= 0 && targetIdx < len(msgs) {
targetMid = msgs[targetIdx].mid targetMid = msgs[targetIdx].mid
} }
if targetMid != "" && emoji != "" { 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{ cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{
Type: proto.CmdSendReaction, Type: proto.CmdSendReaction,
NetworkID: m.activeNetworkID(), NetworkID: m.activeNetworkID(),
ReactionMID: targetMid, ReactionMID: targetMid,
ReactionEmoji: emoji, ReactionEmoji: emoji,
})) }))
}
return m, cmds return m, cmds
} }