From 5d6401535337b65066cacc4dc94ff805196fbbfa Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
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 6cb02870af..bf63fa1621 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<void> {
     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<void> {
     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<void> {
     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 497f10c359..056b0226e8 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<string> {
         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<string, number> {
         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 a18ec5c8c4..74e1dc0dc4 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 f75cec0b74..ac08d5f1f5 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<void> 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<void> 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<String, int>.from(decoded.blob.contexts);
@@ -369,6 +391,7 @@ class ReadStateManager {
     final completer = Completer<void>();
     _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 f1aaf45b73..b38fdcb26f 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 6541bad3c8..1b388c32ee 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 <wpfleger@block.xyz>
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 bf63fa1621..1dd600e51c 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<void> {
-    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 ac08d5f1f5..af98a9ef6b 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 <wpfleger@block.xyz>
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 1dd600e51c..09e5c8ed73 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<string>();
   private contextSourceCreatedAt = new Map<string, number>();
+  private pendingSyncedRollbacks = new Set<string>();
 
   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<string> {
+    const drained = this.pendingSyncedRollbacks;
+    this.pendingSyncedRollbacks = new Set<string>();
+    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 06c7d497df..ff624c4955 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<string> => new Set<string>();
 
 /**
  * React hook that creates and manages a ReadStateManager instance.
@@ -78,6 +79,10 @@ export function useReadState(
     [],
   );
 
+  const drainSyncedRollbacks = React.useCallback((): ReadonlySet<string> => {
+    return managerRef.current?.drainSyncedRollbacks() ?? new Set<string>();
+  }, []);
+
   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 faa34080b8..d53ec52568 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<string>());
 
+  // 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<string>());
diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart
index 12d422c78a..43703a32db 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 af98a9ef6b..603ec36dae 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<String> _forcedContextIds = {};
   final Map<String, int> _contextSourceCreatedAt = {};
+  final Set<String> _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<String> drainSyncedRollbacks() {
+    final drained = Set<String>.from(_pendingSyncedRollbacks);
+    _pendingSyncedRollbacks.clear();
+    return drained;
+  }
+
   Map<String, int> _currentContexts() {
     final contexts = <String, int>{};
     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 b38fdcb26f..0a9b322601 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<String, int> contexts;
   final int version;
+  final Set<String> 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<ReadStateState> {
   ReadStateManager? _manager;
   bool _isInitialized = false;
+  final Set<String> _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<ReadStateState> {
   }
 
   void markContextRead(String contextId, int unixTimestamp) {
+    _syncedForcedChannelIds.remove(contextId);
     _manager?.markContextRead(contextId, unixTimestamp);
   }
 
@@ -138,6 +145,8 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
 
   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<ReadStateState> {
       pubkey: manager.pubkey,
       contexts: manager.effectiveContexts,
       version: (previousVersion ?? 0) + 1,
+      syncedForcedChannelIds: Set.unmodifiable(
+        Set<String>.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 c57be87d4c..5f4b3e42d6 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<UnreadBadgeState>((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 <wpfleger@block.xyz>
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 09e5c8ed73..fe08276a42 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<string>();
   private contextSourceCreatedAt = new Map<string, number>();
   private pendingSyncedRollbacks = new Set<string>();
+  private destroyed = false;
 
   constructor(pubkey: string, relayClient: RelayClient) {
     this.pubkey = pubkey;
@@ -76,7 +77,7 @@ export class ReadStateManager {
   }
 
   async initialize(): Promise<void> {
-    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<void> {
     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 603ec36dae..8b9fb13e75 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<void> 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<void> _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 <wpfleger@block.xyz>
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 fe08276a42..5e84848636 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<string>();
   private contextSourceCreatedAt = new Map<string, number>();
   private pendingSyncedRollbacks = new Set<string>();
+  private pendingSyncedAdvances = new Set<string>();
   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<string> {
+    const drained = this.pendingSyncedAdvances;
+    this.pendingSyncedAdvances = new Set<string>();
+    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 ff624c4955..540824b00f 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<string> => new Set<string>();
+const noopDrainAdvances = (): ReadonlySet<string> => new Set<string>();
 
 /**
  * React hook that creates and manages a ReadStateManager instance.
@@ -83,6 +84,10 @@ export function useReadState(
     return managerRef.current?.drainSyncedRollbacks() ?? new Set<string>();
   }, []);
 
+  const drainSyncedAdvances = React.useCallback((): ReadonlySet<string> => {
+    return managerRef.current?.drainSyncedAdvances() ?? new Set<string>();
+  }, []);
+
   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 d53ec52568..5de08de417 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 f43843a8ea..98df7988f7 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<string, number>;
+      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 eb879e8079..f365040698 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<string, number>;
+            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<string, number>;
+            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<string, number>;
+            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 8b9fb13e75..b6cfa07cdd 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<String> _forcedContextIds = {};
   final Map<String, int> _contextSourceCreatedAt = {};
   final Set<String> _pendingSyncedRollbacks = {};
+  final Set<String> _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<String> drainSyncedAdvances() {
+    final drained = Set<String>.from(_pendingSyncedAdvances);
+    _pendingSyncedAdvances.clear();
+    return drained;
+  }
+
   Map<String, int> _currentContexts() {
     final contexts = <String, int>{};
     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 0a9b322601..0f362c62c8 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<ReadStateState> {
   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,
