From 649433b7fd5d76ecb1c31f8e6669124966615432 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 13 Apr 2026 13:19:30 -0400 Subject: [PATCH] feat(mesh-ui): reply banner + inline reaction chips (Phase 2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tap a bubble to open an action menu with Reply + 6 quick reactions. Reply stashes the target MessageKey and flips the Send button to "Reply" mode, routing through mesh.send-reply. Reactions call mesh.send-reaction immediately and render as chips under the target bubble, collapsed per emoji with a count and self-highlight. Reaction messages are filtered out of the main chat stream so they don't create standalone bubbles. Reply bubbles show a "↳ quoted snippet" header when the target is still in the local window. Co-Authored-By: Claude Opus 4.6 (1M context) --- neode-ui/src/stores/mesh.ts | 36 ++++++++ neode-ui/src/views/Mesh.vue | 166 ++++++++++++++++++++++++++++++++++-- 2 files changed, 196 insertions(+), 6 deletions(-) diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index df52f548..84506409 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -48,6 +48,8 @@ export type MeshMessageTypeLabel = | 'lightning_relay' | 'lightning_relay_response' | 'content_ref' + | 'reply' + | 'reaction' export interface MeshMessage { id: number @@ -61,6 +63,10 @@ export interface MeshMessage { message_type?: MeshMessageTypeLabel // eslint-disable-next-line @typescript-eslint/no-explicit-any typed_payload?: Record | null + /// Cross-transport identity for this message — (sender_pubkey, sender_seq) + /// forms a stable MessageKey used by replies/reactions. + sender_pubkey?: string | null + sender_seq?: number | null } export interface InvoiceData { @@ -394,6 +400,34 @@ export const useMeshStore = defineStore('mesh', () => { } } + async function sendReply(contactId: number, targetPubkey: string, targetSeq: number, text: string) { + sending.value = true + try { + const res = await rpcClient.call<{ sent: boolean; message_id: number; sender_seq: number }>({ + method: 'mesh.send-reply', + params: { contact_id: contactId, target_pubkey: targetPubkey, target_seq: targetSeq, text }, + }) + if (res.sent) await fetchMessages() + return res + } finally { + sending.value = false + } + } + + async function sendReaction(contactId: number, targetPubkey: string, targetSeq: number, emoji: string) { + try { + const res = await rpcClient.call<{ sent: boolean; message_id: number; sender_seq: number }>({ + method: 'mesh.send-reaction', + params: { contact_id: contactId, target_pubkey: targetPubkey, target_seq: targetSeq, emoji }, + }) + if (res.sent) await fetchMessages() + return res + } catch (err: unknown) { + error.value = err instanceof Error ? err.message : 'Failed to send reaction' + throw err + } + } + async function fetchContent(params: { cid: string sender_onion: string @@ -530,6 +564,8 @@ export const useMeshStore = defineStore('mesh', () => { sendAlert, sendContent, fetchContent, + sendReply, + sendReaction, getSessionStatus, rotatePrekeys, getNodePositions, diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 0ddf7591..0b42c7ac 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -2,7 +2,7 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue' import { useMeshStore } from '@/stores/mesh' import { useTransportStore } from '@/stores/transport' -import type { MeshPeer, SessionStatus } from '@/stores/mesh' +import type { MeshMessage, MeshPeer, SessionStatus } from '@/stores/mesh' import AnimatedLogo from '@/components/AnimatedLogo.vue' import MeshMap from '@/components/MeshMap.vue' import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue' @@ -233,7 +233,7 @@ const chatMessages = computed(() => { } else { peerName = m.from_pubkey.slice(0, 12) + '...' } - return { + const mm: MeshMessage = { id: i, peer_contact_id: -99, peer_name: peerName, @@ -244,16 +244,22 @@ const chatMessages = computed(() => { encrypted: false, message_type: undefined, typed_payload: undefined, + sender_pubkey: null, + sender_seq: null, } + return mm }) } + // Reactions are auxiliary — they render as chips under their target + // bubble, not as standalone chat stream entries. + const hideReactions = (m: MeshMessage) => m.message_type !== 'reaction' if (activeChatChannel.value) { const chanId = channelContactId(activeChatChannel.value.index) - return mesh.messages.filter(m => m.peer_contact_id === chanId) + return mesh.messages.filter(m => m.peer_contact_id === chanId && hideReactions(m)) } if (activeChatPeer.value) { const cid = activeChatPeer.value.contact_id - return mesh.messages.filter(m => m.peer_contact_id === cid) + return mesh.messages.filter(m => m.peer_contact_id === cid && hideReactions(m)) } return [] }) @@ -310,6 +316,27 @@ async function handleSendMessage() { nextTick(() => scrollChatToBottom()) return } + // Pending reply: Send flushes as mesh.send-reply targeting the stashed + // MessageKey. Takes precedence over a pending attachment — we don't try + // to express "attach-as-reply" in one go. + if (pendingReply.value && activeChatPeer.value) { + if (!messageText.value.trim()) return + sendError.value = '' + try { + await mesh.sendReply( + activeChatPeer.value.contact_id, + pendingReply.value.target_pubkey, + pendingReply.value.target_seq, + messageText.value.trim(), + ) + messageText.value = '' + pendingReply.value = null + nextTick(() => scrollChatToBottom()) + } catch (err: unknown) { + sendError.value = err instanceof Error ? err.message : 'Reply failed' + } + return + } // Pending share-to-mesh attachment: Send flushes the CID as a ContentRef // rather than a plain text message. Any text in the input becomes the // caption. Only valid for direct peers (channel broadcast of content_ref @@ -444,6 +471,103 @@ async function handleBlobUpload(ev: Event) { } } +// ── Reply + Reaction (Phase 2a) ─────────────────────────────────────────── +// Pending reply state: when the user picks "Reply" on a bubble, we stash its +// MessageKey here; next Send uses mesh.send-reply instead of mesh.send. +interface PendingReply { + target_pubkey: string + target_seq: number + preview: string +} +const pendingReply = ref(null) +const actionMenuForId = ref(null) +const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏'] + +function openActionMenu(msgId: number) { + actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId +} +function closeActionMenu() { + actionMenuForId.value = null +} +function messageKeyFor(msg: { sender_pubkey?: string | null; sender_seq?: number | null }): { pubkey: string; seq: number } | null { + if (!msg.sender_pubkey || msg.sender_seq == null) return null + return { pubkey: msg.sender_pubkey, seq: msg.sender_seq } +} +function startReplyTo(msg: MeshMessage) { + const key = messageKeyFor(msg) + if (!key) return + pendingReply.value = { + target_pubkey: key.pubkey, + target_seq: key.seq, + preview: msg.plaintext.slice(0, 80), + } + closeActionMenu() +} +function clearPendingReply() { + pendingReply.value = null +} +async function reactTo(msg: MeshMessage, emoji: string) { + const key = messageKeyFor(msg) + if (!key || !activeChatPeer.value) return + try { + await mesh.sendReaction(activeChatPeer.value.contact_id, key.pubkey, key.seq, emoji) + } catch (e) { + sendError.value = e instanceof Error ? e.message : 'reaction failed' + } finally { + closeActionMenu() + } +} + +/// Build a map from target MessageKey (pubkey:seq) → emoji strings seen in +/// the current message window. Iterates in timestamp order so the freshest +/// reaction per (reactor, target) wins; an empty emoji clears the reactor. +interface ReactionChip { emoji: string; count: number; by_self: boolean } +const reactionIndex = computed>(() => { + const perTarget = new Map>() // target → (reactor → emoji) + for (const m of mesh.messages) { + if (m.message_type !== 'reaction' || !m.typed_payload) continue + const target = m.typed_payload.target as { sender_pubkey?: string; sender_seq?: number } | undefined + if (!target?.sender_pubkey || target.sender_seq == null) continue + const key = `${target.sender_pubkey}:${target.sender_seq}` + const reactor = m.direction === 'sent' ? '__self__' : (m.sender_pubkey ?? `peer:${m.peer_contact_id}`) + let slot = perTarget.get(key) + if (!slot) { slot = new Map(); perTarget.set(key, slot) } + const emoji = String(m.typed_payload.emoji ?? '') + if (emoji === '') { + slot.delete(reactor) + } else { + slot.set(reactor, emoji) + } + } + const out = new Map() + for (const [key, slot] of perTarget) { + const counts = new Map() + for (const [reactor, emoji] of slot) { + const cur = counts.get(emoji) ?? { count: 0, by_self: false } + cur.count++ + if (reactor === '__self__') cur.by_self = true + counts.set(emoji, cur) + } + out.set(key, Array.from(counts, ([emoji, v]) => ({ emoji, count: v.count, by_self: v.by_self }))) + } + return out +}) +function reactionsFor(msg: { sender_pubkey?: string | null; sender_seq?: number | null }): ReactionChip[] { + const key = messageKeyFor(msg) + if (!key) return [] + return reactionIndex.value.get(`${key.pubkey}:${key.seq}`) ?? [] +} +/// Lookup the target of a reply bubble so we can show a mini-quote above it. +function replyTargetPreview(msg: MeshMessage): string | null { + if (msg.message_type !== 'reply' || !msg.typed_payload) return null + const target = msg.typed_payload.target as { sender_pubkey?: string; sender_seq?: number } | undefined + if (!target?.sender_pubkey || target.sender_seq == null) return null + const match = mesh.messages.find( + (m) => m.sender_pubkey === target.sender_pubkey && m.sender_seq === target.sender_seq, + ) + return match?.plaintext?.slice(0, 80) ?? `→ ${String(target.sender_pubkey).slice(0, 8)}…#${target.sender_seq}` +} + // ── share-to-mesh iframe intent (Phase 3c) ──────────────────────────────── // Marketplace app iframes POST a file to `/api/share-to-mesh` then call // `window.parent.postMessage({type:'share-to-mesh', cid, ...})`. We park the @@ -819,7 +943,10 @@ async function verifyBlobRoundTrip() { class="mesh-chat-bubble-wrapper" :class="msg.direction" > -
+
+
+ ↳ {{ replyTargetPreview(msg) }} +
@@ -934,12 +1061,39 @@ async function verifyBlobRoundTrip() { ✓✓ {{ timeAgo(msg.timestamp) }}
+
+ {{ chip.emoji }}{{ chip.count }} +
+
+ + + +
{{ sendError }}
{{ attachError }}
+
+ + Replying to: {{ pendingReply.preview }} + +
📎 {{ pendingAttachment.filename || pendingAttachment.mime }} @@ -967,7 +1121,7 @@ async function verifyBlobRoundTrip() { :disabled="(!messageText.trim() && !pendingAttachment) || mesh.sending || sendingArch" @click="handleSendMessage" > - {{ (mesh.sending || sendingArch) ? '...' : (pendingAttachment ? 'Share' : 'Send') }} + {{ (mesh.sending || sendingArch) ? '...' : (pendingReply ? 'Reply' : (pendingAttachment ? 'Share' : 'Send')) }}