feat(mesh-ui): reply banner + inline reaction chips (Phase 2a)

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) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-13 13:19:30 -04:00
parent a360f90647
commit 649433b7fd
2 changed files with 196 additions and 6 deletions

View File

@@ -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<string, any> | 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,

View File

@@ -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<PendingReply | null>(null)
const actionMenuForId = ref<number | null>(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<Map<string, ReactionChip[]>>(() => {
const perTarget = new Map<string, Map<string, string>>() // 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<string, ReactionChip[]>()
for (const [key, slot] of perTarget) {
const counts = new Map<string, { count: number; by_self: boolean }>()
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"
>
<div class="mesh-chat-bubble" :class="[msg.direction, msg.message_type ? 'typed-' + msg.message_type : '']">
<div class="mesh-chat-bubble" :class="[msg.direction, msg.message_type ? 'typed-' + msg.message_type : '']" @click="openActionMenu(msg.id)">
<div v-if="replyTargetPreview(msg)" class="mesh-chat-reply-quote">
{{ replyTargetPreview(msg) }}
</div>
<!-- Invoice card -->
<div v-if="msg.message_type === 'invoice' && msg.typed_payload" class="mesh-typed-invoice">
<div class="mesh-typed-invoice-header">
@@ -934,12 +1061,39 @@ async function verifyBlobRoundTrip() {
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>
<span class="mesh-chat-bubble-time">{{ timeAgo(msg.timestamp) }}</span>
</div>
<div v-if="reactionsFor(msg).length > 0" class="mesh-chat-reactions">
<span
v-for="chip in reactionsFor(msg)"
:key="chip.emoji"
class="mesh-chat-reaction-chip"
:class="{ 'by-self': chip.by_self }"
>{{ chip.emoji }}<span v-if="chip.count > 1" class="mesh-chat-reaction-count">{{ chip.count }}</span></span>
</div>
<div
v-if="actionMenuForId === msg.id && messageKeyFor(msg) && msg.message_type !== 'reaction' && activeChatPeer"
class="mesh-chat-action-menu"
@click.stop
>
<button class="mesh-chat-action-btn" @click="startReplyTo(msg)">Reply</button>
<button
v-for="emoji in QUICK_REACTIONS"
:key="emoji"
class="mesh-chat-reaction-btn"
@click="reactTo(msg, emoji)"
>{{ emoji }}</button>
<button class="mesh-chat-action-btn" @click="closeActionMenu"></button>
</div>
</div>
</div>
</div>
<div class="mesh-chat-compose">
<div v-if="sendError" class="mesh-chat-send-error">{{ sendError }}</div>
<div v-if="attachError" class="mesh-chat-send-error">{{ attachError }}</div>
<div v-if="pendingReply" class="mesh-chat-pending-reply">
<span class="mesh-typed-icon"></span>
<span class="mesh-chat-pending-name">Replying to: {{ pendingReply.preview }}</span>
<button class="mesh-chat-pending-clear" @click="clearPendingReply" title="Cancel reply"></button>
</div>
<div v-if="pendingAttachment" class="mesh-chat-pending-attachment">
<span class="mesh-typed-icon">📎</span>
<span class="mesh-chat-pending-name">{{ pendingAttachment.filename || pendingAttachment.mime }}</span>
@@ -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')) }}
</button>
</div>
</div>