feat(mesh-ui): render tx/lightning relay typed messages and skip self-send

Adds renderers for tx_relay, tx_relay_response, tx_confirmation,
lightning_relay, and lightning_relay_response message types so these
appear as rich cards in the chat stream. sendArchMessage now looks up
our own onion via getTorAddress and skips federation peers that match,
preventing the duplicate "echoed back to self" message we were seeing
on single-node test federations. Empty-federation error message is
also clearer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-13 08:01:21 -04:00
parent 3ed9243c50
commit 36cd3f4e7d
2 changed files with 57 additions and 3 deletions

View File

@@ -42,6 +42,11 @@ export type MeshMessageTypeLabel =
| 'psbt_hash'
| 'coordinate'
| 'block_header'
| 'tx_relay'
| 'tx_relay_response'
| 'tx_confirmation'
| 'lightning_relay'
| 'lightning_relay_response'
export interface MeshMessage {
id: number

View File

@@ -97,11 +97,20 @@ async function sendArchMessage() {
sendingArch.value = true
try {
const nodes = await rpcClient.federationListNodes()
// Get our own onion address to skip sending to self
let selfOnion: string | null = null
try {
const tor = await rpcClient.getTorAddress()
selfOnion = tor.tor_address
} catch { /* non-fatal */ }
const msg = messageText.value.trim()
let sent = 0
for (const node of nodes.nodes) {
const nodeOnion = node.onion || node.did
// Skip sending to ourselves (would create duplicate received message)
if (selfOnion && (nodeOnion === selfOnion || nodeOnion === selfOnion.replace('.onion', '') || selfOnion === nodeOnion + '.onion')) continue
try {
await rpcClient.sendMessageToPeer(node.onion || node.did, msg)
await rpcClient.sendMessageToPeer(nodeOnion, msg)
sent++
} catch { /* some peers may be offline */ }
}
@@ -109,7 +118,7 @@ async function sendArchMessage() {
await rpcClient.call({ method: 'node-store-sent', params: { message: msg } })
} catch { /* non-fatal */ }
messageText.value = ''
if (sent === 0) sendError.value = 'No peers reachable — message may arrive when they come online'
if (sent === 0 && nodes.nodes.length <= 1) sendError.value = 'No other peers in federation — add nodes first'
await loadArchMessages()
} catch (e) {
sendError.value = e instanceof Error ? e.message : 'Send failed'
@@ -217,7 +226,7 @@ const chatMessages = computed(() => {
} else if (m.from_name) {
peerName = m.from_name
} else if (fedNodeNames.value[m.from_pubkey]) {
peerName = fedNodeNames.value[m.from_pubkey]
peerName = fedNodeNames.value[m.from_pubkey]!
} else {
peerName = m.from_pubkey.slice(0, 12) + '...'
}
@@ -642,6 +651,46 @@ function truncatePubkey(hex: string | null): string {
<span class="mesh-typed-icon">&#x26D3;</span>
<span class="mesh-typed-label">{{ msg.typed_payload.message || msg.plaintext }}</span>
</div>
<!-- TX relay request -->
<div v-else-if="msg.message_type === 'tx_relay' && msg.typed_payload" class="mesh-typed-block">
<span class="mesh-typed-icon">&#x21AA;</span>
<span class="mesh-typed-label">TX relay #{{ msg.typed_payload.request_id }} ({{ (msg.typed_payload.tx_hex || '').length }} hex chars)</span>
</div>
<!-- TX relay response -->
<div v-else-if="msg.message_type === 'tx_relay_response' && msg.typed_payload" class="mesh-typed-block">
<span class="mesh-typed-icon">{{ msg.typed_payload.txid ? '&#x2705;' : '&#x274C;' }}</span>
<span class="mesh-typed-label">
<template v-if="msg.typed_payload.txid">Broadcast #{{ msg.typed_payload.request_id }}: {{ String(msg.typed_payload.txid).substring(0, 12) }}</template>
<template v-else>TX relay failed #{{ msg.typed_payload.request_id }}: {{ msg.typed_payload.error || 'unknown' }}</template>
</span>
</div>
<!-- TX confirmation -->
<div v-else-if="msg.message_type === 'tx_confirmation' && msg.typed_payload" class="mesh-typed-block">
<span class="mesh-typed-icon">&#x26D3;</span>
<span class="mesh-typed-label">{{ msg.typed_payload.confirmations }} conf @ block {{ msg.typed_payload.block_height }} {{ String(msg.typed_payload.txid || '').substring(0, 12) }}</span>
</div>
<!-- Lightning relay request -->
<div v-else-if="msg.message_type === 'lightning_relay' && msg.typed_payload" class="mesh-typed-invoice">
<div class="mesh-typed-invoice-header">
<span class="mesh-typed-icon">&#x26A1;</span>
<span class="mesh-typed-label">Lightning Relay Request</span>
</div>
<div class="mesh-typed-invoice-amount">{{ (msg.typed_payload.amount_sats || 0).toLocaleString() }} sats</div>
<div class="mesh-typed-invoice-bolt11">{{ (msg.typed_payload.bolt11 || '').substring(0, 40) }}</div>
</div>
<!-- Lightning relay response -->
<div v-else-if="msg.message_type === 'lightning_relay_response' && msg.typed_payload" class="mesh-typed-invoice">
<div class="mesh-typed-invoice-header">
<span class="mesh-typed-icon">{{ msg.typed_payload.preimage ? '&#x2705;' : '&#x274C;' }}</span>
<span class="mesh-typed-label">
<template v-if="msg.typed_payload.preimage">Lightning Paid</template>
<template v-else>Lightning Failed</template>
</span>
</div>
<div v-if="msg.typed_payload.payment_hash" class="mesh-typed-invoice-bolt11">hash: {{ String(msg.typed_payload.payment_hash).substring(0, 20) }}</div>
<div v-if="msg.typed_payload.preimage" class="mesh-typed-invoice-bolt11">preimage: {{ String(msg.typed_payload.preimage).substring(0, 20) }}</div>
<div v-if="msg.typed_payload.error" class="mesh-typed-invoice-memo">{{ msg.typed_payload.error }}</div>
</div>
<!-- Default: plain text -->
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta">