From 4650272abbf8723547f017692d92f952bd56b88e Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 15 May 2026 13:08:03 -0400 Subject: [PATCH] fix(desktop): persist per-channel latest message timestamp so unread badges survive restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useUnreadChannels computes unread as 'latest > readMarker', where latest is an in-session Map populated only by the live subscription. Two facts combine to wipe the comparison on every restart: 1. The live sub subscribes with since: now, so it doesn't backfill. 2. Channel.lastMessageAt from the backend is always null (ChannelRecord has no last_message_at field), so the seed-from-channel-metadata effect is a no-op today. Net effect: every channel's 'latest' is undefined on startup, the predicate returns false, and badges disappear. Read markers via NIP-RS were never actually wiped — they just had nothing left to be compared against. Fix: persist the latestByChannelRef Map to localStorage per pubkey, hydrate on identity setup, and write through whenever the map advances. The write path is already filtered to UNREAD_TRIGGER_KINDS and external authors upstream in useLiveChannelUpdates, so we don't create phantom unread from edits/reactions/system events or from the user's own messages. The deeper fix is to have the backend populate Channel.last_message_at via the existing sprout-db get_last_message_at_bulk and feed it through the list-channels handler; the seeding effect in useUnreadChannels is already wired for that. Filing as follow-up. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> --- .../features/channels/latestMessageStorage.ts | 73 +++++++++++++++++++ .../features/channels/useUnreadChannels.ts | 33 +++++++-- 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 desktop/src/features/channels/latestMessageStorage.ts diff --git a/desktop/src/features/channels/latestMessageStorage.ts b/desktop/src/features/channels/latestMessageStorage.ts new file mode 100644 index 000000000..87fe63569 --- /dev/null +++ b/desktop/src/features/channels/latestMessageStorage.ts @@ -0,0 +1,73 @@ +// Persists the in-session "latest message at" map per pubkey so unread +// indicators survive an app restart. See useUnreadChannels for the consumer. +// +// Without this, the sidebar unread comparison (`latest > readMarker`) has its +// `latest` side reset to empty on every startup — read markers persist via +// NIP-RS, but there is nothing to compare them against, and badges silently +// disappear until a new live message arrives. +// +// Stored shape: { [channelId]: unixSeconds }. Per-pubkey. Callers write +// monotonically; this module validates on read and doesn't trust the file. + +const STORAGE_KEY_PREFIX = "sprout.channel-latest-message.v1"; + +// Cap entries written to localStorage to keep the blob bounded even for users +// in thousands of channels. Same order of magnitude as readStateFormat's +// MAX_CONTEXTS. +const MAX_ENTRIES = 10_000; + +function storageKey(pubkey: string): string { + return `${STORAGE_KEY_PREFIX}:${pubkey}`; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function readStoredLatestMessages(pubkey: string): Map { + const result = new Map(); + let raw: string | null; + try { + raw = localStorage.getItem(storageKey(pubkey)); + } catch { + return result; + } + if (!raw) return result; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return result; + } + if (!isPlainRecord(parsed)) return result; + + for (const [channelId, value] of Object.entries(parsed)) { + if (typeof channelId !== "string" || channelId.length === 0) continue; + if (typeof value !== "number" || !Number.isInteger(value)) continue; + if (value < 0 || value > 4_294_967_295) continue; + result.set(channelId, value); + } + + return result; +} + +export function writeStoredLatestMessages( + pubkey: string, + latest: ReadonlyMap, +): void { + const state: Record = {}; + let count = 0; + for (const [channelId, timestamp] of latest) { + if (count >= MAX_ENTRIES) break; + state[channelId] = timestamp; + count += 1; + } + + try { + localStorage.setItem(storageKey(pubkey), JSON.stringify(state)); + } catch { + // Quota/serialization failure — non-fatal. Worst case is that one + // restart loses badges, which is the status quo we're fixing. + } +} diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 059b7c566..0600b0302 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -3,6 +3,10 @@ import { useLiveChannelUpdates, type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; +import { + readStoredLatestMessages, + writeStoredLatestMessages, +} from "@/features/channels/latestMessageStorage"; import { useReadState } from "@/features/channels/readState/useReadState"; import type { RelayClient } from "@/shared/api/relayClientSession"; import type { Channel, RelayEvent } from "@/shared/api/types"; @@ -72,10 +76,17 @@ export function useUnreadChannels( // Track whether channels have been initialized (for first-load seeding) const hasInitializedChannelsRef = React.useRef(false); - // Reset the in-session state when the identity or relay changes. + // Reset the in-session state when the identity or relay changes, then + // hydrate the per-channel "latest message at" map from localStorage so + // unread badges survive an app restart. Without this, the only source for + // `latest` is the live subscription (which uses `since: now` and so + // doesn't backfill), meaning every channel's latest would be undefined on + // startup and the unread comparison would always be false. // biome-ignore lint/correctness/useExhaustiveDependencies: pubkey/relayClient are intentional reset signals React.useEffect(() => { - latestByChannelRef.current = new Map(); + latestByChannelRef.current = pubkey + ? readStoredLatestMessages(pubkey) + : new Map(); forcedUnreadRef.current = new Set(); hasInitializedChannelsRef.current = false; bumpLatestVersion(); @@ -128,8 +139,13 @@ export function useUnreadChannels( didAdvance = true; } } - if (didAdvance) bumpLatestVersion(); - }, [channels]); + if (didAdvance) { + if (pubkey) { + writeStoredLatestMessages(pubkey, latestByChannelRef.current); + } + bumpLatestVersion(); + } + }, [channels, pubkey]); // Seed read state on first load so existing channels don't flash as unread // when the backend reports a non-null Channel.lastMessageAt. We deliberately @@ -171,18 +187,23 @@ export function useUnreadChannels( ]); // Feed the in-session "latest message at" map from live channel events. - // Composes with any caller-supplied onChannelMessage handler. + // Composes with any caller-supplied onChannelMessage handler. Persists to + // localStorage so unread badges survive an app restart — see the hydrate + // effect above. const callerOnChannelMessage = liveUpdateOptions.onChannelMessage; const handleChannelMessage = React.useCallback( (channelId: string, event: RelayEvent) => { const current = latestByChannelRef.current.get(channelId) ?? 0; if (event.created_at > current) { latestByChannelRef.current.set(channelId, event.created_at); + if (pubkey) { + writeStoredLatestMessages(pubkey, latestByChannelRef.current); + } bumpLatestVersion(); } callerOnChannelMessage?.(channelId, event); }, - [callerOnChannelMessage], + [callerOnChannelMessage, pubkey], ); useLiveChannelUpdates(channels, activeChannelId, {