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:
@@ -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'">🛡</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">⚡</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' ? '🚨' : msg.typed_payload.alert_type === 'dead_man' ? '☠' : 'ℹ' }}</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">
|
||||
📍 {{ 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">📍</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">⛓</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">✓✓</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>
|
||||
|
||||
Reference in New Issue
Block a user