From 3da1d0b0f7610c63390466e38b076d043bc46551 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 29 Jul 2026 07:52:56 -0400 Subject: [PATCH] fix(ui): mesh unread badges persist seen-state; more button + animated route modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unread badges came back on every visit (framework showed a phantom "2" with nothing new): unreadCounts was memory-only, so each page load replayed the entire message history as "new". Seen-state now persists as a per-contact highest-seen-message-id watermark in localStorage (ids are backend-monotonic across restarts); first run after this ships seeds the watermark from history so nobody gets a wall of stale badges. Opening a chat advances and persists the watermark for all twins of the merged conversation. The hop-route modal the user asked for is now reachable from a visible per-message "⋯" button (the transport pill remains clickable too), has a fallback title/branch for messages that predate transport tracking, and animates: endpoints and link reveal in sequence, a pulse travels the link, and relay dots blink in order — all disabled under prefers-reduced-motion. Co-Authored-By: Claude Fable 5 --- neode-ui/src/stores/mesh.ts | 55 +++++++++++++++++++++++-- neode-ui/src/views/Mesh.vue | 6 ++- neode-ui/src/views/mesh/mesh-styles.css | 37 +++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 96788428..68ea0819 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -281,6 +281,27 @@ export const useMeshStore = defineStore('mesh', () => { // Track unread message counts per peer (contact_id -> count) const unreadCounts = ref>({}) + // Durable seen-state: highest message id the user has actually seen, per + // contact. Without this, every page load replayed ALL historical received + // messages into unreadCounts (the in-memory store starts empty, so every + // old message looked "new") — badges came back on every visit. Message ids + // 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 + ) + // 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 + function persistLastSeen() { + localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value)) + } + function advanceLastSeen(contactId: number, msgId: number): boolean { + if ((lastSeenId.value[contactId] ?? 0) >= msgId) return false + lastSeenId.value[contactId] = msgId + return true + } // Contact ids of the chat currently on screen — ALL twins of the merged // conversation, not just the clicked row's id, since the unread badge sums // across every underlying contact_id (a message can land on any twin). @@ -455,19 +476,35 @@ export const useMeshStore = defineStore('mesh', () => { method: 'mesh.messages', params: limit ? { limit } : {}, }) - // Detect new incoming messages and increment unread counts + if (seedLastSeenFromHistory && res.messages.length > 0) { + for (const m of res.messages) { + if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id) + } + persistLastSeen() + seedLastSeenFromHistory = false + } + // Detect new incoming messages and increment unread counts. "New" means + // past the durable seen watermark, not merely absent from the in-memory + // store — otherwise a page reload re-badges the whole history. const newMsgs = res.messages.filter( - m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id) + m => + m.direction === 'received' && + m.id > (lastSeenId.value[m.peer_contact_id] ?? 0) && + !messages.value.some(existing => existing.id === m.id) ) + let seenDirty = false for (const msg of newMsgs) { // Don't count as unread if we're currently viewing that chat AND the // bottom (latest messages) is in view — i.e. the user actually sees it. const seenLive = viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value - if (!seenLive) { + if (seenLive) { + seenDirty = advanceLastSeen(msg.peer_contact_id, msg.id) || seenDirty + } else { unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1 } } + if (seenDirty) persistLastSeen() messages.value = res.messages // Extract node positions from coordinate messages updateNodePositionsFromMessages(res.messages) @@ -595,7 +632,17 @@ export const useMeshStore = defineStore('mesh', () => { function markChatRead(contactId: number | number[]) { const ids = Array.isArray(contactId) ? contactId : [contactId] viewingChatIds.value = ids - for (const id of ids) delete unreadCounts.value[id] + let seenDirty = false + for (const id of ids) { + delete unreadCounts.value[id] + // Persist the watermark so the seen-state survives reloads. + for (const m of messages.value) { + if (m.direction === 'received' && m.peer_contact_id === id) { + seenDirty = advanceLastSeen(id, m.id) || seenDirty + } + } + } + if (seenDirty) persistLastSeen() } function clearViewingChat() { diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 923601a7..9bd1c4e8 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -2344,6 +2344,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
{{ msg.plaintext }}
{{ transportLabel(msg) }} + E2E (edited) ✓✓ @@ -2629,7 +2630,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
-

{{ transportLabel(hopVizMsg) }} route

+

{{ transportLabel(hopVizMsg) || 'Message' }} route

{{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }}

@@ -2644,6 +2645,9 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { +