fix(ui): mesh unread badges persist seen-state; more button + animated route modal
Demo images / Build & push demo images (push) Has been cancelled
Demo images / Build & push demo images (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
500aebb3e2
commit
3da1d0b0f7
@@ -281,6 +281,27 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
|
||||
// Track unread message counts per peer (contact_id -> count)
|
||||
const unreadCounts = ref<Record<number, number>>({})
|
||||
// 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<Record<number, number>>(
|
||||
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number>
|
||||
)
|
||||
// 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() {
|
||||
|
||||
Reference in New Issue
Block a user