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:
@@ -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,
|
||||
|
||||
@@ -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">✓✓</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>
|
||||
|
||||
Reference in New Issue
Block a user