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

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