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

@@ -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 | 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 | Date-aware timestamps in TUI and web UI |
| ✅ shipped | Historical peer alias resolution in 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).
--- ---

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: case proto.CmdCreateRoom:
n := mgr.Resolve(cmd.NetworkID) n := mgr.Resolve(cmd.NetworkID)
if n == nil { if n == nil {
@@ -505,6 +533,25 @@ func sendStoredHistory(mgr *netmgr.Manager, send func(proto.IpcMessage)) {
Room: room, Room: room,
Messages: msgs, 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. // SaveMessage persists a chat message if a store is configured.
// Duplicate mids are silently dropped. // Duplicate mids are silently dropped.
func (m *Mesh) SaveMessage(msg *proto.ChatMessage) { 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: case proto.MsgHistoryChunk:
go m.HandleHistoryChunk(msg.Room, msg.History) 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: case proto.MsgPing:
log.Printf("mesh: ping from %s", from.Short()) log.Printf("mesh: ping from %s", from.Short())
case proto.MsgPong: case proto.MsgPong:

View File

@@ -51,6 +51,7 @@ const (
MsgPong MsgType = "pong" MsgPong MsgType = "pong"
MsgHistoryRequest MsgType = "history_request" MsgHistoryRequest MsgType = "history_request"
MsgHistoryChunk MsgType = "history_chunk" MsgHistoryChunk MsgType = "history_chunk"
MsgReaction MsgType = "reaction"
) )
// PmMessage is a private message sent directly over a single peer link (§8 "pm"). // 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_chunk fields
History []HistoryEntry `json:"history,omitempty"` History []HistoryEntry `json:"history,omitempty"`
HistoryDone bool `json:"history_done,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. // ResumableFile describes a partially-downloaded file found on daemon startup.
@@ -280,6 +285,7 @@ const (
CmdListShares IpcMsgType = "list_shares" // returns shares_list event CmdListShares IpcMsgType = "list_shares" // returns shares_list event
CmdCreateRoom IpcMsgType = "create_room" // field: room (name) CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path 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) // Events (daemon → UI)
EvtMessageReceived IpcMsgType = "message_received" EvtMessageReceived IpcMsgType = "message_received"
@@ -302,6 +308,7 @@ const (
EvtRoomCreated IpcMsgType = "room_created" // field: room (name) EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages
EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files 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. // NetworkInfo summarises one joined network for state_snapshot and network_joined events.
@@ -359,6 +366,8 @@ type IpcMessage struct {
Files []FileEntry `json:"files,omitempty"` Files []FileEntry `json:"files,omitempty"`
Messages []ChatMessage `json:"messages,omitempty"` // history_loaded Messages []ChatMessage `json:"messages,omitempty"` // history_loaded
ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers 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"` Shares []ShareEntry `json:"shares,omitempty"`
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
// export_identity / import_identity // 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 // migrations run after the base schema. ALTER TABLE ADD COLUMN fails with
// "duplicate column name" on subsequent opens — we swallow that error. // "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{ var migrations = []string{
// EXT-007: canonical message ID for history dedup (NULL for pre-feature messages). // EXT-007: canonical message ID for history dedup (NULL for pre-feature messages).
`ALTER TABLE messages ADD COLUMN msg_id TEXT`, `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`, `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. // 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() 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 { func nullableString(s string) any {
if s == "" { if s == "" {
return nil return nil

View File

@@ -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 { 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); } .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 ── */ /* ── 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 { 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; } .menu-btn-mobile:hover { color: var(--text); background: none; }

View File

@@ -12,20 +12,48 @@ function formatTs(ts: number): string {
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time 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(
<a key={m.index} href={url} target="_blank" rel="noopener noreferrer" className="msg-link">
{url}
</a>
)
if (isImage) {
parts.push(
<img key={`img-${m.index}`} src={url} alt="" className="msg-image-preview" loading="lazy" />
)
}
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 }) { 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 [draft, setDraft] = useState('')
const [pickerMid, setPickerMid] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const msgKey = activeNetworkId ? `${activeNetworkId}:${activeRoom}` : activeRoom const msgKey = activeNetworkId ? `${activeNetworkId}:${activeRoom}` : activeRoom
const roomMessages = messages[msgKey] ?? [] const roomMessages = messages[msgKey] ?? []
const cutoff = historyCutoff[msgKey] ?? 0 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 const firstLiveIdx = cutoff > 0
? roomMessages.findIndex(m => m.ts > cutoff) ? roomMessages.findIndex(m => m.ts > cutoff)
: -1 : -1
// If all messages are history (no live yet), put divider at the start.
const dividerIdx = cutoff > 0 const dividerIdx = cutoff > 0
? (firstLiveIdx === -1 ? 0 : firstLiveIdx) ? (firstLiveIdx === -1 ? 0 : firstLiveIdx)
: -1 : -1
@@ -34,6 +62,14 @@ export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [roomMessages.length]) }, [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) { function submit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
const text = draft.trim() const text = draft.trim()
@@ -58,6 +94,18 @@ export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
?? fromId.slice(0, 8) ?? 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:') const roomLabel = activeRoom.startsWith('dm:')
? `@ ${activeRoom.slice(3, 11)}` ? `@ ${activeRoom.slice(3, 11)}`
: `# ${activeRoom}` : `# ${activeRoom}`
@@ -77,16 +125,54 @@ export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
const mine = msg.from === localPeer?.id const mine = msg.from === localPeer?.id
const alias = aliasFor(String(msg.from)) const alias = aliasFor(String(msg.from))
const ts = formatTs(msg.ts) const ts = formatTs(msg.ts)
const mid = msg.mid ?? ''
const msgReactions = mid ? reactions[mid] : undefined
const hasReactions = msgReactions && Object.keys(msgReactions).length > 0
return ( return (
<div key={msg.mid ?? i}> <div key={mid || i} className="message-wrapper">
{i === dividerIdx && dividerIdx > 0 && ( {i === dividerIdx && dividerIdx > 0 && (
<div className="history-divider"><span>earlier messages</span></div> <div className="history-divider"><span>earlier messages</span></div>
)} )}
<div className={`message ${mine ? 'mine' : ''}`}> <div className={`message ${mine ? 'mine' : ''}`}>
<span className="message-ts">{ts}</span> <span className="message-ts">{ts}</span>
<span className="message-alias">{alias}</span> <span className="message-alias">{alias}</span>
<span className="message-text">{msg.text}</span> <span className="message-text">
{renderText(msg.text)}
</span>
{mid && (
<button
className="reaction-add"
onClick={e => openPicker(mid, e)}
title="React"
>+</button>
)}
</div> </div>
{pickerMid === mid && (
<div className="reaction-picker" onClick={e => e.stopPropagation()}>
{EMOJI_SET.map(emoji => (
<button key={emoji} className="reaction-picker-btn" onClick={e => toggleReaction(mid, emoji, e)}>
{emoji}
</button>
))}
</div>
)}
{hasReactions && (
<div className="reaction-bar">
{Object.entries(msgReactions!).map(([emoji, fromIds]) => {
const iMine = fromIds.includes(localPeer?.id ?? '')
return (
<button
key={emoji}
className={`reaction-chip ${iMine ? 'mine' : ''}`}
onClick={e => toggleReaction(mid, emoji, e)}
title={fromIds.map(aliasFor).join(', ')}
>
{emoji} {fromIds.length}
</button>
)
})}
</div>
)}
</div> </div>
) )
})} })}

View File

@@ -58,6 +58,8 @@ interface WasteState {
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }> fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
// partial downloads found on daemon startup: sha256 → info // partial downloads found on daemon startup: sha256 → info
resumableFiles: Record<string, { name: string; from: string; size: number; offset: number }> resumableFiles: Record<string, { name: string; from: string; size: number; offset: number }>
// reactions: mid → emoji → [fromId, ...]
reactions: Record<string, Record<string, string[]>>
// actions // actions
connect: (url: string) => void connect: (url: string) => void
@@ -74,6 +76,7 @@ interface WasteState {
rejectOffer: (peerId: string, xid: string) => void rejectOffer: (peerId: string, xid: string) => void
cancelTransfer: (peerId: string, xid: string, direction: 'recv' | 'send') => void cancelTransfer: (peerId: string, xid: string, direction: 'recv' | 'send') => void
createRoom: (name: string) => void createRoom: (name: string) => void
sendReaction: (networkId: string, mid: string, emoji: string) => void
logout: (clearIdentity: boolean) => void logout: (clearIdentity: boolean) => void
handleEvent: (msg: IpcMessage) => void handleEvent: (msg: IpcMessage) => void
} }
@@ -101,6 +104,7 @@ export const useWaste = create<WasteState>((set, get) => ({
pendingOffers: {}, pendingOffers: {},
fileProgress: {}, fileProgress: {},
resumableFiles: {}, resumableFiles: {},
reactions: {},
connect(url: string) { connect(url: string) {
const adapter = new DaemonAdapter(url) const adapter = new DaemonAdapter(url)
@@ -189,6 +193,10 @@ export const useWaste = create<WasteState>((set, get) => ({
window.location.reload() window.location.reload()
}, },
sendReaction(networkId, mid, emoji) {
get().send({ type: 'send_reaction', network_id: networkId, reaction_mid: mid, reaction_emoji: emoji })
},
createRoom(name) { createRoom(name) {
const netId = get().activeNetworkId const netId = get().activeNetworkId
if (!netId || !name.trim()) return if (!netId || !name.trim()) return
@@ -374,6 +382,19 @@ export const useWaste = create<WasteState>((set, get) => ({
set(s => ({ resumableFiles: { ...s.resumableFiles, ...byHash } })) set(s => ({ resumableFiles: { ...s.resumableFiles, ...byHash } }))
break 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': { case 'history_loaded': {
const room = msg.room const room = msg.room
const incoming = (msg.messages ?? []) as ChatMessage[] const incoming = (msg.messages ?? []) as ChatMessage[]

View File

@@ -86,6 +86,8 @@ export type IpcMsgType =
| 'room_created' | 'room_created'
| 'create_room' | 'create_room'
| 'resumable_transfers' | 'resumable_transfers'
| 'send_reaction'
| 'reaction'
export interface IpcMessage { export interface IpcMessage {
type: IpcMsgType type: IpcMsgType
@@ -133,4 +135,7 @@ export interface IpcMessage {
messages?: ChatMessage[] messages?: ChatMessage[]
// resumable_transfers // resumable_transfers
resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }> resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
// reaction
reaction_mid?: string
reaction_emoji?: string
} }