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

@@ -12,20 +12,48 @@ function formatTs(ts: number): string {
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 }) {
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 [pickerMid, setPickerMid] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const msgKey = activeNetworkId ? `${activeNetworkId}:${activeRoom}` : activeRoom
const roomMessages = messages[msgKey] ?? []
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
? roomMessages.findIndex(m => m.ts > cutoff)
: -1
// If all messages are history (no live yet), put divider at the start.
const dividerIdx = cutoff > 0
? (firstLiveIdx === -1 ? 0 : firstLiveIdx)
: -1
@@ -34,6 +62,14 @@ export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [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) {
e.preventDefault()
const text = draft.trim()
@@ -58,6 +94,18 @@ export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
?? 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:')
? `@ ${activeRoom.slice(3, 11)}`
: `# ${activeRoom}`
@@ -77,16 +125,54 @@ export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
const mine = msg.from === localPeer?.id
const alias = aliasFor(String(msg.from))
const ts = formatTs(msg.ts)
const mid = msg.mid ?? ''
const msgReactions = mid ? reactions[mid] : undefined
const hasReactions = msgReactions && Object.keys(msgReactions).length > 0
return (
<div key={msg.mid ?? i}>
<div key={mid || i} className="message-wrapper">
{i === dividerIdx && dividerIdx > 0 && (
<div className="history-divider"><span>earlier messages</span></div>
)}
<div className={`message ${mine ? 'mine' : ''}`}>
<span className="message-ts">{ts}</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>
{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>
)
})}