From 5d6401535337b65066cacc4dc94ff805196fbbfa Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 3 Jun 2026 22:13:23 -0400 Subject: [PATCH 1/5] fix(notifications): add diagnostic logging to read state sync path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both platforms silently swallow all errors across 20+ catch sites in the read state manager, format decoder, and storage layers. Cross-device sync failures (decrypt, LWW merge, subscription, publish, storage corruption) produce zero log output, making debugging impossible. Add [ReadStateManager]-tagged logging at every silent drop point on both mobile (debugPrint) and desktop (console.debug) — initialization identity, subscription lifecycle, incoming event decrypt/decode, per- context LWW merge decisions, publish lifecycle, and storage hydration. --- .../channels/readState/readStateManager.ts | 55 ++++++++++++++++--- .../channels/readState/readStateStorage.ts | 15 ++++- .../read_state/read_state_format.dart | 10 +++- .../read_state/read_state_manager.dart | 38 ++++++++++--- .../read_state/read_state_provider.dart | 3 +- .../read_state/read_state_storage.dart | 14 ++++- 6 files changed, 112 insertions(+), 23 deletions(-) diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 6cb02870a..bf63fa162 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -76,6 +76,9 @@ export class ReadStateManager { async initialize(): Promise { if (this.initialized) return; + console.debug( + `[ReadStateManager] initialize pubkey=${this.pubkey.substring(0, 8)}… clientId=${this.clientId.substring(0, 8)}… slotId=${this.slotId}`, + ); this.hydrateFromLocalStorage(); @@ -86,6 +89,9 @@ export class ReadStateManager { } this.initialized = true; + console.debug( + `[ReadStateManager] initialize complete maxFetchedCreatedAt=${this.maxFetchedCreatedAt} contexts=${this.effectiveState.size}`, + ); this.notifyListeners(); } @@ -177,7 +183,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 +226,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 +271,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 } } @@ -287,13 +302,18 @@ export class ReadStateManager { }, ); 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; + 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 +340,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; } @@ -329,6 +353,9 @@ export class ReadStateManager { if (this.forcedContexts.has(ctx)) continue; const sourceCreatedAt = this.contextSourceCreatedAt.get(ctx) ?? 0; const current = this.effectiveState.get(ctx) ?? 0; + console.debug( + `[ReadStateManager] LWW ctx=${ctx.substring(0, 12)}… event.created_at=${event.created_at} sourceCreatedAt=${sourceCreatedAt} current=${current} incoming=${ts}`, + ); if (event.created_at > sourceCreatedAt) { if (this.effectiveState.get(ctx) !== ts) { this.effectiveState.set(ctx, ts); @@ -344,6 +371,9 @@ export class ReadStateManager { anyAdvanced = true; } } + console.debug( + `[ReadStateManager] incoming result anyAdvanced=${anyAdvanced} clientId=${blob.client_id.substring(0, 8)}…`, + ); if (anyAdvanced) { this.persistLocalState(); @@ -369,6 +399,7 @@ export class ReadStateManager { private async publish(): Promise { await this.fetchOwnBlobBeforePublish(); + console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`); // Build blob from contexts this client is allowed to publish. const contexts = this.currentContexts(); @@ -408,6 +439,9 @@ 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(); @@ -420,7 +454,7 @@ export class ReadStateManager { ); } catch (error) { // Non-fatal: will retry on next debounce - console.warn("[ReadStateManager] publish failed:", error); + console.debug("[ReadStateManager] publish failed:", error); } } @@ -435,7 +469,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. } } @@ -490,7 +528,8 @@ export class ReadStateManager { 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/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..ac08d5f1f 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; } } @@ -91,6 +92,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 +108,9 @@ class ReadStateManager { } _onChanged(); + debugPrint( + '[ReadStateManager] initialize complete maxFetchedCreatedAt=$_maxFetchedCreatedAt contexts=${_effectiveState.length}', + ); } void markContextRead(String contextId, int unixTimestamp) { @@ -139,6 +146,7 @@ class ReadStateManager { Future reinitializeRemote() async { if (_disposed || !_remoteEnabled) return; + debugPrint('[ReadStateManager] reinitializeRemote'); if (_isPublishing) { await _publishCompleter?.future; } @@ -218,8 +226,8 @@ class ReadStateManager { _mergeEvents(events); _persistLocalState(); _onChanged(); - } catch (_) { - // Local state remains usable when relay history is unavailable. + } catch (e) { + debugPrint('[ReadStateManager] fetchAndMerge failed: $e'); } } @@ -234,6 +242,9 @@ class ReadStateManager { decrypt: _crypto.decrypt, ); if (decoded == null) { + debugPrint( + '[ReadStateManager] mergeEvents skipped event=${event.id.substring(0, 8)}…', + ); continue; } @@ -286,13 +297,17 @@ class ReadStateManager { ), _handleIncomingEvent, ); - } catch (_) { - // Non-fatal; history and local writes still work. + 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, @@ -300,6 +315,7 @@ class ReadStateManager { decrypt: _crypto.decrypt, ); if (decoded == null) { + debugPrint('[ReadStateManager] incoming event decode returned null'); return; } @@ -317,6 +333,9 @@ class ReadStateManager { if (_forcedContextIds.contains(entry.key)) continue; final sourceCreatedAt = _contextSourceCreatedAt[entry.key] ?? 0; final current = _effectiveState[entry.key] ?? 0; + debugPrint( + '[ReadStateManager] LWW ctx=${entry.key.substring(0, min(12, entry.key.length))}… event.createdAt=${event.createdAt} sourceCreatedAt=$sourceCreatedAt current=$current incoming=${entry.value}', + ); if (event.createdAt > sourceCreatedAt) { if (_effectiveState[entry.key] != entry.value) { _effectiveState[entry.key] = entry.value; @@ -331,6 +350,9 @@ class ReadStateManager { changed = true; } } + debugPrint( + '[ReadStateManager] incoming result changed=$changed clientId=${decoded.blob.clientId.substring(0, 8)}…', + ); if (decoded.blob.clientId == _clientId) { _lastPublishedContexts = Map.from(decoded.blob.contexts); @@ -369,6 +391,7 @@ class ReadStateManager { final completer = Completer(); _publishCompleter = completer; _isPublishing = true; + debugPrint('[ReadStateManager] publish starting slotId=$_slotId'); try { await _fetchOwnBlobBeforePublish(); @@ -390,6 +413,7 @@ class ReadStateManager { ], createdAt: createdAt, ); + debugPrint('[ReadStateManager] publish accepted createdAt=$createdAt'); _lastPublishedContexts = contexts; _forcedContextIds.clear(); @@ -438,8 +462,8 @@ class ReadStateManager { if (!_disposed) { _onChanged(); } - } catch (_) { - // Per NIP-RS, proceed with reachable data and merge later. + } catch (e) { + debugPrint('[ReadStateManager] fetchOwnBlobBeforePublish failed: $e'); } } 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..b38fdcb26 100644 --- a/mobile/lib/features/channels/read_state/read_state_provider.dart +++ b/mobile/lib/features/channels/read_state/read_state_provider.dart @@ -174,7 +174,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 {}; } From b987c1a23e8dd468ec3a646ae630340ab8032185 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 3 Jun 2026 23:38:30 -0400 Subject: [PATCH 2/5] fix(notifications): address code review findings for read state logging Guard Dart clientId.substring with min() to prevent RangeError on short IDs from relay-delivered events. Restore console.warn for publish failures (was accidentally downgraded to debug). Move "publish starting" log before fetchOwnBlobBeforePublish so hung fetches are visible. Remove redundant outer logs where inner format-layer logs already capture the specific failure. --- .../channels/readState/readStateManager.ts | 4 ++-- .../channels/read_state/read_state_manager.dart | 14 +++----------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index bf63fa162..1dd600e51 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -398,8 +398,8 @@ export class ReadStateManager { } private async publish(): Promise { - await this.fetchOwnBlobBeforePublish(); console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`); + await this.fetchOwnBlobBeforePublish(); // Build blob from contexts this client is allowed to publish. const contexts = this.currentContexts(); @@ -454,7 +454,7 @@ export class ReadStateManager { ); } catch (error) { // Non-fatal: will retry on next debounce - console.debug("[ReadStateManager] publish failed:", error); + console.warn("[ReadStateManager] publish failed:", error); } } 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 ac08d5f1f..af98a9ef6 100644 --- a/mobile/lib/features/channels/read_state/read_state_manager.dart +++ b/mobile/lib/features/channels/read_state/read_state_manager.dart @@ -241,12 +241,7 @@ class ReadStateManager { pubkey: pubkey, decrypt: _crypto.decrypt, ); - if (decoded == null) { - debugPrint( - '[ReadStateManager] mergeEvents skipped event=${event.id.substring(0, 8)}…', - ); - continue; - } + if (decoded == null) continue; if (_isPlausibleCreatedAt(event.createdAt)) { _maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt); @@ -314,10 +309,7 @@ class ReadStateManager { pubkey: pubkey, decrypt: _crypto.decrypt, ); - if (decoded == null) { - debugPrint('[ReadStateManager] incoming event decode returned null'); - return; - } + if (decoded == null) return; if (_isPlausibleCreatedAt(event.createdAt)) { _maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt); @@ -351,7 +343,7 @@ class ReadStateManager { } } debugPrint( - '[ReadStateManager] incoming result changed=$changed clientId=${decoded.blob.clientId.substring(0, 8)}…', + '[ReadStateManager] incoming result changed=$changed clientId=${decoded.blob.clientId.substring(0, min(8, decoded.blob.clientId.length))}…', ); if (decoded.blob.clientId == _clientId) { From ded6ad6dca454ea8d6d639366d318f45b2508543 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 4 Jun 2026 16:46:28 -0400 Subject: [PATCH 3/5] fix(mobile+desktop): propagate synced mark-as-unread to UI layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-device mark-as-unread published via NIP-RS correctly but the receiving device's UI never showed the unread dot. Root cause: the unread computation requires both a synced read marker AND a session-local "latest message timestamp" to agree, but only the read marker syncs. On the receiving device, the catch-up REQ already ran with the old (higher) marker and found nothing, so latestByChannelRef/lastMessageAt was missing — the channel was silently skipped. Fix: when handleIncomingEvent detects a rollback (incoming ts < current), store the context ID and propagate it to the forced-unread path — the same mechanism local mark-as-unread already uses. Also trims the verbose per-context LWW loop logs that fire 85+ times per incoming event. --- .../channels/readState/readStateManager.ts | 16 +++++++++++++--- .../channels/readState/useReadState.ts | 7 +++++++ .../src/features/channels/useUnreadChannels.ts | 18 ++++++++++++++++++ .../lib/features/channels/channels_page.dart | 4 ++++ .../read_state/read_state_manager.dart | 16 +++++++++++++--- .../read_state/read_state_provider.dart | 14 +++++++++++++- .../unread_badge/unread_badge_provider.dart | 10 ++++++++-- 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 1dd600e51..09e5c8ed7 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -62,6 +62,7 @@ export class ReadStateManager { private maxFetchedCreatedAt = 0; private forcedContexts = new Set(); private contextSourceCreatedAt = new Map(); + private pendingSyncedRollbacks = new Set(); constructor(pubkey: string, relayClient: RelayClient) { this.pubkey = pubkey; @@ -353,11 +354,14 @@ export class ReadStateManager { if (this.forcedContexts.has(ctx)) continue; const sourceCreatedAt = this.contextSourceCreatedAt.get(ctx) ?? 0; const current = this.effectiveState.get(ctx) ?? 0; - console.debug( - `[ReadStateManager] LWW ctx=${ctx.substring(0, 12)}… event.created_at=${event.created_at} sourceCreatedAt=${sourceCreatedAt} current=${current} incoming=${ts}`, - ); if (event.created_at > sourceCreatedAt) { if (this.effectiveState.get(ctx) !== ts) { + if (ts < current && current > 0) { + this.pendingSyncedRollbacks.add(ctx); + console.debug( + `[ReadStateManager] synced rollback ctx=${ctx.substring(0, 12)}… from=${current} to=${ts}`, + ); + } this.effectiveState.set(ctx, ts); anyAdvanced = true; } @@ -524,6 +528,12 @@ export class ReadStateManager { ); } + drainSyncedRollbacks(): ReadonlySet { + const drained = this.pendingSyncedRollbacks; + this.pendingSyncedRollbacks = new Set(); + return drained; + } + private notifyListeners(): void { for (const listener of this.listeners) { try { diff --git a/desktop/src/features/channels/readState/useReadState.ts b/desktop/src/features/channels/readState/useReadState.ts index 06c7d497d..ff624c495 100644 --- a/desktop/src/features/channels/readState/useReadState.ts +++ b/desktop/src/features/channels/readState/useReadState.ts @@ -5,6 +5,7 @@ import type { RelayClient } from "@/shared/api/relayClientSession"; const noopGetTimestamp = () => null; const noopMarkRead = () => {}; const noopMarkUnread = () => {}; +const noopDrainRollbacks = (): ReadonlySet => new Set(); /** * React hook that creates and manages a ReadStateManager instance. @@ -78,6 +79,10 @@ export function useReadState( [], ); + const drainSyncedRollbacks = React.useCallback((): ReadonlySet => { + return managerRef.current?.drainSyncedRollbacks() ?? new Set(); + }, []); + const isReady = Boolean( pubkey && relayClient && initializedPubkey === pubkey, ); @@ -89,6 +94,7 @@ export function useReadState( markContextRead: noopMarkRead, markContextUnread: noopMarkUnread, seedContextRead: noopMarkRead, + drainSyncedRollbacks: noopDrainRollbacks, readStateVersion: 0, }; } @@ -99,6 +105,7 @@ export function useReadState( markContextRead, markContextUnread, seedContextRead, + drainSyncedRollbacks, readStateVersion, }; } diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index faa34080b..d53ec5256 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -247,6 +247,7 @@ export function useUnreadChannels( isReady: isReadStateReady, markContextRead, markContextUnread, + drainSyncedRollbacks, readStateVersion, } = useReadState(pubkey, relayClient); @@ -272,6 +273,23 @@ 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. + // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional drain trigger + React.useEffect(() => { + const rolled = drainSyncedRollbacks(); + if (rolled.size === 0) return; + let anyNew = false; + for (const channelId of rolled) { + if (!forcedUnreadRef.current.has(channelId)) { + forcedUnreadRef.current.add(channelId); + anyNew = true; + } + } + if (anyNew) bumpLatestVersion(); + }, [readStateVersion, drainSyncedRollbacks]); + // 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/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_manager.dart b/mobile/lib/features/channels/read_state/read_state_manager.dart index af98a9ef6..603ec36da 100644 --- a/mobile/lib/features/channels/read_state/read_state_manager.dart +++ b/mobile/lib/features/channels/read_state/read_state_manager.dart @@ -65,6 +65,7 @@ class ReadStateManager { int _maxFetchedCreatedAt = 0; final Set _forcedContextIds = {}; final Map _contextSourceCreatedAt = {}; + final Set _pendingSyncedRollbacks = {}; ReadStateManager({ required this.pubkey, @@ -325,11 +326,14 @@ class ReadStateManager { if (_forcedContextIds.contains(entry.key)) continue; final sourceCreatedAt = _contextSourceCreatedAt[entry.key] ?? 0; final current = _effectiveState[entry.key] ?? 0; - debugPrint( - '[ReadStateManager] LWW ctx=${entry.key.substring(0, min(12, entry.key.length))}… event.createdAt=${event.createdAt} sourceCreatedAt=$sourceCreatedAt current=$current incoming=${entry.value}', - ); if (event.createdAt > sourceCreatedAt) { if (_effectiveState[entry.key] != entry.value) { + if (entry.value < current && current > 0) { + _pendingSyncedRollbacks.add(entry.key); + debugPrint( + '[ReadStateManager] synced rollback ctx=${entry.key.substring(0, min(12, entry.key.length))}… from=$current to=${entry.value}', + ); + } _effectiveState[entry.key] = entry.value; changed = true; } @@ -471,6 +475,12 @@ class ReadStateManager { return true; } + Set drainSyncedRollbacks() { + final drained = Set.from(_pendingSyncedRollbacks); + _pendingSyncedRollbacks.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 b38fdcb26..0a9b32260 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,8 @@ class ReadStateNotifier extends Notifier { void _emitManagerState(ReadStateManager manager) { if (_manager != manager) return; + final rollbacks = manager.drainSyncedRollbacks(); + _syncedForcedChannelIds.addAll(rollbacks); state = _stateFromManager( manager, isReady: _isInitialized, @@ -155,6 +164,9 @@ class ReadStateNotifier extends Notifier { pubkey: manager.pubkey, contexts: manager.effectiveContexts, version: (previousVersion ?? 0) + 1, + syncedForcedChannelIds: Set.unmodifiable( + Set.from(_syncedForcedChannelIds), + ), ); } } 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) { From b540251649b683efca3d3ea1f92492da6c5425b9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 4 Jun 2026 17:12:03 -0400 Subject: [PATCH 4/5] fix(mobile+desktop): StrictMode orphaned subscription + LWW scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes of flaky cross-device read state sync: 1. React.StrictMode mount→unmount→remount creates two ReadStateManager instances with independent live subscriptions. The first manager's destroy() nulls unsubscribeLive before the async startLiveSubscription() resolves, leaving an orphaned subscription. Fix: add destroyed flag, check after every await in initialize() and startLiveSubscription(). 2. _publish() sets contextSourceCreatedAt for ALL 85 contexts to the publish timestamp, causing any incoming event with an older createdAt to fail the LWW check for every context — not just the ones that changed. Fix: only bump contextSourceCreatedAt for contexts whose value actually differs from lastPublishedContexts. Also guards reinitializeRemote() with !_initialized on mobile to prevent duplicate subscriptions when it races with initialize(). --- .../channels/readState/readStateManager.ts | 19 +++++++++++++++---- .../read_state/read_state_manager.dart | 17 ++++++++++++----- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 09e5c8ed7..fe08276a4 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -63,6 +63,7 @@ export class ReadStateManager { private forcedContexts = new Set(); private contextSourceCreatedAt = new Map(); private pendingSyncedRollbacks = new Set(); + private destroyed = false; constructor(pubkey: string, relayClient: RelayClient) { this.pubkey = pubkey; @@ -76,7 +77,7 @@ 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}`, ); @@ -84,7 +85,9 @@ export class ReadStateManager { this.hydrateFromLocalStorage(); await this.fetchAndMerge(); + if (this.destroyed) return; await this.startLiveSubscription(); + if (this.destroyed) return; if (!this.isIdenticalToLastPublished(this.currentContexts())) { this.schedulePublish(); } @@ -159,6 +162,7 @@ export class ReadStateManager { } destroy(): void { + this.destroyed = true; // Flush any pending writes immediately if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -302,6 +306,10 @@ export class ReadStateManager { void this.handleIncomingEvent(event); }, ); + if (this.destroyed) { + unsub(); + return; + } this.unsubscribeLive = unsub; console.debug("[ReadStateManager] live subscription established"); } catch (error) { @@ -312,6 +320,7 @@ export class ReadStateManager { 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}`, ); @@ -447,11 +456,13 @@ export class ReadStateManager { `[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, 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 603ec36da..8b9fb13e7 100644 --- a/mobile/lib/features/channels/read_state/read_state_manager.dart +++ b/mobile/lib/features/channels/read_state/read_state_manager.dart @@ -146,7 +146,7 @@ class ReadStateManager { } Future reinitializeRemote() async { - if (_disposed || !_remoteEnabled) return; + if (_disposed || !_remoteEnabled || !_initialized) return; debugPrint('[ReadStateManager] reinitializeRemote'); if (_isPublishing) { await _publishCompleter?.future; @@ -282,7 +282,7 @@ class ReadStateManager { Future _startLiveSubscription() async { try { - _unsubscribeLive = await _relaySession!.subscribe( + final unsub = await _relaySession!.subscribe( NostrFilter( kinds: const [EventKind.readState], authors: [pubkey], @@ -293,6 +293,11 @@ class ReadStateManager { ), _handleIncomingEvent, ); + if (_disposed) { + unsub.call(); + return; + } + _unsubscribeLive = unsub; debugPrint('[ReadStateManager] live subscription established'); } catch (e) { debugPrint('[ReadStateManager] live subscription FAILED: $e'); @@ -411,11 +416,13 @@ class ReadStateManager { ); 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) { From d7d33a17e1d3c73ab132e75cd3dab4e1ce1622c5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 4 Jun 2026 18:29:06 -0400 Subject: [PATCH 5/5] fix(mobile+desktop): synced mark-as-read clears forced-unread dot + E2E test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a synced rollback arrived (cross-device mark-as-unread), the UI correctly showed the unread dot via forcedUnreadRef/syncedForcedChannelIds. But a subsequent synced advance (mark-as-read) never cleared it — the dot stayed visible until the user opened the channel locally. Track synced advances alongside rollbacks in ReadStateManager. When LWW detects ts > current, add to pendingSyncedAdvances and remove from pendingSyncedRollbacks (mutual exclusion — last-wins within a React batch). The UI drain effect removes advanced channels from forcedUnreadRef. E2E infrastructure: mock NIP-44 encrypt/decrypt as passthrough in the test bridge, add __SPROUT_E2E_EMIT_MOCK_READ_STATE__ helper to inject kind:30078 events, and ACK kind:30078 EVENT publishes (no #h tag). New badge.spec.ts test exercises the full lifecycle: seed → rollback (dot appears) → advance (dot disappears). --- .../channels/readState/readStateManager.ts | 11 ++ .../channels/readState/useReadState.ts | 7 + .../features/channels/useUnreadChannels.ts | 12 +- desktop/src/testing/e2eBridge.ts | 39 ++++++ desktop/tests/e2e/badge.spec.ts | 129 ++++++++++++++++++ .../read_state/read_state_manager.dart | 11 ++ .../read_state/read_state_provider.dart | 2 + 7 files changed, 209 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index fe08276a4..5e8484863 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -63,6 +63,7 @@ export class ReadStateManager { private forcedContexts = new Set(); private contextSourceCreatedAt = new Map(); private pendingSyncedRollbacks = new Set(); + private pendingSyncedAdvances = new Set(); private destroyed = false; constructor(pubkey: string, relayClient: RelayClient) { @@ -367,9 +368,13 @@ export class ReadStateManager { 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; @@ -545,6 +550,12 @@ export class ReadStateManager { return drained; } + drainSyncedAdvances(): ReadonlySet { + const drained = this.pendingSyncedAdvances; + this.pendingSyncedAdvances = new Set(); + return drained; + } + private notifyListeners(): void { for (const listener of this.listeners) { try { diff --git a/desktop/src/features/channels/readState/useReadState.ts b/desktop/src/features/channels/readState/useReadState.ts index ff624c495..540824b00 100644 --- a/desktop/src/features/channels/readState/useReadState.ts +++ b/desktop/src/features/channels/readState/useReadState.ts @@ -6,6 +6,7 @@ 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. @@ -83,6 +84,10 @@ export function useReadState( return managerRef.current?.drainSyncedRollbacks() ?? new Set(); }, []); + const drainSyncedAdvances = React.useCallback((): ReadonlySet => { + return managerRef.current?.drainSyncedAdvances() ?? new Set(); + }, []); + const isReady = Boolean( pubkey && relayClient && initializedPubkey === pubkey, ); @@ -95,6 +100,7 @@ export function useReadState( markContextUnread: noopMarkUnread, seedContextRead: noopMarkRead, drainSyncedRollbacks: noopDrainRollbacks, + drainSyncedAdvances: noopDrainAdvances, readStateVersion: 0, }; } @@ -106,6 +112,7 @@ export function useReadState( markContextUnread, seedContextRead, drainSyncedRollbacks, + drainSyncedAdvances, readStateVersion, }; } diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index d53ec5256..5de08de41 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -248,6 +248,7 @@ export function useUnreadChannels( markContextRead, markContextUnread, drainSyncedRollbacks, + drainSyncedAdvances, readStateVersion, } = useReadState(pubkey, relayClient); @@ -276,10 +277,12 @@ export function useUnreadChannels( // 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(); - if (rolled.size === 0) return; + const advanced = drainSyncedAdvances(); let anyNew = false; for (const channelId of rolled) { if (!forcedUnreadRef.current.has(channelId)) { @@ -287,8 +290,13 @@ export function useUnreadChannels( anyNew = true; } } + for (const channelId of advanced) { + if (forcedUnreadRef.current.delete(channelId)) { + anyNew = true; + } + } if (anyNew) bumpLatestVersion(); - }, [readStateVersion, drainSyncedRollbacks]); + }, [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. 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/read_state/read_state_manager.dart b/mobile/lib/features/channels/read_state/read_state_manager.dart index 8b9fb13e7..b6cfa07cd 100644 --- a/mobile/lib/features/channels/read_state/read_state_manager.dart +++ b/mobile/lib/features/channels/read_state/read_state_manager.dart @@ -66,6 +66,7 @@ class ReadStateManager { final Set _forcedContextIds = {}; final Map _contextSourceCreatedAt = {}; final Set _pendingSyncedRollbacks = {}; + final Set _pendingSyncedAdvances = {}; ReadStateManager({ required this.pubkey, @@ -335,9 +336,13 @@ class ReadStateManager { 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; @@ -488,6 +493,12 @@ class ReadStateManager { 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 0a9b32260..0f362c62c 100644 --- a/mobile/lib/features/channels/read_state/read_state_provider.dart +++ b/mobile/lib/features/channels/read_state/read_state_provider.dart @@ -146,7 +146,9 @@ 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,