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:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,17 @@ func (m *Mesh) AddPeer(conn *PeerConn) {
|
||||
})
|
||||
}
|
||||
|
||||
// SaveReaction persists a reaction if a store is configured.
|
||||
// Duplicate (mid, emoji, fromPeer) triples are silently dropped.
|
||||
func (m *Mesh) SaveReaction(mid, emoji, fromPeer string) {
|
||||
if m.Store == nil {
|
||||
return
|
||||
}
|
||||
if err := m.Store.SaveReaction(mid, emoji, fromPeer); err != nil {
|
||||
log.Printf("mesh: store reaction %s/%s: %v", mid, emoji, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SaveMessage persists a chat message if a store is configured.
|
||||
// Duplicate mids are silently dropped.
|
||||
func (m *Mesh) SaveMessage(msg *proto.ChatMessage) {
|
||||
|
||||
@@ -307,6 +307,18 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
||||
case proto.MsgHistoryChunk:
|
||||
go m.HandleHistoryChunk(msg.Room, msg.History)
|
||||
|
||||
case proto.MsgReaction:
|
||||
if msg.ReactionMID == "" || msg.ReactionEmoji == "" {
|
||||
return
|
||||
}
|
||||
m.SaveReaction(msg.ReactionMID, msg.ReactionEmoji, string(from))
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtReaction,
|
||||
PeerID: peerIDPtr(from),
|
||||
ReactionMID: msg.ReactionMID,
|
||||
ReactionEmoji: msg.ReactionEmoji,
|
||||
})
|
||||
|
||||
case proto.MsgPing:
|
||||
log.Printf("mesh: ping from %s", from.Short())
|
||||
case proto.MsgPong:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user