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