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 -1
View File
@@ -181,7 +181,7 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
startRemoteRelay() startRemoteRelay()
} else { } else {
messageToast.stopPolling() messageToast.stopPolling()
toastMessage.value = { show: false, text: '', fromPubkey: '' } toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
screensaverStore.clearInactivityTimer() screensaverStore.clearInactivityTimer()
screensaverStore.deactivate() screensaverStore.deactivate()
stopRemoteRelay() stopRemoteRelay()
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mockPush = vi.fn() const mockPush = vi.fn()
@@ -9,6 +10,7 @@ vi.mock('vue-router', () => ({
vi.mock('@/api/rpc-client', () => ({ vi.mock('@/api/rpc-client', () => ({
rpcClient: { rpcClient: {
getReceivedMessages: vi.fn(), getReceivedMessages: vi.fn(),
call: vi.fn(),
}, },
})) }))
@@ -21,13 +23,16 @@ describe('useMessageToast', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.useFakeTimers() vi.useFakeTimers()
localStorage.clear()
setActivePinia(createPinia())
vi.mocked(rpcClient.call).mockResolvedValue({ messages: [], count: 0 })
// Reset shared singleton state // Reset shared singleton state
const toast = useMessageToast() const toast = useMessageToast()
toast.stopPolling() toast.stopPolling()
toast.receivedMessages.value = [] toast.receivedMessages.value = []
toast.lastMessageCount.value = 0 toast.lastMessageCount.value = 0
toast.loadingMessages.value = false toast.loadingMessages.value = false
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' } toast.toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
}) })
afterEach(() => { afterEach(() => {
@@ -143,9 +148,43 @@ describe('useMessageToast', () => {
expect(toast.unreadCount.value).toBe(0) 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', () => { it('dismissToastAndOpenMessages clears toast and navigates', () => {
const toast = useMessageToast() 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() toast.dismissToastAndOpenMessages()
expect(toast.toastMessage.value.show).toBe(false) expect(toast.toastMessage.value.show).toBe(false)
+41 -6
View File
@@ -1,6 +1,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useMeshStore } from '@/stores/mesh'
export interface ReceivedMessage { export interface ReceivedMessage {
from_pubkey: string from_pubkey: string
@@ -14,11 +15,19 @@ const MESSAGE_POLL_INTERVAL = 30000 // 30s
const receivedMessages = ref<ReceivedMessage[]>([]) const receivedMessages = ref<ReceivedMessage[]>([])
const lastMessageCount = ref(0) const lastMessageCount = ref(0)
const loadingMessages = ref(false) 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 let pollTimer: ReturnType<typeof setInterval> | null = null
export function useMessageToast() { export function useMessageToast() {
const router = useRouter() const router = useRouter()
const mesh = useMeshStore()
const unreadCount = computed(() => const unreadCount = computed(() =>
Math.max(0, receivedMessages.value.length - lastMessageCount.value) 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 // Only deep-link to a specific chat when it's a single new message
// from one sender; otherwise open the mesh list. // from one sender; otherwise open the mesh list.
fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '', fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '',
contactId: null,
} }
lastMessageCount.value = msgs.length lastMessageCount.value = msgs.length
} else { } else {
@@ -55,6 +65,26 @@ export function useMessageToast() {
} finally { } finally {
loadingMessages.value = false 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 { function isAuthenticated(): boolean {
@@ -86,16 +116,21 @@ export function useMessageToast() {
} }
function dismissToastAndOpenMessages() { function dismissToastAndOpenMessages() {
const peer = toastMessage.value.fromPubkey const { fromPubkey: peer, contactId } = toastMessage.value
toastMessage.value = { show: false, text: '', fromPubkey: '' } toastMessage.value = emptyToast()
markAsRead() markAsRead()
// Open the specific conversation when we know the sender; else the mesh list. // Open the exact radio conversation by contact id, or the federation
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh') // 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). // Dismiss the toast without navigating (the close icon).
function closeToast() { function closeToast() {
toastMessage.value = { show: false, text: '', fromPubkey: '' } toastMessage.value = emptyToast()
} }
return { return {
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
import { rpcClient } from '@/api/rpc-client'
import { useMeshStore, type MeshMessage } from '../mesh'
const message = (id: number, contact = 7): MeshMessage => ({
id,
direction: 'received',
peer_contact_id: contact,
peer_name: 'Alice',
plaintext: `message ${id}`,
timestamp: `2026-01-${String(id).padStart(2, '0')}`,
delivered: true,
encrypted: true,
transport: 'meshtastic',
})
function reply(messages: MeshMessage[]) {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ messages, count: messages.length })
}
describe('mesh unread persistence', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('does not swallow the first message after initializing with empty history', async () => {
const store = useMeshStore()
reply([])
expect(await store.fetchMessages()).toEqual([])
expect(localStorage.getItem('archipelago.mesh.last-seen.v1')).toBe('{}')
reply([message(1)])
expect(await store.fetchMessages()).toEqual([message(1)])
expect(store.unreadCounts[7]).toBe(1)
})
it('keeps read messages read across a page refresh', async () => {
const firstPage = useMeshStore()
reply([message(1), message(2)])
await firstPage.fetchMessages() // migration seeds existing history as read
firstPage.markChatRead(7)
setActivePinia(createPinia()) // simulate a full page/store reload
const refreshedPage = useMeshStore()
reply([message(1), message(2), message(3)])
const newlyUnread = await refreshedPage.fetchMessages()
expect(newlyUnread.map(m => m.id)).toEqual([3])
expect(refreshedPage.unreadCounts[7]).toBe(1)
refreshedPage.markChatRead(7)
setActivePinia(createPinia())
const readAgain = useMeshStore()
reply([message(1), message(2), message(3)])
expect(await readAgain.fetchMessages()).toEqual([])
expect(readAgain.totalUnread).toBe(0)
})
})
+30 -7
View File
@@ -288,12 +288,27 @@ export const useMeshStore = defineStore('mesh', () => {
// are safe watermarks: the backend allocates them monotonically and // are safe watermarks: the backend allocates them monotonically and
// restores the counter as max(persisted)+1 across restarts. // restores the counter as max(persisted)+1 across restarts.
const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1' const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1'
const lastSeenId = ref<Record<number, number>>( let storedLastSeen = localStorage.getItem(LAST_SEEN_KEY)
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number> function parseLastSeen(raw: string | null): Record<number, number> {
) if (!raw) return {}
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<number, number>
}
} catch {
// Treat corrupt browser state like a first run and safely reseed it.
}
storedLastSeen = null
return {}
}
const lastSeenId = ref<Record<number, number>>(parseLastSeen(storedLastSeen))
// First run after this feature ships: treat existing history as seen so // First run after this feature ships: treat existing history as seen so
// nobody gets a wall of phantom badges for months-old messages. // nobody gets a wall of phantom badges for months-old messages. Complete
let seedLastSeenFromHistory = localStorage.getItem(LAST_SEEN_KEY) === null // this initialization even when history is empty; otherwise the first real
// message to arrive on a brand-new node is mistaken for old history and its
// notification is silently swallowed.
let seedLastSeenFromHistory = storedLastSeen === null
function persistLastSeen() { function persistLastSeen() {
localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value)) localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value))
} }
@@ -481,17 +496,18 @@ export const useMeshStore = defineStore('mesh', () => {
} }
} }
async function fetchMessages(limit?: number) { async function fetchMessages(limit?: number): Promise<MeshMessage[]> {
try { try {
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({ const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
method: 'mesh.messages', method: 'mesh.messages',
params: limit ? { limit } : {}, params: limit ? { limit } : {},
dedup: true, dedup: true,
}) })
if (seedLastSeenFromHistory && res.messages.length > 0) { if (seedLastSeenFromHistory) {
for (const m of res.messages) { for (const m of res.messages) {
if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id) if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id)
} }
// Persist even an empty object as the initialization sentinel.
persistLastSeen() persistLastSeen()
seedLastSeenFromHistory = false seedLastSeenFromHistory = false
} }
@@ -520,8 +536,15 @@ export const useMeshStore = defineStore('mesh', () => {
messages.value = res.messages messages.value = res.messages
// Extract node positions from coordinate messages // Extract node positions from coordinate messages
updateNodePositionsFromMessages(res.messages) updateNodePositionsFromMessages(res.messages)
// The app-wide notification poll uses this exact batch, rather than a
// session message-count delta, so one arrival can never resurrect old
// messages as "11 unread" after a refresh.
return newMsgs.filter(msg => !(
viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value
))
} catch (err: unknown) { } catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages' error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
return []
} }
} }
+7 -1
View File
@@ -565,7 +565,13 @@ function armMeshLive() {
// match an entry in mesh.peers, so without this fallback the deep-link // match an entry in mesh.peers, so without this fallback the deep-link
// silently failed and just landed on the bare mesh page every time. // silently failed and just landed on the bare mesh page every time.
const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : '' const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : ''
if (targetPeer) { const targetContact = typeof route.query.contact === 'string'
? Number(route.query.contact)
: NaN
if (Number.isInteger(targetContact)) {
const match = mesh.peers.find(p => p.contact_id === targetContact)
if (match) openChat(match)
} else if (targetPeer) {
const match = mesh.peers.find( const match = mesh.peers.find(
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer (p) => p.pubkey_hex === targetPeer || p.did === targetPeer
) )