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