diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 6cb02870a..5e8484863 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -62,6 +62,9 @@ export class ReadStateManager { private maxFetchedCreatedAt = 0; private forcedContexts = new Set(); private contextSourceCreatedAt = new Map(); + private pendingSyncedRollbacks = new Set(); + private pendingSyncedAdvances = new Set(); + private destroyed = false; constructor(pubkey: string, relayClient: RelayClient) { this.pubkey = pubkey; @@ -75,17 +78,25 @@ export class ReadStateManager { } async initialize(): Promise { - if (this.initialized) return; + if (this.initialized || this.destroyed) return; + console.debug( + `[ReadStateManager] initialize pubkey=${this.pubkey.substring(0, 8)}… clientId=${this.clientId.substring(0, 8)}… slotId=${this.slotId}`, + ); this.hydrateFromLocalStorage(); await this.fetchAndMerge(); + if (this.destroyed) return; await this.startLiveSubscription(); + if (this.destroyed) return; if (!this.isIdenticalToLastPublished(this.currentContexts())) { this.schedulePublish(); } this.initialized = true; + console.debug( + `[ReadStateManager] initialize complete maxFetchedCreatedAt=${this.maxFetchedCreatedAt} contexts=${this.effectiveState.size}`, + ); this.notifyListeners(); } @@ -152,6 +163,7 @@ export class ReadStateManager { } destroy(): void { + this.destroyed = true; // Flush any pending writes immediately if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -177,7 +189,8 @@ export class ReadStateManager { since: Math.floor(Date.now() / 1_000) - READ_STATE_HORIZON_SECONDS, limit: READ_STATE_FETCH_LIMIT, }); - } catch { + } catch (error) { + console.debug("[ReadStateManager] fetchAndMerge failed:", error); // If fetch fails, proceed with local state only return; } @@ -219,7 +232,11 @@ export class ReadStateManager { client_id: parsed.client_id, contexts: sanitizeContexts(parsed.contexts), }; - } catch { + } catch (error) { + console.debug( + `[ReadStateManager] mergeEvents decrypt failed event=${event.id.substring(0, 8)}…:`, + error, + ); continue; } @@ -260,7 +277,11 @@ export class ReadStateManager { localStorage.setItem(slotIdKey(this.pubkey), this.slotId); break; } - } catch { + } catch (error) { + console.debug( + `[ReadStateManager] conflict check decrypt failed event=${event.id.substring(0, 8)}…:`, + error, + ); // Decrypt failure — skip this event } } @@ -286,14 +307,24 @@ export class ReadStateManager { void this.handleIncomingEvent(event); }, ); + if (this.destroyed) { + unsub(); + return; + } this.unsubscribeLive = unsub; - } catch { + console.debug("[ReadStateManager] live subscription established"); + } catch (error) { + console.debug("[ReadStateManager] live subscription FAILED:", error); // Non-fatal: we can still work with local state } } private async handleIncomingEvent(event: RelayEvent): Promise { if (event.pubkey !== this.pubkey) return; + if (this.destroyed) return; + console.debug( + `[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`, + ); const dTags = event.tags.filter((t) => t[0] === "d"); if (dTags.length !== 1) return; @@ -320,7 +351,11 @@ export class ReadStateManager { client_id: parsed.client_id, contexts: sanitizeContexts(parsed.contexts), }; - } catch { + } catch (error) { + console.debug( + `[ReadStateManager] incoming event decrypt/parse failed event=${event.id.substring(0, 8)}…:`, + error, + ); return; } @@ -331,6 +366,16 @@ export class ReadStateManager { const current = this.effectiveState.get(ctx) ?? 0; if (event.created_at > sourceCreatedAt) { if (this.effectiveState.get(ctx) !== ts) { + if (ts < current && current > 0) { + this.pendingSyncedRollbacks.add(ctx); + this.pendingSyncedAdvances.delete(ctx); + console.debug( + `[ReadStateManager] synced rollback ctx=${ctx.substring(0, 12)}… from=${current} to=${ts}`, + ); + } else if (ts > current) { + this.pendingSyncedAdvances.add(ctx); + this.pendingSyncedRollbacks.delete(ctx); + } this.effectiveState.set(ctx, ts); anyAdvanced = true; } @@ -344,6 +389,9 @@ export class ReadStateManager { anyAdvanced = true; } } + console.debug( + `[ReadStateManager] incoming result anyAdvanced=${anyAdvanced} clientId=${blob.client_id.substring(0, 8)}…`, + ); if (anyAdvanced) { this.persistLocalState(); @@ -368,6 +416,7 @@ export class ReadStateManager { } private async publish(): Promise { + console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`); await this.fetchOwnBlobBeforePublish(); // Build blob from contexts this client is allowed to publish. @@ -408,12 +457,17 @@ export class ReadStateManager { "Timed out publishing read state.", "Failed to publish read state.", ); + console.debug( + `[ReadStateManager] publish accepted createdAt=${createdAt}`, + ); - this.lastPublishedContexts = contexts; - this.forcedContexts.clear(); for (const key of Object.keys(contexts)) { - this.contextSourceCreatedAt.set(key, createdAt); + if (this.lastPublishedContexts[key] !== contexts[key]) { + this.contextSourceCreatedAt.set(key, createdAt); + } } + this.lastPublishedContexts = contexts; + this.forcedContexts.clear(); this.maxFetchedCreatedAt = Math.max( this.maxFetchedCreatedAt, event.created_at, @@ -435,7 +489,11 @@ export class ReadStateManager { await this.mergeEvents(events); this.persistLocalState(); - } catch { + } catch (error) { + console.debug( + "[ReadStateManager] fetchOwnBlobBeforePublish failed:", + error, + ); // Per NIP-RS, proceed with reachable data and merge on a later fetch. } } @@ -486,11 +544,24 @@ export class ReadStateManager { ); } + drainSyncedRollbacks(): ReadonlySet { + const drained = this.pendingSyncedRollbacks; + this.pendingSyncedRollbacks = new Set(); + return drained; + } + + drainSyncedAdvances(): ReadonlySet { + const drained = this.pendingSyncedAdvances; + this.pendingSyncedAdvances = new Set(); + return drained; + } + private notifyListeners(): void { for (const listener of this.listeners) { try { listener(); - } catch { + } catch (error) { + console.debug("[ReadStateManager] listener threw:", error); // Don't let a broken listener break the manager } } diff --git a/desktop/src/features/channels/readState/readStateStorage.ts b/desktop/src/features/channels/readState/readStateStorage.ts index 497f10c35..056b0226e 100644 --- a/desktop/src/features/channels/readState/readStateStorage.ts +++ b/desktop/src/features/channels/readState/readStateStorage.ts @@ -31,7 +31,8 @@ function mergeLocalStorageKey( contexts.set(channelId, unixSeconds); } } - } catch { + } catch (error) { + console.debug("[ReadStateManager] storage: contexts JSON corrupt:", error); // Corrupt localStorage, ignore. } } @@ -50,7 +51,11 @@ function readPublishableContextIds(pubkey: string): Set { result.add(value); } } - } catch { + } catch (error) { + console.debug( + "[ReadStateManager] storage: publishableContextIds JSON corrupt:", + error, + ); // Corrupt localStorage, ignore. } @@ -71,7 +76,11 @@ function readContextSourceCreatedAt(pubkey: string): Map { result.set(key, value); } } - } catch { + } catch (error) { + console.debug( + "[ReadStateManager] storage: sourceCreatedAt JSON corrupt:", + error, + ); // Corrupt localStorage, ignore. } diff --git a/desktop/src/features/channels/readState/useReadState.ts b/desktop/src/features/channels/readState/useReadState.ts index 06c7d497d..540824b00 100644 --- a/desktop/src/features/channels/readState/useReadState.ts +++ b/desktop/src/features/channels/readState/useReadState.ts @@ -5,6 +5,8 @@ import type { RelayClient } from "@/shared/api/relayClientSession"; const noopGetTimestamp = () => null; const noopMarkRead = () => {}; const noopMarkUnread = () => {}; +const noopDrainRollbacks = (): ReadonlySet => new Set(); +const noopDrainAdvances = (): ReadonlySet => new Set(); /** * React hook that creates and manages a ReadStateManager instance. @@ -78,6 +80,14 @@ export function useReadState( [], ); + const drainSyncedRollbacks = React.useCallback((): ReadonlySet => { + return managerRef.current?.drainSyncedRollbacks() ?? new Set(); + }, []); + + const drainSyncedAdvances = React.useCallback((): ReadonlySet => { + return managerRef.current?.drainSyncedAdvances() ?? new Set(); + }, []); + const isReady = Boolean( pubkey && relayClient && initializedPubkey === pubkey, ); @@ -89,6 +99,8 @@ export function useReadState( markContextRead: noopMarkRead, markContextUnread: noopMarkUnread, seedContextRead: noopMarkRead, + drainSyncedRollbacks: noopDrainRollbacks, + drainSyncedAdvances: noopDrainAdvances, readStateVersion: 0, }; } @@ -99,6 +111,8 @@ export function useReadState( markContextRead, markContextUnread, seedContextRead, + drainSyncedRollbacks, + drainSyncedAdvances, readStateVersion, }; } diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index faa34080b..5de08de41 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -247,6 +247,8 @@ export function useUnreadChannels( isReady: isReadStateReady, markContextRead, markContextUnread, + drainSyncedRollbacks, + drainSyncedAdvances, readStateVersion, } = useReadState(pubkey, relayClient); @@ -272,6 +274,30 @@ export function useUnreadChannels( // against. Cleared when the user opens the channel. const forcedUnreadRef = React.useRef(new Set()); + // When a synced event rolls back a read marker (cross-device mark-as-unread), + // merge into forcedUnreadRef so the badge appears immediately without waiting + // for a catch-up REQ that already ran with the old (higher) marker. + // When a synced event advances a read marker (cross-device mark-as-read), + // remove from forcedUnreadRef so the dot clears immediately. + // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional drain trigger + React.useEffect(() => { + const rolled = drainSyncedRollbacks(); + const advanced = drainSyncedAdvances(); + let anyNew = false; + for (const channelId of rolled) { + if (!forcedUnreadRef.current.has(channelId)) { + forcedUnreadRef.current.add(channelId); + anyNew = true; + } + } + for (const channelId of advanced) { + if (forcedUnreadRef.current.delete(channelId)) { + anyNew = true; + } + } + if (anyNew) bumpLatestVersion(); + }, [readStateVersion, drainSyncedRollbacks, drainSyncedAdvances]); + // Root event IDs of threads where the current user has replied at least once. // Used to determine if thread replies should trigger unread notifications. const participatedRootIdsRef = React.useRef(new Set()); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f43843a8e..98df7988f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -526,6 +526,12 @@ declare global { models?: Array<{ id: string; name: string | null }>; denyReason?: string; }) => void; + __SPROUT_E2E_EMIT_MOCK_READ_STATE__?: (input: { + clientId: string; + contexts: Record; + createdAt: number; + slotId: string; + }) => unknown; } } @@ -5098,6 +5104,11 @@ function sendToMockSocket(args: { return; } + if (event.kind === 30078) { + sendWsText(socket.handler, ["OK", event.id, true, ""]); + return; + } + const channelId = getChannelIdFromTags(event.tags); if (!channelId) { sendWsText(socket.handler, [ @@ -5201,6 +5212,30 @@ export function maybeInstallE2eTauriMocks() { window.dispatchEvent(new CustomEvent("sprout:e2e-home-feed-updated")); return item; }; + window.__SPROUT_E2E_EMIT_MOCK_READ_STATE__ = ({ + clientId, + contexts, + createdAt, + slotId, + }) => { + const blob = JSON.stringify({ + v: 1, + client_id: clientId, + contexts, + }); + const event = createMockEvent( + 30078, + blob, + [ + ["d", `read-state:${slotId}`], + ["t", "read-state"], + ], + getMockMemberPubkey(config), + createdAt, + ); + emitMockLiveEvent(GLOBAL_MOCK_SUBSCRIPTION, event); + return event; + }; window.__SPROUT_E2E_SET_STALL_WEBSOCKET_SENDS__ = (stall) => { const config = getConfig(); if (!config?.mock) return; @@ -5652,6 +5687,10 @@ export function maybeInstallE2eTauriMocks() { (payload as { createdAt?: number }).createdAt, ), ); + case "nip44_encrypt_to_self": + return (payload as { plaintext: string }).plaintext; + case "nip44_decrypt_from_self": + return (payload as { ciphertext: string }).ciphertext; case "create_auth_event": if (identity) { return JSON.stringify( diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index eb879e807..f36504069 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -205,3 +205,132 @@ test("mark-as-unread via context menu shows dot badge", async ({ page }) => { await expect(page.getByTestId("channel-unread-random")).toBeVisible(); await waitForBadgeState(page, { state: "dot" }); }); + +test("synced mark-as-unread from another device shows dot, synced mark-as-read clears it", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Baseline: random has no unread dot + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + + // Wait for ReadStateManager's live subscription (kind:30078) to be + // established before injecting events. + await expect + .poll(async () => { + return page.evaluate(() => { + return ( + ( + window as Window & { + __SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind?: number; + }) => boolean; + } + ).__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 30078, + }) ?? false + ); + }); + }) + .toBe(true); + + const REMOTE_CLIENT_ID = "other-device-client-id"; + const REMOTE_SLOT_ID = "e2e00000000000000000000000000000"; + const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; + const now = Math.floor(Date.now() / 1000); + + // Step 1: seed a "read at now" state from the remote device so the + // local manager has a baseline value for this channel context. + await page.evaluate( + ({ clientId, slotId, channelId, ts }) => { + ( + window as Window & { + __SPROUT_E2E_EMIT_MOCK_READ_STATE__?: (input: { + clientId: string; + contexts: Record; + createdAt: number; + slotId: string; + }) => unknown; + } + ).__SPROUT_E2E_EMIT_MOCK_READ_STATE__?.({ + clientId, + slotId, + contexts: { [channelId]: ts }, + createdAt: ts, + }); + }, + { + clientId: REMOTE_CLIENT_ID, + slotId: REMOTE_SLOT_ID, + channelId: RANDOM_CHANNEL_ID, + ts: now, + }, + ); + + // Step 2: rollback — read timestamp drops (another device marks unread). + // createdAt must be strictly greater than step 1 to pass LWW gate. + await page.evaluate( + ({ clientId, slotId, channelId, ts, createdAt }) => { + ( + window as Window & { + __SPROUT_E2E_EMIT_MOCK_READ_STATE__?: (input: { + clientId: string; + contexts: Record; + createdAt: number; + slotId: string; + }) => unknown; + } + ).__SPROUT_E2E_EMIT_MOCK_READ_STATE__?.({ + clientId, + slotId, + contexts: { [channelId]: ts }, + createdAt, + }); + }, + { + clientId: REMOTE_CLIENT_ID, + slotId: REMOTE_SLOT_ID, + channelId: RANDOM_CHANNEL_ID, + ts: now - 100, + createdAt: now + 5, + }, + ); + + // The unread dot should appear. + await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + + // Step 3: advance — read timestamp moves forward (device marks read). + await page.evaluate( + ({ clientId, slotId, channelId, ts, createdAt }) => { + ( + window as Window & { + __SPROUT_E2E_EMIT_MOCK_READ_STATE__?: (input: { + clientId: string; + contexts: Record; + createdAt: number; + slotId: string; + }) => unknown; + } + ).__SPROUT_E2E_EMIT_MOCK_READ_STATE__?.({ + clientId, + slotId, + contexts: { [channelId]: ts }, + createdAt, + }); + }, + { + clientId: REMOTE_CLIENT_ID, + slotId: REMOTE_SLOT_ID, + channelId: RANDOM_CHANNEL_ID, + ts: now, + createdAt: now + 10, + }, + ); + + // The unread dot should disappear. + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); +}); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 12d422c78..43703a32d 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -36,6 +36,10 @@ enum _QuickAction { createChannel, createForum, newDm } const double _kBannerHeight = 24.0; bool _isUnread(Channel channel, ReadStateState readState) { + if (readState.syncedForcedChannelIds.contains(channel.id)) { + return true; + } + final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt); if (lastMessageAt == null) { return false; diff --git a/mobile/lib/features/channels/read_state/read_state_format.dart b/mobile/lib/features/channels/read_state/read_state_format.dart index a18ec5c8c..74e1dc0dc 100644 --- a/mobile/lib/features/channels/read_state/read_state_format.dart +++ b/mobile/lib/features/channels/read_state/read_state_format.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; + import '../../../shared/relay/nostr_models.dart'; const readStateDTagPrefix = 'read-state:'; @@ -141,12 +143,18 @@ DecodedReadStateEvent? decodeReadStateEvent( final String plaintext; try { plaintext = decrypt(event.content); - } catch (_) { + } catch (e) { + debugPrint( + '[ReadStateManager] decrypt failed for event ${event.id.substring(0, 8)}…: $e', + ); return null; } final blob = decodeReadStateBlob(plaintext); if (blob == null) { + debugPrint( + '[ReadStateManager] blob decode failed for event ${event.id.substring(0, 8)}…', + ); return null; } diff --git a/mobile/lib/features/channels/read_state/read_state_manager.dart b/mobile/lib/features/channels/read_state/read_state_manager.dart index f75cec0b7..b6cfa07cd 100644 --- a/mobile/lib/features/channels/read_state/read_state_manager.dart +++ b/mobile/lib/features/channels/read_state/read_state_manager.dart @@ -27,7 +27,8 @@ class ReadStateCrypto { return null; } return ReadStateCrypto._(getConversationKey(privkeyHex, pubkey)); - } catch (_) { + } catch (e) { + debugPrint('[ReadStateManager] crypto init failed: $e'); return null; } } @@ -64,6 +65,8 @@ class ReadStateManager { int _maxFetchedCreatedAt = 0; final Set _forcedContextIds = {}; final Map _contextSourceCreatedAt = {}; + final Set _pendingSyncedRollbacks = {}; + final Set _pendingSyncedAdvances = {}; ReadStateManager({ required this.pubkey, @@ -91,6 +94,9 @@ class ReadStateManager { Future initialize() async { if (_initialized || _disposed) return; _initialized = true; + debugPrint( + '[ReadStateManager] initialize pubkey=${pubkey.substring(0, 8)}… clientId=${_clientId.substring(0, 8)}… slotId=$_slotId', + ); if (!_remoteEnabled || _relaySession == null) { _onChanged(); @@ -104,6 +110,9 @@ class ReadStateManager { } _onChanged(); + debugPrint( + '[ReadStateManager] initialize complete maxFetchedCreatedAt=$_maxFetchedCreatedAt contexts=${_effectiveState.length}', + ); } void markContextRead(String contextId, int unixTimestamp) { @@ -138,7 +147,8 @@ class ReadStateManager { } Future reinitializeRemote() async { - if (_disposed || !_remoteEnabled) return; + if (_disposed || !_remoteEnabled || !_initialized) return; + debugPrint('[ReadStateManager] reinitializeRemote'); if (_isPublishing) { await _publishCompleter?.future; } @@ -218,8 +228,8 @@ class ReadStateManager { _mergeEvents(events); _persistLocalState(); _onChanged(); - } catch (_) { - // Local state remains usable when relay history is unavailable. + } catch (e) { + debugPrint('[ReadStateManager] fetchAndMerge failed: $e'); } } @@ -233,9 +243,7 @@ class ReadStateManager { pubkey: pubkey, decrypt: _crypto.decrypt, ); - if (decoded == null) { - continue; - } + if (decoded == null) continue; if (_isPlausibleCreatedAt(event.createdAt)) { _maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt); @@ -275,7 +283,7 @@ class ReadStateManager { Future _startLiveSubscription() async { try { - _unsubscribeLive = await _relaySession!.subscribe( + final unsub = await _relaySession!.subscribe( NostrFilter( kinds: const [EventKind.readState], authors: [pubkey], @@ -286,22 +294,29 @@ class ReadStateManager { ), _handleIncomingEvent, ); - } catch (_) { - // Non-fatal; history and local writes still work. + if (_disposed) { + unsub.call(); + return; + } + _unsubscribeLive = unsub; + debugPrint('[ReadStateManager] live subscription established'); + } catch (e) { + debugPrint('[ReadStateManager] live subscription FAILED: $e'); } } void _handleIncomingEvent(NostrEvent event) { if (_disposed) return; + debugPrint( + '[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.createdAt}', + ); final decoded = decodeReadStateEvent( event, pubkey: pubkey, decrypt: _crypto.decrypt, ); - if (decoded == null) { - return; - } + if (decoded == null) return; if (_isPlausibleCreatedAt(event.createdAt)) { _maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt); @@ -319,6 +334,16 @@ class ReadStateManager { final current = _effectiveState[entry.key] ?? 0; if (event.createdAt > sourceCreatedAt) { if (_effectiveState[entry.key] != entry.value) { + if (entry.value < current && current > 0) { + _pendingSyncedRollbacks.add(entry.key); + _pendingSyncedAdvances.remove(entry.key); + debugPrint( + '[ReadStateManager] synced rollback ctx=${entry.key.substring(0, min(12, entry.key.length))}… from=$current to=${entry.value}', + ); + } else if (entry.value > current) { + _pendingSyncedAdvances.add(entry.key); + _pendingSyncedRollbacks.remove(entry.key); + } _effectiveState[entry.key] = entry.value; changed = true; } @@ -331,6 +356,9 @@ class ReadStateManager { changed = true; } } + debugPrint( + '[ReadStateManager] incoming result changed=$changed clientId=${decoded.blob.clientId.substring(0, min(8, decoded.blob.clientId.length))}…', + ); if (decoded.blob.clientId == _clientId) { _lastPublishedContexts = Map.from(decoded.blob.contexts); @@ -369,6 +397,7 @@ class ReadStateManager { final completer = Completer(); _publishCompleter = completer; _isPublishing = true; + debugPrint('[ReadStateManager] publish starting slotId=$_slotId'); try { await _fetchOwnBlobBeforePublish(); @@ -390,12 +419,15 @@ class ReadStateManager { ], createdAt: createdAt, ); + debugPrint('[ReadStateManager] publish accepted createdAt=$createdAt'); - _lastPublishedContexts = contexts; - _forcedContextIds.clear(); for (final key in contexts.keys) { - _contextSourceCreatedAt[key] = createdAt; + if (_lastPublishedContexts[key] != contexts[key]) { + _contextSourceCreatedAt[key] = createdAt; + } } + _lastPublishedContexts = contexts; + _forcedContextIds.clear(); _maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt); _persistLocalState(); } catch (error) { @@ -438,8 +470,8 @@ class ReadStateManager { if (!_disposed) { _onChanged(); } - } catch (_) { - // Per NIP-RS, proceed with reachable data and merge later. + } catch (e) { + debugPrint('[ReadStateManager] fetchOwnBlobBeforePublish failed: $e'); } } @@ -455,6 +487,18 @@ class ReadStateManager { return true; } + Set drainSyncedRollbacks() { + final drained = Set.from(_pendingSyncedRollbacks); + _pendingSyncedRollbacks.clear(); + return drained; + } + + Set drainSyncedAdvances() { + final drained = Set.from(_pendingSyncedAdvances); + _pendingSyncedAdvances.clear(); + return drained; + } + Map _currentContexts() { final contexts = {}; for (final entry in _effectiveState.entries) { diff --git a/mobile/lib/features/channels/read_state/read_state_provider.dart b/mobile/lib/features/channels/read_state/read_state_provider.dart index f1aaf45b7..0f362c62c 100644 --- a/mobile/lib/features/channels/read_state/read_state_provider.dart +++ b/mobile/lib/features/channels/read_state/read_state_provider.dart @@ -13,19 +13,22 @@ class ReadStateState { final String? pubkey; final Map contexts; final int version; + final Set syncedForcedChannelIds; const ReadStateState({ required this.isReady, required this.pubkey, required this.contexts, required this.version, + this.syncedForcedChannelIds = const {}, }); const ReadStateState.inert() : isReady = false, pubkey = null, contexts = const {}, - version = 0; + version = 0, + syncedForcedChannelIds = const {}; int? effectiveTimestamp(String contextId) => contexts[contextId]; @@ -40,6 +43,7 @@ class ReadStateState { pubkey: pubkey, contexts: Map.unmodifiable({...contexts, contextId: timestamp}), version: version + 1, + syncedForcedChannelIds: syncedForcedChannelIds, ); } } @@ -47,12 +51,14 @@ class ReadStateState { class ReadStateNotifier extends Notifier { ReadStateManager? _manager; bool _isInitialized = false; + final Set _syncedForcedChannelIds = {}; @override ReadStateState build() { _manager?.dispose(flushPending: false); _manager = null; _isInitialized = false; + _syncedForcedChannelIds.clear(); final relayConfig = ref.watch(relayConfigProvider); ref.watch(relaySessionProvider); @@ -125,6 +131,7 @@ class ReadStateNotifier extends Notifier { } void markContextRead(String contextId, int unixTimestamp) { + _syncedForcedChannelIds.remove(contextId); _manager?.markContextRead(contextId, unixTimestamp); } @@ -138,6 +145,10 @@ class ReadStateNotifier extends Notifier { void _emitManagerState(ReadStateManager manager) { if (_manager != manager) return; + final rollbacks = manager.drainSyncedRollbacks(); + final advances = manager.drainSyncedAdvances(); + _syncedForcedChannelIds.addAll(rollbacks); + _syncedForcedChannelIds.removeAll(advances); state = _stateFromManager( manager, isReady: _isInitialized, @@ -155,6 +166,9 @@ class ReadStateNotifier extends Notifier { pubkey: manager.pubkey, contexts: manager.effectiveContexts, version: (previousVersion ?? 0) + 1, + syncedForcedChannelIds: Set.unmodifiable( + Set.from(_syncedForcedChannelIds), + ), ); } } @@ -174,7 +188,8 @@ String? _normalizePubkey(String? value) { String? _safeDerivedPubkey(SignedEventRelay relay) { try { return _normalizePubkey(relay.pubkey); - } catch (_) { + } catch (e) { + debugPrint('[ReadStateManager] pubkey derivation failed: $e'); return null; } } diff --git a/mobile/lib/features/channels/read_state/read_state_storage.dart b/mobile/lib/features/channels/read_state/read_state_storage.dart index 6541bad3c..1b388c32e 100644 --- a/mobile/lib/features/channels/read_state/read_state_storage.dart +++ b/mobile/lib/features/channels/read_state/read_state_storage.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; @@ -110,7 +111,8 @@ class ReadStateStorage { final Object? parsed; try { parsed = jsonDecode(raw); - } catch (_) { + } catch (e) { + debugPrint('[ReadStateManager] storage: contexts JSON corrupt: $e'); return {}; } @@ -141,7 +143,10 @@ class ReadStateStorage { final Object? parsed; try { parsed = jsonDecode(raw); - } catch (_) { + } catch (e) { + debugPrint( + '[ReadStateManager] storage: publishableContextIds JSON corrupt: $e', + ); return {}; } @@ -162,7 +167,10 @@ class ReadStateStorage { final Object? parsed; try { parsed = jsonDecode(raw); - } catch (_) { + } catch (e) { + debugPrint( + '[ReadStateManager] storage: sourceCreatedAt JSON corrupt: $e', + ); return {}; } diff --git a/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart index c57be87d4..5f4b3e42d 100644 --- a/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart +++ b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart @@ -33,11 +33,17 @@ final unreadBadgeProvider = Provider((ref) { for (final channel in channels) { if (!channel.isMember || channel.isArchived) continue; + final isSyncedForced = readState.syncedForcedChannelIds.contains( + channel.id, + ); final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt); - if (lastMessageAt == null) continue; + if (lastMessageAt == null && !isSyncedForced) continue; final readAt = readState.effectiveTimestamp(channel.id); - final isUnread = readAt == null || lastMessageAt > readAt; + final isUnread = + isSyncedForced || + readAt == null || + (lastMessageAt != null && lastMessageAt > readAt); if (!isUnread) continue; if (channel.isDm) {