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>
194 lines
6.7 KiB
TypeScript
194 lines
6.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { useWaste } from '../store'
|
|
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
const todayMs = today.getTime()
|
|
|
|
function formatTs(ts: number): string {
|
|
const d = new Date(ts)
|
|
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
if (ts >= todayMs) return 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 }) {
|
|
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
|
|
|
|
const firstLiveIdx = cutoff > 0
|
|
? roomMessages.findIndex(m => m.ts > cutoff)
|
|
: -1
|
|
const dividerIdx = cutoff > 0
|
|
? (firstLiveIdx === -1 ? 0 : firstLiveIdx)
|
|
: -1
|
|
|
|
useEffect(() => {
|
|
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()
|
|
if (!text || !activeNetworkId) return
|
|
|
|
if (activeRoom.startsWith('dm:')) {
|
|
const toPeerID = activeRoom.slice(3)
|
|
const peer = connectedPeers.find(p => p.id.startsWith(toPeerID) || p.id === toPeerID)
|
|
if (peer) {
|
|
send({ type: 'send_message', network_id: activeNetworkId, room: activeRoom, body: text, to: peer.id })
|
|
}
|
|
} else {
|
|
send({ type: 'send_message', network_id: activeNetworkId, room: activeRoom, body: text })
|
|
}
|
|
setDraft('')
|
|
}
|
|
|
|
function aliasFor(fromId: string) {
|
|
if (fromId === localPeer?.id) return localPeer.alias
|
|
return connectedPeers.find(p => p.id === fromId)?.alias
|
|
?? knownPeers[fromId]
|
|
?? 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}`
|
|
|
|
return (
|
|
<main className="message-pane">
|
|
<div className="message-pane-header">
|
|
<button className="menu-btn-mobile" onClick={onMenuClick} aria-label="Menu">☰</button>
|
|
{roomLabel}
|
|
</div>
|
|
|
|
<div className="messages">
|
|
{dividerIdx === 0 && (
|
|
<div className="history-divider"><span>earlier messages</span></div>
|
|
)}
|
|
{roomMessages.map((msg, i) => {
|
|
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={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">
|
|
{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>
|
|
)
|
|
})}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
<form className="compose" onSubmit={submit}>
|
|
<input
|
|
value={draft}
|
|
onChange={e => setDraft(e.target.value)}
|
|
placeholder={`Message ${roomLabel}`}
|
|
autoComplete="off"
|
|
/>
|
|
<button type="submit" disabled={!draft.trim()}>Send</button>
|
|
</form>
|
|
</main>
|
|
)
|
|
}
|