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>
This commit is contained in:
Fredrik Johansson
2026-06-29 19:17:16 +02:00
parent 32a6f46481
commit 4a7a95fe9d
10 changed files with 276 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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