Dorian 07ca6ca286 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>
2026-04-13 08:01:21 -04:00

511 lines
14 KiB
TypeScript

// Pinia store for mesh networking state (Meshcore LoRa)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
export interface MeshStatus {
enabled: boolean
device_type: string
device_path: string | null
device_connected: boolean
firmware_version: string | null
self_node_id: number | null
self_advert_name: string | null
peer_count: number
channel_name: string
messages_sent: number
messages_received: number
detected_devices?: string[]
}
export interface MeshPeer {
contact_id: number
advert_name: string
did: string | null
pubkey_hex: string | null
rssi: number | null
snr: number | null
last_heard: string
hops: number
}
export interface MeshChannel {
index: number
name: string
has_secret: boolean
}
export type MeshMessageTypeLabel =
| 'text'
| 'alert'
| 'invoice'
| 'psbt_hash'
| 'coordinate'
| 'block_header'
| 'tx_relay'
| 'tx_relay_response'
| 'tx_confirmation'
| 'lightning_relay'
| 'lightning_relay_response'
export interface MeshMessage {
id: number
direction: 'sent' | 'received'
peer_contact_id: number
peer_name: string | null
plaintext: string
timestamp: string
delivered: boolean
encrypted: boolean
message_type?: MeshMessageTypeLabel
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typed_payload?: Record<string, any> | 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 interface BlockHeader {
height: number
hash: string
prev_hash: string
timestamp: number
announced_by: string
}
export interface NodePosition {
lat: number
lng: number
label?: string
timestamp: string
}
export const useMeshStore = defineStore('mesh', () => {
const status = ref<MeshStatus | null>(null)
const peers = ref<MeshPeer[]>([])
const messages = ref<MeshMessage[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const sending = ref(false)
// Serialize send operations to prevent concurrent fetchMessages() races
let sendQueue: Promise<void> = Promise.resolve()
// Node position tracking for map view (contact_id -> position)
const nodePositions = ref<Map<number, NodePosition>>(new Map())
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
// Currently viewing chat for this contact_id (clears unread)
const viewingChatId = ref<number | null>(null)
// Total unread count for nav badge
const totalUnread = computed(() =>
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
)
async function fetchStatus() {
try {
loading.value = true
error.value = null
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
status.value = res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
} finally {
loading.value = false
}
}
async function fetchPeers() {
try {
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
method: 'mesh.peers',
})
peers.value = res.peers
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh peers'
}
}
async function fetchMessages(limit?: number) {
try {
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
method: 'mesh.messages',
params: limit ? { limit } : {},
})
// Detect new incoming messages and increment unread counts
const newMsgs = res.messages.filter(
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
)
for (const msg of newMsgs) {
// Don't count as unread if we're currently viewing that chat
if (msg.peer_contact_id !== viewingChatId.value) {
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
}
}
messages.value = res.messages
// Extract node positions from coordinate messages
updateNodePositionsFromMessages(res.messages)
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
}
}
// Convert microdegrees (from mesh protocol) to degrees for Leaflet
// Values > 90 for lat or > 180 for lng indicate microdegrees
function toDegreesIfMicro(lat: number, lng: number): { lat: number; lng: number } {
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) {
return { lat: lat / 1000000, lng: lng / 1000000 }
}
return { lat, lng }
}
function updateNodePositionsFromMessages(msgs: MeshMessage[]) {
for (const msg of msgs) {
if (msg.message_type === 'coordinate' && msg.typed_payload) {
const payload = msg.typed_payload as CoordinateData
if (typeof payload.lat === 'number' && typeof payload.lng === 'number') {
const existing = nodePositions.value.get(msg.peer_contact_id)
if (!existing || msg.timestamp > existing.timestamp) {
const deg = toDegreesIfMicro(payload.lat, payload.lng)
nodePositions.value.set(msg.peer_contact_id, {
lat: deg.lat,
lng: deg.lng,
label: payload.label,
timestamp: msg.timestamp,
})
}
}
}
// Also extract coordinates from alert messages that include location
if (msg.message_type === 'alert' && msg.typed_payload) {
const payload = msg.typed_payload as AlertData
if (payload.coordinate && typeof payload.coordinate.lat === 'number' && typeof payload.coordinate.lng === 'number') {
const existing = nodePositions.value.get(msg.peer_contact_id)
if (!existing || msg.timestamp > existing.timestamp) {
const deg = toDegreesIfMicro(payload.coordinate.lat, payload.coordinate.lng)
nodePositions.value.set(msg.peer_contact_id, {
lat: deg.lat,
lng: deg.lng,
label: payload.coordinate.label,
timestamp: msg.timestamp,
})
}
}
}
}
}
function getNodePositions(): Map<number, NodePosition> {
return nodePositions.value
}
// Update self node position from deadman GPS data (contact_id = -1 for self)
function updateSelfPosition(lat: number, lng: number, label?: string) {
nodePositions.value.set(-1, {
lat,
lng,
label: label ?? 'This Node',
timestamp: new Date().toISOString(),
})
}
function markChatRead(contactId: number) {
viewingChatId.value = contactId
delete unreadCounts.value[contactId]
}
function clearViewingChat() {
viewingChatId.value = null
}
async function sendMessage(contactId: number, message: string) {
const doSend = async () => {
try {
sending.value = true
error.value = null
const res = await rpcClient.call<{ sent: boolean; message_id: number; encrypted: boolean }>({
method: 'mesh.send',
params: { contact_id: contactId, message: message.trim() },
})
// Refresh messages after sending
if (res.sent) {
await fetchMessages()
}
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send mesh message'
throw err
} finally {
sending.value = false
}
}
// Chain onto send queue to prevent concurrent fetchMessages() calls
const result = sendQueue.then(doSend, doSend)
sendQueue = result.then(() => {}, () => {})
return result
}
async function sendChannelMessage(channel: number, message: string) {
const doSend = async () => {
try {
sending.value = true
error.value = null
const res = await rpcClient.call<{ sent: boolean; message_id: number; channel: number }>({
method: 'mesh.send-channel',
params: { channel, message: message.trim() },
})
if (res.sent) {
await fetchMessages()
}
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send channel message'
throw err
} finally {
sending.value = false
}
}
const result = sendQueue.then(doSend, doSend)
sendQueue = result.then(() => {}, () => {})
return result
}
async function broadcastIdentity() {
try {
error.value = null
await rpcClient.call<{ broadcast: boolean }>({ method: 'mesh.broadcast' })
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to broadcast identity'
throw err
}
}
async function configure(config: Partial<MeshStatus>) {
try {
error.value = null
await rpcClient.call<{ configured: boolean }>({
method: 'mesh.configure',
params: config,
})
await fetchStatus()
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to configure mesh'
throw err
}
}
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',
})
}
// ─── Phase 4: Off-Grid Bitcoin Operations ────────────────────────────
const deadmanStatus = ref<AlertStatus | null>(null)
const blockHeaders = ref<BlockHeader[]>([])
const latestBlockHeight = ref(0)
async function fetchDeadmanStatus() {
try {
deadmanStatus.value = await rpcClient.call<AlertStatus>({ method: 'mesh.deadman-status' })
} catch {
// Dead man switch not available
}
}
async function configureDeadman(config: {
enabled?: boolean
interval_secs?: number
lat?: number
lng?: number
label?: string
contacts?: string[]
custom_message?: string
auto_gps?: boolean
}) {
return rpcClient.call<AlertStatus>({
method: 'mesh.deadman-configure',
params: config,
})
}
async function deadmanCheckin() {
return rpcClient.call<{ checked_in: boolean; time_remaining_secs: number }>({
method: 'mesh.deadman-checkin',
})
}
async function fetchBlockHeaders(count = 10) {
try {
const res = await rpcClient.call<{ headers: BlockHeader[]; latest_height: number; count: number }>({
method: 'mesh.block-headers',
params: { count },
})
blockHeaders.value = res.headers
latestBlockHeight.value = res.latest_height
} catch {
// Block headers not available
}
}
async function relayTransaction(txHex: string, mode: 'archy' | 'broadcast' = 'archy') {
return rpcClient.call<{ request_id: number; queued: boolean; tx_hex_len: number }>({
method: 'mesh.relay-tx',
params: { tx_hex: txHex, relay_mode: mode },
})
}
async function relayLightning(bolt11: string, amountSats: number) {
return rpcClient.call<{ request_id: number; queued: boolean; amount_sats: number }>({
method: 'mesh.relay-lightning',
params: { bolt11, amount_sats: amountSats },
})
}
async function relayStatus(requestId: number) {
return rpcClient.call<{
status: 'pending' | 'confirmed' | 'failed' | 'unknown'
request_id: number
txid?: string
error?: string
error_code?: string
completed_at?: string
}>({
method: 'mesh.relay-status',
params: { request_id: requestId },
})
}
async function refreshAll() {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
}
return {
status,
peers,
messages,
loading,
error,
sending,
unreadCounts,
totalUnread,
nodePositions,
deadmanStatus,
blockHeaders,
latestBlockHeight,
fetchStatus,
fetchPeers,
fetchMessages,
sendMessage,
sendChannelMessage,
broadcastIdentity,
configure,
refreshAll,
markChatRead,
clearViewingChat,
sendInvoice,
sendCoordinate,
sendAlert,
getSessionStatus,
rotatePrekeys,
getNodePositions,
updateSelfPosition,
fetchDeadmanStatus,
configureDeadman,
deadmanCheckin,
fetchBlockHeaders,
relayTransaction,
relayLightning,
relayStatus,
}
})