Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions desktop/src/features/channels/latestMessageStorage.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function readStoredLatestMessages(pubkey: string): Map<string, number> {
const result = new Map<string, number>();
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<string, number>,
): void {
const state: Record<string, number> = {};
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.
}
}
33 changes: 27 additions & 6 deletions desktop/src/features/channels/useUnreadChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand Down
Loading