From 4a7a95fe9db1728a6326ea6eb535421646212aa7 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Mon, 29 Jun 2026 19:17:16 +0200 Subject: [PATCH] feat: message reactions + link/image previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- FUTURE.md | 13 ++++ internal/ipc/ipc.go | 47 ++++++++++++++ internal/mesh/mesh.go | 11 ++++ internal/mesh/peer.go | 12 ++++ internal/proto/proto.go | 9 +++ internal/store/store.go | 46 ++++++++++++++ web/src/App.css | 20 ++++++ web/src/components/MessagePane.tsx | 98 ++++++++++++++++++++++++++++-- web/src/store/index.ts | 21 +++++++ web/src/types.ts | 5 ++ 10 files changed, 276 insertions(+), 6 deletions(-) diff --git a/FUTURE.md b/FUTURE.md index 70249a5..394f4f4 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -137,6 +137,19 @@ Web frontend (React, already built) + [Wails v2](https://wails.io) shell for nat | โœ… shipped | P2P message history gossip (EXT-007) โ€” new peers receive recent history from first connected peer | | โœ… shipped | Date-aware timestamps in TUI and web UI | | โœ… shipped | Historical peer alias resolution in web UI | +| ๐Ÿ”œ planned | Push notifications (PWA Web Push + service worker) | +| ๐Ÿ”œ planned | Message reactions (emoji, full-stack gossip) | +| ๐Ÿ”œ planned | Link rendering + image preview in messages | + +### 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). --- diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index e98aee2..5899d94 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -244,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 { @@ -505,6 +533,25 @@ func sendStoredHistory(mgr *netmgr.Manager, send func(proto.IpcMessage)) { 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, + }) + } + } + } } } diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index b417443..54bb52e 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -146,6 +146,17 @@ func (m *Mesh) AddPeer(conn *PeerConn) { }) } +// SaveReaction persists a reaction if a store is configured. +// Duplicate (mid, emoji, fromPeer) triples are silently dropped. +func (m *Mesh) SaveReaction(mid, emoji, fromPeer string) { + if m.Store == nil { + return + } + if err := m.Store.SaveReaction(mid, emoji, fromPeer); err != nil { + log.Printf("mesh: store reaction %s/%s: %v", mid, emoji, err) + } +} + // SaveMessage persists a chat message if a store is configured. // Duplicate mids are silently dropped. func (m *Mesh) SaveMessage(msg *proto.ChatMessage) { diff --git a/internal/mesh/peer.go b/internal/mesh/peer.go index 28cab58..482cc9d 100644 --- a/internal/mesh/peer.go +++ b/internal/mesh/peer.go @@ -307,6 +307,18 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) { case proto.MsgHistoryChunk: go m.HandleHistoryChunk(msg.Room, msg.History) + case proto.MsgReaction: + if msg.ReactionMID == "" || msg.ReactionEmoji == "" { + return + } + m.SaveReaction(msg.ReactionMID, msg.ReactionEmoji, string(from)) + m.emit(proto.IpcMessage{ + Type: proto.EvtReaction, + PeerID: peerIDPtr(from), + ReactionMID: msg.ReactionMID, + ReactionEmoji: msg.ReactionEmoji, + }) + case proto.MsgPing: log.Printf("mesh: ping from %s", from.Short()) case proto.MsgPong: diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 432fb02..8302bc6 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -51,6 +51,7 @@ const ( MsgPong MsgType = "pong" MsgHistoryRequest MsgType = "history_request" MsgHistoryChunk MsgType = "history_chunk" + MsgReaction MsgType = "reaction" ) // PmMessage is a private message sent directly over a single peer link (ยง8 "pm"). @@ -97,6 +98,10 @@ type PeerMessage struct { // history_chunk fields History []HistoryEntry `json:"history,omitempty"` HistoryDone bool `json:"history_done,omitempty"` + + // reaction fields + ReactionMID string `json:"reaction_mid,omitempty"` + ReactionEmoji string `json:"reaction_emoji,omitempty"` } // ResumableFile describes a partially-downloaded file found on daemon startup. @@ -280,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" @@ -302,6 +308,7 @@ const ( 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. @@ -359,6 +366,8 @@ type IpcMessage struct { 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 diff --git a/internal/store/store.go b/internal/store/store.go index 58bbbb3..0563b13 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -38,10 +38,19 @@ CREATE TABLE IF NOT EXISTS rooms ( // migrations run after the base schema. ALTER TABLE ADD COLUMN fails with // "duplicate column name" on subsequent opens โ€” we swallow that error. +// CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS are idempotent. var migrations = []string{ // EXT-007: canonical message ID for history dedup (NULL for pre-feature messages). `ALTER TABLE messages ADD COLUMN msg_id TEXT`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages (msg_id) WHERE msg_id IS NOT NULL`, + // Reactions: (mid, emoji, from_peer) triple is the unique dedup key. + `CREATE TABLE IF NOT EXISTS reactions ( + mid TEXT NOT NULL, + emoji TEXT NOT NULL, + from_peer TEXT NOT NULL, + reacted_at DATETIME NOT NULL, + PRIMARY KEY (mid, emoji, from_peer) + )`, } // Store is a local SQLite-backed message and peer store. @@ -208,6 +217,43 @@ func (s *Store) KnownPeers() (map[proto.PeerID]string, error) { return out, rows.Err() } +// SaveReaction persists a reaction. Duplicate (mid, emoji, from_peer) triples are silently ignored. +func (s *Store) SaveReaction(mid, emoji, fromPeer string) error { + _, err := s.db.Exec( + `INSERT OR IGNORE INTO reactions (mid, emoji, from_peer, reacted_at) VALUES (?, ?, ?, ?)`, + mid, emoji, fromPeer, time.Now().UTC(), + ) + return err +} + +// ReactionsForRoom returns all reactions for messages in a given room. +// Result: mid โ†’ emoji โ†’ []fromPeer (ordered by reaction time). +func (s *Store) ReactionsForRoom(room string) (map[string]map[string][]string, error) { + rows, err := s.db.Query(` + SELECT r.mid, r.emoji, r.from_peer + FROM reactions r + JOIN messages m ON m.mid = r.mid + WHERE m.room = ? + ORDER BY r.reacted_at + `, room) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[string]map[string][]string) + for rows.Next() { + var mid, emoji, from string + if err := rows.Scan(&mid, &emoji, &from); err != nil { + return nil, err + } + if out[mid] == nil { + out[mid] = make(map[string][]string) + } + out[mid][emoji] = append(out[mid][emoji], from) + } + return out, rows.Err() +} + func nullableString(s string) any { if s == "" { return nil diff --git a/web/src/App.css b/web/src/App.css index 0e7c800..22f01fd 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -165,6 +165,26 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; } .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; } diff --git a/web/src/components/MessagePane.tsx b/web/src/components/MessagePane.tsx index 12e5c61..a690f90 100644 --- a/web/src/components/MessagePane.tsx +++ b/web/src/components/MessagePane.tsx @@ -12,20 +12,48 @@ function formatTs(ts: number): string { 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( + + {url} + + ) + if (isImage) { + parts.push( + + ) + } + 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, send } = useWaste() + const { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, knownPeers, reactions, sendReaction, send } = useWaste() const [draft, setDraft] = useState('') + const [pickerMid, setPickerMid] = useState(null) const bottomRef = useRef(null) const msgKey = activeNetworkId ? `${activeNetworkId}:${activeRoom}` : activeRoom const roomMessages = messages[msgKey] ?? [] const cutoff = historyCutoff[msgKey] ?? 0 - // Find the index of the first live message (ts > cutoff). - // The divider appears just before this index, or at the top if all are history. const firstLiveIdx = cutoff > 0 ? roomMessages.findIndex(m => m.ts > cutoff) : -1 - // If all messages are history (no live yet), put divider at the start. const dividerIdx = cutoff > 0 ? (firstLiveIdx === -1 ? 0 : firstLiveIdx) : -1 @@ -34,6 +62,14 @@ export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) { 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() @@ -58,6 +94,18 @@ export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) { ?? 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:') ? `@ ${activeRoom.slice(3, 11)}โ€ฆ` : `# ${activeRoom}` @@ -77,16 +125,54 @@ export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) { const mine = msg.from === localPeer?.id 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 ( -
+
{i === dividerIdx && dividerIdx > 0 && (
earlier messages
)}
{ts} {alias} - {msg.text} + + {renderText(msg.text)} + + {mid && ( + + )}
+ {pickerMid === mid && ( +
e.stopPropagation()}> + {EMOJI_SET.map(emoji => ( + + ))} +
+ )} + {hasReactions && ( +
+ {Object.entries(msgReactions!).map(([emoji, fromIds]) => { + const iMine = fromIds.includes(localPeer?.id ?? '') + return ( + + ) + })} +
+ )}
) })} diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 157417f..abbb6eb 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -58,6 +58,8 @@ interface WasteState { fileProgress: Record // partial downloads found on daemon startup: sha256 โ†’ info resumableFiles: Record + // reactions: mid โ†’ emoji โ†’ [fromId, ...] + reactions: Record> // actions connect: (url: string) => void @@ -74,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 } @@ -101,6 +104,7 @@ export const useWaste = create((set, get) => ({ pendingOffers: {}, fileProgress: {}, resumableFiles: {}, + reactions: {}, connect(url: string) { const adapter = new DaemonAdapter(url) @@ -189,6 +193,10 @@ export const useWaste = create((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 @@ -374,6 +382,19 @@ export const useWaste = create((set, get) => ({ 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[] diff --git a/web/src/types.ts b/web/src/types.ts index 4f2e600..d4be9e9 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -86,6 +86,8 @@ export type IpcMsgType = | 'room_created' | 'create_room' | 'resumable_transfers' + | 'send_reaction' + | 'reaction' export interface IpcMessage { type: IpcMsgType @@ -133,4 +135,7 @@ export interface IpcMessage { messages?: ChatMessage[] // resumable_transfers resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }> + // reaction + reaction_mid?: string + reaction_emoji?: string }