// 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' | 'content_ref' | 'reply' | 'reaction' | 'read_receipt' | 'forward' | 'edit' | 'delete' | 'presence' | 'channel_invite' | 'contact_card' 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 | null /// Cross-transport identity for this message — (sender_pubkey, sender_seq) /// forms a stable MessageKey used by replies/reactions. sender_pubkey?: string | null sender_seq?: number | 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(null) const peers = ref([]) const messages = ref([]) const loading = ref(false) const error = ref(null) const sending = ref(false) // Serialize send operations to prevent concurrent fetchMessages() races let sendQueue: Promise = Promise.resolve() // Node position tracking for map view (contact_id -> position) const nodePositions = ref>(new Map()) // Track unread message counts per peer (contact_id -> count) const unreadCounts = ref>({}) // Currently viewing chat for this contact_id (clears unread) const viewingChatId = ref(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({ 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 { 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) { 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 sendContent(contactId: number, cid: string, caption?: string, peerOnion?: string) { try { sending.value = true error.value = null const res = await rpcClient.call<{ sent: boolean; message_id: number; cid: string; size: number }>({ method: 'mesh.send-content', params: { contact_id: contactId, cid, caption, peer_onion: peerOnion }, }) if (res.sent) await fetchMessages() return res } catch (err: unknown) { error.value = err instanceof Error ? err.message : 'Failed to send content' throw err } finally { sending.value = false } } async function sendReply(contactId: number, targetPubkey: string, targetSeq: number, text: string) { sending.value = true try { const res = await rpcClient.call<{ sent: boolean; message_id: number; sender_seq: number }>({ method: 'mesh.send-reply', params: { contact_id: contactId, target_pubkey: targetPubkey, target_seq: targetSeq, text }, }) if (res.sent) await fetchMessages() return res } finally { sending.value = false } } async function sendReaction(contactId: number, targetPubkey: string, targetSeq: number, emoji: string) { try { const res = await rpcClient.call<{ sent: boolean; message_id: number; sender_seq: number }>({ method: 'mesh.send-reaction', params: { contact_id: contactId, target_pubkey: targetPubkey, target_seq: targetSeq, emoji }, }) if (res.sent) await fetchMessages() return res } catch (err: unknown) { error.value = err instanceof Error ? err.message : 'Failed to send reaction' throw err } } async function getOutbox() { try { return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' }) } catch { return { count: 0 } } } async function sendReadReceipt(contactId: number, targetPubkey: string, targetSeq: number) { try { const res = await rpcClient.call<{ sent: boolean }>({ method: 'mesh.send-read-receipt', params: { contact_id: contactId, target_pubkey: targetPubkey, target_seq: targetSeq }, }) return res } catch { // Read receipts are best-effort — never surface errors to the user. return null } } async function forwardMessage(contactId: number, sourceMessageId: number) { sending.value = true try { const res = await rpcClient.call<{ sent: boolean; message_id: number }>({ method: 'mesh.forward-message', params: { contact_id: contactId, source_message_id: sourceMessageId }, }) if (res.sent) await fetchMessages() return res } finally { sending.value = false } } async function editMessage(contactId: number, targetSeq: number, newText: string) { try { const res = await rpcClient.call<{ sent: boolean; message_id: number }>({ method: 'mesh.edit-message', params: { contact_id: contactId, target_seq: targetSeq, new_text: newText }, }) if (res.sent) await fetchMessages() return res } catch (err: unknown) { error.value = err instanceof Error ? err.message : 'Failed to edit message' throw err } } async function deleteMessage(contactId: number, targetSeq: number) { try { const res = await rpcClient.call<{ sent: boolean; message_id: number }>({ method: 'mesh.delete-message', params: { contact_id: contactId, target_seq: targetSeq }, }) if (res.sent) await fetchMessages() return res } catch (err: unknown) { error.value = err instanceof Error ? err.message : 'Failed to delete message' throw err } } async function fetchContent(params: { cid: string sender_onion: string cap_token: string cap_exp: number mime?: string filename?: string }) { return rpcClient.call<{ fetched: boolean; cached: boolean; cid: string; size?: number; mime?: string }>({ method: 'mesh.fetch-content', params, }) } async function getSessionStatus(contactId: number): Promise { return rpcClient.call({ 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(null) const blockHeaders = ref([]) const latestBlockHeight = ref(0) async function fetchDeadmanStatus() { try { deadmanStatus.value = await rpcClient.call({ 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({ 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, sendContent, fetchContent, sendReply, sendReaction, getOutbox, sendReadReceipt, forwardMessage, editMessage, deleteMessage, getSessionStatus, rotatePrekeys, getNodePositions, updateSelfPosition, fetchDeadmanStatus, configureDeadman, deadmanCheckin, fetchBlockHeaders, relayTransaction, relayLightning, relayStatus, } })