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
@@ -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
// restores the counter as max(persisted)+1 across restarts.
const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1'
const lastSeenId = ref<Record<number, number>>(
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number>
)
let storedLastSeen = localStorage.getItem(LAST_SEEN_KEY)
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
// nobody gets a wall of phantom badges for months-old messages.
let seedLastSeenFromHistory = localStorage.getItem(LAST_SEEN_KEY) === null
// nobody gets a wall of phantom badges for months-old messages. Complete
// 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() {
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 {
const res = await rpcClient.call<{ messages: MeshMessage[]; count: number }>({
method: 'mesh.messages',
params: limit ? { limit } : {},
dedup: true,
})
if (seedLastSeenFromHistory && res.messages.length > 0) {
if (seedLastSeenFromHistory) {
for (const m of res.messages) {
if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id)
}
// Persist even an empty object as the initialization sentinel.
persistLastSeen()
seedLastSeenFromHistory = false
}
@@ -520,8 +536,15 @@ export const useMeshStore = defineStore('mesh', () => {
messages.value = res.messages
// Extract node positions from coordinate 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) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
return []
}
}