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>
511 lines
14 KiB
TypeScript
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,
|
|
}
|
|
})
|