fix(mesh): make radio message notifications durable (#57)

This commit is contained in:
archipelago
2026-08-30 10:16:33 -04:00
parent 2c984fbd49
commit a624d11b6a
6 changed files with 186 additions and 17 deletions
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mockPush = vi.fn()
@@ -9,6 +10,7 @@ vi.mock('vue-router', () => ({
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
getReceivedMessages: vi.fn(),
call: vi.fn(),
},
}))
@@ -21,13 +23,16 @@ describe('useMessageToast', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
localStorage.clear()
setActivePinia(createPinia())
vi.mocked(rpcClient.call).mockResolvedValue({ messages: [], count: 0 })
// Reset shared singleton state
const toast = useMessageToast()
toast.stopPolling()
toast.receivedMessages.value = []
toast.lastMessageCount.value = 0
toast.loadingMessages.value = false
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
toast.toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
})
afterEach(() => {
@@ -143,9 +148,43 @@ describe('useMessageToast', () => {
expect(toast.unreadCount.value).toBe(0)
})
it('shows a radio-mesh toast and deep-links to its contact', async () => {
const toast = useMessageToast()
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
// Initialize an empty node, then deliver its first Meshtastic message.
await toast.loadReceivedMessages()
vi.mocked(rpcClient.call).mockResolvedValueOnce({
messages: [{
id: 1,
direction: 'received',
peer_contact_id: 42,
peer_name: 'Alice',
plaintext: 'Over LoRa',
timestamp: '2026-01-01',
delivered: true,
encrypted: true,
transport: 'meshtastic',
}],
count: 1,
})
await toast.loadReceivedMessages()
expect(toast.toastMessage.value).toMatchObject({
show: true,
text: 'Over LoRa',
contactId: 42,
})
toast.dismissToastAndOpenMessages()
expect(mockPush).toHaveBeenCalledWith({
path: '/dashboard/mesh',
query: { contact: '42' },
})
})
it('dismissToastAndOpenMessages clears toast and navigates', () => {
const toast = useMessageToast()
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' }
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '', contactId: null }
toast.dismissToastAndOpenMessages()
expect(toast.toastMessage.value.show).toBe(false)
+41 -6
View File
@@ -1,6 +1,7 @@
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useMeshStore } from '@/stores/mesh'
export interface ReceivedMessage {
from_pubkey: string
@@ -14,11 +15,19 @@ const MESSAGE_POLL_INTERVAL = 30000 // 30s
const receivedMessages = ref<ReceivedMessage[]>([])
const lastMessageCount = ref(0)
const loadingMessages = ref(false)
const toastMessage = ref<{ show: boolean; text: string; fromPubkey: string }>({ show: false, text: '', fromPubkey: '' })
type MessageToast = {
show: boolean
text: string
fromPubkey: string
contactId: number | null
}
const emptyToast = (): MessageToast => ({ show: false, text: '', fromPubkey: '', contactId: null })
const toastMessage = ref<MessageToast>(emptyToast())
let pollTimer: ReturnType<typeof setInterval> | null = null
export function useMessageToast() {
const router = useRouter()
const mesh = useMeshStore()
const unreadCount = computed(() =>
Math.max(0, receivedMessages.value.length - lastMessageCount.value)
@@ -40,6 +49,7 @@ export function useMessageToast() {
// Only deep-link to a specific chat when it's a single new message
// from one sender; otherwise open the mesh list.
fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '',
contactId: null,
}
lastMessageCount.value = msgs.length
} else {
@@ -55,6 +65,26 @@ export function useMessageToast() {
} finally {
loadingMessages.value = false
}
// Federation messages and radio-mesh messages use separate backend
// queues. Poll the mesh store too so Meshtastic/MeshCore/Reticulum
// arrivals produce the same app-wide toast. fetchMessages returns only
// the newly-unread batch computed from its durable per-contact watermark.
const newMeshMessages = await mesh.fetchMessages()
if (newMeshMessages.length > 0) {
const latest = newMeshMessages[newMeshMessages.length - 1]!
const oneConversation = newMeshMessages.every(
msg => msg.peer_contact_id === latest.peer_contact_id
)
toastMessage.value = {
show: true,
text: newMeshMessages.length === 1
? latest.plaintext
: `${newMeshMessages.length} new messages`,
fromPubkey: '',
contactId: oneConversation ? latest.peer_contact_id : null,
}
}
}
function isAuthenticated(): boolean {
@@ -86,16 +116,21 @@ export function useMessageToast() {
}
function dismissToastAndOpenMessages() {
const peer = toastMessage.value.fromPubkey
toastMessage.value = { show: false, text: '', fromPubkey: '' }
const { fromPubkey: peer, contactId } = toastMessage.value
toastMessage.value = emptyToast()
markAsRead()
// Open the specific conversation when we know the sender; else the mesh list.
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
// Open the exact radio conversation by contact id, or the federation
// conversation by pubkey. Multiple conversations fall back to the list.
if (contactId !== null) {
router.push({ path: '/dashboard/mesh', query: { contact: String(contactId) } })
} else {
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
}
}
// Dismiss the toast without navigating (the close icon).
function closeToast() {
toastMessage.value = { show: false, text: '', fromPubkey: '' }
toastMessage.value = emptyToast()
}
return {