feat: Phase 3 Week 7 — typed message UI, session badges, rich chat cards

Frontend store (mesh.ts):
- Add typed message interfaces: InvoiceData, AlertData, CoordinateData,
  SessionStatus, AlertStatus, MeshMessageTypeLabel
- New actions: sendInvoice, sendCoordinate, sendAlert, getSessionStatus,
  rotatePrekeys

Mesh.vue UI:
- Typed message rendering in chat bubbles:
  - Invoice: orange card with sats amount, memo, bolt11 preview, paid badge
  - Alert: red card (emergency/dead_man) or blue (status), signed badge,
    GPS link to OpenStreetMap
  - Coordinate: blue card with lat/lng, label, OSM map link
  - Block header: purple inline with chain icon
- Session badge in chat header: green shield (Double Ratchet),
  yellow (static encryption), gray (none)
- Session status fetched on peer selection via mesh.session-status RPC

Mock backend:
- Messages now include message_type and typed_payload fields
- Mix of text, invoice (paid + unpaid), alert (emergency + status),
  coordinate, and block_header messages for testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-17 02:34:37 +00:00
parent a5d5faf90c
commit eeb3c77e12
3 changed files with 228 additions and 16 deletions

View File

@ -1520,18 +1520,18 @@ app.post('/rpc/v1', (req, res) => {
const limit = params?.limit || 100
const now = Date.now()
const allMessages = [
{ id: 1, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Node online. Bitcoin Knots synced to tip.', timestamp: new Date(now - 3600000).toISOString(), delivered: true, encrypted: true },
{ id: 2, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Good. Electrs index at 98%. Channel capacity 2.5M sats.', timestamp: new Date(now - 3540000).toISOString(), delivered: true, encrypted: true },
{ id: 3, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'ARCHY:2:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2:d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5', timestamp: new Date(now - 3000000).toISOString(), delivered: true, encrypted: false },
{ id: 4, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Federation state sync complete. 3 containers matched.', timestamp: new Date(now - 1800000).toISOString(), delivered: true, encrypted: true },
{ id: 5, direction: 'sent', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Running mesh-only mode. No internet for 48h. All good.', timestamp: new Date(now - 900000).toISOString(), delivered: true, encrypted: true },
{ id: 6, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Copy. Block height 890,412 via compact headers. 6 confirmations on last tx.', timestamp: new Date(now - 840000).toISOString(), delivered: true, encrypted: true },
{ id: 7, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'New block relayed: 890,413. Fees averaging 12 sat/vB.', timestamp: new Date(now - 600000).toISOString(), delivered: true, encrypted: true },
{ id: 8, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Opening 1M sat channel to your node. Approve?', timestamp: new Date(now - 300000).toISOString(), delivered: true, encrypted: true },
{ id: 9, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Approved. Waiting for funding tx confirmation.', timestamp: new Date(now - 240000).toISOString(), delivered: true, encrypted: true },
{ id: 10, direction: 'received', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Anyone copy? Solar panel restored, back online.', timestamp: new Date(now - 120000).toISOString(), delivered: true, encrypted: false },
{ id: 11, direction: 'sent', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Copy mountain-node. Welcome back. Relaying your backlog.', timestamp: new Date(now - 60000).toISOString(), delivered: true, encrypted: false },
{ id: 12, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Dead man switch check-in. All systems nominal. Battery 78%.', timestamp: new Date(now - 30000).toISOString(), delivered: true, encrypted: true },
{ id: 1, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Node online. Bitcoin Knots synced to tip.', timestamp: new Date(now - 3600000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 2, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Good. Electrs index at 98%. Channel capacity 2.5M sats.', timestamp: new Date(now - 3540000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 3, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'Block #890,413 relayed. Fees avg 12 sat/vB.', timestamp: new Date(now - 3000000).toISOString(), delivered: true, encrypted: true, message_type: 'block_header', typed_payload: { alert_type: 'block_header', message: 'Block #890,413 — 2,847 txs, 12 sat/vB avg fee', signed: true } },
{ id: 4, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice: 50,000 sats — Channel opening fee', timestamp: new Date(now - 1800000).toISOString(), delivered: true, encrypted: true, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: false } },
{ id: 5, direction: 'sent', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Running mesh-only mode. No internet for 48h. All good.', timestamp: new Date(now - 900000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 6, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Copy. Block height 890,412 via compact headers.', timestamp: new Date(now - 840000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 7, direction: 'received', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'EMERGENCY: Solar array failure. Running on battery reserve.', timestamp: new Date(now - 600000).toISOString(), delivered: true, encrypted: false, message_type: 'alert', typed_payload: { alert_type: 'emergency', message: 'Solar array failure. Running on battery reserve. ETA 4h before shutdown.', coordinate: { lat: 39507400, lng: -106042800, label: 'Mountain relay site' }, signed: true } },
{ id: 8, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Opening 1M sat channel to your node. Approve?', timestamp: new Date(now - 300000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 9, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Approved. Waiting for funding tx confirmation.', timestamp: new Date(now - 240000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 10, direction: 'sent', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Location shared', timestamp: new Date(now - 120000).toISOString(), delivered: true, encrypted: false, message_type: 'coordinate', typed_payload: { lat: 30267200, lng: -97743100, label: 'Supply drop point' } },
{ id: 11, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Dead man switch check-in. All systems nominal. Battery 78%.', timestamp: new Date(now - 60000).toISOString(), delivered: true, encrypted: true, message_type: 'alert', typed_payload: { alert_type: 'status', message: 'All systems nominal. Battery 78%. Mesh uptime 14d.', signed: true } },
{ id: 12, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice paid: 50,000 sats', timestamp: new Date(now - 30000).toISOString(), delivered: true, encrypted: true, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: true, payment_hash: 'a1b2c3d4e5f6...' } },
]
return res.json({
result: {

View File

@ -35,6 +35,14 @@ export interface MeshChannel {
has_secret: boolean
}
export type MeshMessageTypeLabel =
| 'text'
| 'alert'
| 'invoice'
| 'psbt_hash'
| 'coordinate'
| 'block_header'
export interface MeshMessage {
id: number
direction: 'sent' | 'received'
@ -44,6 +52,46 @@ export interface MeshMessage {
timestamp: string
delivered: boolean
encrypted: boolean
message_type?: MeshMessageTypeLabel
typed_payload?: InvoiceData | AlertData | CoordinateData | null
}
export interface InvoiceData {
bolt11: string
amount_sats: number
memo: string | null
payment_hash?: string
paid?: boolean
}
export interface AlertData {
alert_type: 'emergency' | 'status' | 'dead_man' | 'block_header'
message: string
coordinate?: { lat: number; lng: number; label?: string }
signed?: boolean
}
export interface CoordinateData {
lat: number
lng: number
label?: string
}
export interface SessionStatus {
has_session: boolean
forward_secrecy: boolean
message_count: number
ratchet_generation: number
peer_did: string | null
}
export interface AlertStatus {
dead_man_enabled: boolean
dead_man_interval_secs: number
triggered: boolean
time_remaining_secs: number
has_gps: boolean
emergency_contacts: number
}
export const useMeshStore = defineStore('mesh', () => {
@ -163,6 +211,64 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
async function sendInvoice(contactId: number, amountSats: number, memo?: string) {
try {
sending.value = true
error.value = null
return await rpcClient.call<{ sent: boolean; amount_sats: number; bolt11: string }>({
method: 'mesh.send-invoice',
params: { contact_id: contactId, amount_sats: amountSats, memo },
})
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send invoice'
throw err
} finally {
sending.value = false
}
}
async function sendCoordinate(contactId: number, lat: number, lng: number, label?: string) {
try {
sending.value = true
error.value = null
return await rpcClient.call<{ sent: boolean }>({
method: 'mesh.send-coordinate',
params: { contact_id: contactId, lat, lng, label },
})
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send coordinate'
throw err
} finally {
sending.value = false
}
}
async function sendAlert(message: string, alertType: string, broadcast = false, lat?: number, lng?: number) {
try {
error.value = null
return await rpcClient.call<{ sent: boolean; signed: boolean }>({
method: 'mesh.send-alert',
params: { message, alert_type: alertType, broadcast, lat, lng },
})
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send alert'
throw err
}
}
async function getSessionStatus(contactId: number): Promise<SessionStatus> {
return rpcClient.call<SessionStatus>({
method: 'mesh.session-status',
params: { contact_id: contactId },
})
}
async function rotatePrekeys() {
return rpcClient.call<{ rotated: boolean; one_time_prekeys: number }>({
method: 'mesh.rotate-prekeys',
})
}
async function refreshAll() {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages()])
}
@ -185,5 +291,10 @@ export const useMeshStore = defineStore('mesh', () => {
refreshAll,
markChatRead,
clearViewingChat,
sendInvoice,
sendCoordinate,
sendAlert,
getSessionStatus,
rotatePrekeys,
}
})

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import { useTransportStore } from '@/stores/transport'
import type { MeshPeer } from '@/stores/mesh'
import type { MeshPeer, SessionStatus } from '@/stores/mesh'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
const mesh = useMeshStore()
@ -22,6 +22,20 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
const publicChannel = { index: 0, name: 'Public' }
const togglingOffGrid = ref(false)
const peerSessionInfo = ref<SessionStatus | null>(null)
// Fetch session status when active peer changes
watch(() => activeChatPeer.value, async (peer) => {
if (peer) {
try {
peerSessionInfo.value = await mesh.getSessionStatus(peer.contact_id)
} catch {
peerSessionInfo.value = null
}
} else {
peerSessionInfo.value = null
}
})
async function handleToggleOffGrid() {
togglingOffGrid.value = true
@ -343,6 +357,7 @@ function truncatePubkey(hex: string | null): string {
<div class="mesh-chat-header-sub">{{ activeChatSub }}</div>
</div>
<div class="mesh-chat-header-status">
<span v-if="activeChatPeer && peerSessionInfo" class="mesh-session-badge" :class="peerSessionInfo.forward_secrecy ? 'session-ratchet' : peerSessionInfo.has_session ? 'session-static' : 'session-none'" :title="peerSessionInfo.forward_secrecy ? 'Double Ratchet (forward secrecy)' : peerSessionInfo.has_session ? 'Static encryption' : 'No encryption'">&#x1F6E1;</span>
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
</div>
</div>
@ -357,8 +372,47 @@ function truncatePubkey(hex: string | null): string {
class="mesh-chat-bubble-wrapper"
:class="msg.direction"
>
<div class="mesh-chat-bubble" :class="msg.direction">
<div class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble" :class="[msg.direction, msg.message_type ? 'typed-' + msg.message_type : '']">
<!-- Invoice card -->
<div v-if="msg.message_type === 'invoice' && 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 Invoice</span>
<span v-if="msg.typed_payload.paid" class="mesh-typed-paid">Paid</span>
</div>
<div class="mesh-typed-invoice-amount">{{ (msg.typed_payload.amount_sats || 0).toLocaleString() }} sats</div>
<div v-if="msg.typed_payload.memo" class="mesh-typed-invoice-memo">{{ msg.typed_payload.memo }}</div>
<div class="mesh-typed-invoice-bolt11">{{ (msg.typed_payload.bolt11 || '').substring(0, 40) }}...</div>
</div>
<!-- Alert card -->
<div v-else-if="msg.message_type === 'alert' && msg.typed_payload" class="mesh-typed-alert" :class="'alert-' + (msg.typed_payload.alert_type || 'status')">
<div class="mesh-typed-alert-header">
<span class="mesh-typed-icon">{{ msg.typed_payload.alert_type === 'emergency' ? '&#x1F6A8;' : msg.typed_payload.alert_type === 'dead_man' ? '&#x2620;' : '&#x2139;' }}</span>
<span class="mesh-typed-label">{{ msg.typed_payload.alert_type === 'emergency' ? 'EMERGENCY' : msg.typed_payload.alert_type === 'dead_man' ? 'DEAD MAN' : 'Status' }}</span>
<span v-if="msg.typed_payload.signed" class="mesh-typed-signed">Signed</span>
</div>
<div class="mesh-typed-alert-message">{{ msg.typed_payload.message }}</div>
<a v-if="msg.typed_payload.coordinate" class="mesh-typed-alert-location" :href="'https://www.openstreetmap.org/?mlat=' + (msg.typed_payload.coordinate.lat / 1000000) + '&mlon=' + (msg.typed_payload.coordinate.lng / 1000000) + '&zoom=14'" target="_blank" rel="noopener">
&#x1F4CD; {{ msg.typed_payload.coordinate.label || 'View location' }}
</a>
</div>
<!-- Coordinate card -->
<div v-else-if="msg.message_type === 'coordinate' && msg.typed_payload" class="mesh-typed-coordinate">
<div class="mesh-typed-coordinate-header">
<span class="mesh-typed-icon">&#x1F4CD;</span>
<span class="mesh-typed-label">Location</span>
</div>
<div class="mesh-typed-coordinate-value">{{ (msg.typed_payload.lat / 1000000).toFixed(4) }}, {{ (msg.typed_payload.lng / 1000000).toFixed(4) }}</div>
<div v-if="msg.typed_payload.label" class="mesh-typed-coordinate-label">{{ msg.typed_payload.label }}</div>
<a class="mesh-typed-coordinate-link" :href="'https://www.openstreetmap.org/?mlat=' + (msg.typed_payload.lat / 1000000) + '&mlon=' + (msg.typed_payload.lng / 1000000) + '&zoom=14'" target="_blank" rel="noopener">Open Map</a>
</div>
<!-- Block header -->
<div v-else-if="msg.message_type === 'block_header' && msg.typed_payload" class="mesh-typed-block">
<span class="mesh-typed-icon">&#x26D3;</span>
<span class="mesh-typed-label">{{ msg.typed_payload.message || msg.plaintext }}</span>
</div>
<!-- Default: plain text -->
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta">
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>
@ -1057,4 +1111,51 @@ function truncatePubkey(hex: string | null): string {
display: block;
}
}
/* ─── Session badge ─── */
.mesh-session-badge {
font-size: 0.75rem;
margin-right: 6px;
opacity: 0.7;
}
.session-ratchet { color: #4ade80; opacity: 1; }
.session-static { color: #fbbf24; }
.session-none { color: rgba(255,255,255,0.3); }
/* ─── Typed message cards ─── */
.mesh-typed-icon { margin-right: 4px; }
.mesh-typed-label { font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
/* Invoice */
.typed-invoice { border-left: 3px solid #fb923c; }
.mesh-typed-invoice { padding: 4px 0; }
.mesh-typed-invoice-header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; color: #fb923c; font-size: 0.75rem; }
.mesh-typed-invoice-amount { font-size: 1.1rem; font-weight: 700; color: #fb923c; }
.mesh-typed-invoice-memo { font-size: 0.8rem; color: rgba(255,255,255,0.7); margin-top: 2px; }
.mesh-typed-invoice-bolt11 { font-size: 0.65rem; color: rgba(255,255,255,0.3); font-family: monospace; margin-top: 4px; word-break: break-all; }
.mesh-typed-paid { background: rgba(74,222,128,0.2); color: #4ade80; font-size: 0.65rem; padding: 1px 6px; border-radius: 4px; margin-left: auto; }
/* Alert */
.typed-alert { border-left: 3px solid #ef4444; }
.typed-alert.alert-status { border-left-color: #3b82f6; }
.mesh-typed-alert { padding: 4px 0; }
.mesh-typed-alert-header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; font-size: 0.75rem; }
.alert-emergency .mesh-typed-alert-header { color: #ef4444; }
.alert-dead_man .mesh-typed-alert-header { color: #ef4444; }
.alert-status .mesh-typed-alert-header { color: #3b82f6; }
.mesh-typed-alert-message { font-size: 0.85rem; color: rgba(255,255,255,0.9); }
.mesh-typed-alert-location { display: block; font-size: 0.75rem; color: #3b82f6; margin-top: 4px; text-decoration: underline; }
.mesh-typed-signed { font-size: 0.6rem; color: #4ade80; border: 1px solid rgba(74,222,128,0.3); padding: 0 4px; border-radius: 3px; margin-left: auto; }
/* Coordinate */
.typed-coordinate { border-left: 3px solid #3b82f6; }
.mesh-typed-coordinate { padding: 4px 0; }
.mesh-typed-coordinate-header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; color: #3b82f6; font-size: 0.75rem; }
.mesh-typed-coordinate-value { font-size: 0.9rem; font-family: monospace; color: rgba(255,255,255,0.8); }
.mesh-typed-coordinate-label { font-size: 0.8rem; color: rgba(255,255,255,0.6); margin-top: 2px; }
.mesh-typed-coordinate-link { display: inline-block; font-size: 0.75rem; color: #3b82f6; margin-top: 4px; text-decoration: underline; }
/* Block header */
.typed-block_header { border-left: 3px solid #a855f7; }
.mesh-typed-block { display: flex; align-items: center; gap: 4px; color: #a855f7; font-size: 0.8rem; }
</style>