fix(mesh): make radio message notifications durable (#57)
This commit is contained in:
@@ -181,7 +181,7 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||
startRemoteRelay()
|
||||
} else {
|
||||
messageToast.stopPolling()
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '', contactId: null }
|
||||
screensaverStore.clearInactivityTimer()
|
||||
screensaverStore.deactivate()
|
||||
stopRemoteRelay()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
// 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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -565,7 +565,13 @@ function armMeshLive() {
|
||||
// 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.
|
||||
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(
|
||||
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user