// 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' 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 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', () => { const status = ref(null) const peers = ref([]) const messages = ref([]) const loading = ref(false) const error = ref(null) const sending = ref(false) // 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 } catch (err: unknown) { error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages' } } function markChatRead(contactId: number) { viewingChatId.value = contactId delete unreadCounts.value[contactId] } function clearViewingChat() { viewingChatId.value = null } async function sendMessage(contactId: number, message: string) { 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 } } 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 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', }) } async function refreshAll() { await Promise.all([fetchStatus(), fetchPeers(), fetchMessages()]) } return { status, peers, messages, loading, error, sending, unreadCounts, totalUnread, fetchStatus, fetchPeers, fetchMessages, sendMessage, broadcastIdentity, configure, refreshAll, markChatRead, clearViewingChat, sendInvoice, sendCoordinate, sendAlert, getSessionStatus, rotatePrekeys, } })