diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index a4a28a9b..fa75455a 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -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() diff --git a/neode-ui/src/composables/__tests__/useMessageToast.test.ts b/neode-ui/src/composables/__tests__/useMessageToast.test.ts index 9cdca72b..44cb9ba2 100644 --- a/neode-ui/src/composables/__tests__/useMessageToast.test.ts +++ b/neode-ui/src/composables/__tests__/useMessageToast.test.ts @@ -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) diff --git a/neode-ui/src/composables/useMessageToast.ts b/neode-ui/src/composables/useMessageToast.ts index aafde68d..7f0f079f 100644 --- a/neode-ui/src/composables/useMessageToast.ts +++ b/neode-ui/src/composables/useMessageToast.ts @@ -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([]) 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(emptyToast()) let pollTimer: ReturnType | 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 { diff --git a/neode-ui/src/stores/__tests__/meshUnread.test.ts b/neode-ui/src/stores/__tests__/meshUnread.test.ts new file mode 100644 index 00000000..1929393d --- /dev/null +++ b/neode-ui/src/stores/__tests__/meshUnread.test.ts @@ -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) + }) +}) diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index db7379e6..bc845f72 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -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>( - JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record - ) + let storedLastSeen = localStorage.getItem(LAST_SEEN_KEY) + function parseLastSeen(raw: string | null): Record { + if (!raw) return {} + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // Treat corrupt browser state like a first run and safely reseed it. + } + storedLastSeen = null + return {} + } + const lastSeenId = ref>(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 { 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 [] } } diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 4dceed67..7aa89529 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -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 )