fix(mobile+desktop): cross-device read state sync + diagnostic logging (#843)

This commit is contained in:
Will Pfleger
2026-06-04 18:55:40 -04:00
committed by GitHub
parent 3ddfe5fcc2
commit 269b35e8de
12 changed files with 413 additions and 40 deletions

View File

@@ -62,6 +62,9 @@ export class ReadStateManager {
private maxFetchedCreatedAt = 0; private maxFetchedCreatedAt = 0;
private forcedContexts = new Set<string>(); private forcedContexts = new Set<string>();
private contextSourceCreatedAt = new Map<string, number>(); private contextSourceCreatedAt = new Map<string, number>();
private pendingSyncedRollbacks = new Set<string>();
private pendingSyncedAdvances = new Set<string>();
private destroyed = false;
constructor(pubkey: string, relayClient: RelayClient) { constructor(pubkey: string, relayClient: RelayClient) {
this.pubkey = pubkey; this.pubkey = pubkey;
@@ -75,17 +78,25 @@ export class ReadStateManager {
} }
async initialize(): Promise<void> { 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}`,
);
this.hydrateFromLocalStorage(); this.hydrateFromLocalStorage();
await this.fetchAndMerge(); await this.fetchAndMerge();
if (this.destroyed) return;
await this.startLiveSubscription(); await this.startLiveSubscription();
if (this.destroyed) return;
if (!this.isIdenticalToLastPublished(this.currentContexts())) { if (!this.isIdenticalToLastPublished(this.currentContexts())) {
this.schedulePublish(); this.schedulePublish();
} }
this.initialized = true; this.initialized = true;
console.debug(
`[ReadStateManager] initialize complete maxFetchedCreatedAt=${this.maxFetchedCreatedAt} contexts=${this.effectiveState.size}`,
);
this.notifyListeners(); this.notifyListeners();
} }
@@ -152,6 +163,7 @@ export class ReadStateManager {
} }
destroy(): void { destroy(): void {
this.destroyed = true;
// Flush any pending writes immediately // Flush any pending writes immediately
if (this.debounceTimer !== null) { if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer); window.clearTimeout(this.debounceTimer);
@@ -177,7 +189,8 @@ export class ReadStateManager {
since: Math.floor(Date.now() / 1_000) - READ_STATE_HORIZON_SECONDS, since: Math.floor(Date.now() / 1_000) - READ_STATE_HORIZON_SECONDS,
limit: READ_STATE_FETCH_LIMIT, limit: READ_STATE_FETCH_LIMIT,
}); });
} catch { } catch (error) {
console.debug("[ReadStateManager] fetchAndMerge failed:", error);
// If fetch fails, proceed with local state only // If fetch fails, proceed with local state only
return; return;
} }
@@ -219,7 +232,11 @@ export class ReadStateManager {
client_id: parsed.client_id, client_id: parsed.client_id,
contexts: sanitizeContexts(parsed.contexts), contexts: sanitizeContexts(parsed.contexts),
}; };
} catch { } catch (error) {
console.debug(
`[ReadStateManager] mergeEvents decrypt failed event=${event.id.substring(0, 8)}…:`,
error,
);
continue; continue;
} }
@@ -260,7 +277,11 @@ export class ReadStateManager {
localStorage.setItem(slotIdKey(this.pubkey), this.slotId); localStorage.setItem(slotIdKey(this.pubkey), this.slotId);
break; break;
} }
} catch { } catch (error) {
console.debug(
`[ReadStateManager] conflict check decrypt failed event=${event.id.substring(0, 8)}…:`,
error,
);
// Decrypt failure — skip this event // Decrypt failure — skip this event
} }
} }
@@ -286,14 +307,24 @@ export class ReadStateManager {
void this.handleIncomingEvent(event); void this.handleIncomingEvent(event);
}, },
); );
if (this.destroyed) {
unsub();
return;
}
this.unsubscribeLive = unsub; 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 // Non-fatal: we can still work with local state
} }
} }
private async handleIncomingEvent(event: RelayEvent): Promise<void> { private async handleIncomingEvent(event: RelayEvent): Promise<void> {
if (event.pubkey !== this.pubkey) return; if (event.pubkey !== this.pubkey) return;
if (this.destroyed) return;
console.debug(
`[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`,
);
const dTags = event.tags.filter((t) => t[0] === "d"); const dTags = event.tags.filter((t) => t[0] === "d");
if (dTags.length !== 1) return; if (dTags.length !== 1) return;
@@ -320,7 +351,11 @@ export class ReadStateManager {
client_id: parsed.client_id, client_id: parsed.client_id,
contexts: sanitizeContexts(parsed.contexts), contexts: sanitizeContexts(parsed.contexts),
}; };
} catch { } catch (error) {
console.debug(
`[ReadStateManager] incoming event decrypt/parse failed event=${event.id.substring(0, 8)}…:`,
error,
);
return; return;
} }
@@ -331,6 +366,16 @@ export class ReadStateManager {
const current = this.effectiveState.get(ctx) ?? 0; const current = this.effectiveState.get(ctx) ?? 0;
if (event.created_at > sourceCreatedAt) { if (event.created_at > sourceCreatedAt) {
if (this.effectiveState.get(ctx) !== ts) { 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); this.effectiveState.set(ctx, ts);
anyAdvanced = true; anyAdvanced = true;
} }
@@ -344,6 +389,9 @@ export class ReadStateManager {
anyAdvanced = true; anyAdvanced = true;
} }
} }
console.debug(
`[ReadStateManager] incoming result anyAdvanced=${anyAdvanced} clientId=${blob.client_id.substring(0, 8)}…`,
);
if (anyAdvanced) { if (anyAdvanced) {
this.persistLocalState(); this.persistLocalState();
@@ -368,6 +416,7 @@ export class ReadStateManager {
} }
private async publish(): Promise<void> { private async publish(): Promise<void> {
console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`);
await this.fetchOwnBlobBeforePublish(); await this.fetchOwnBlobBeforePublish();
// Build blob from contexts this client is allowed to publish. // Build blob from contexts this client is allowed to publish.
@@ -408,12 +457,17 @@ export class ReadStateManager {
"Timed out publishing read state.", "Timed out publishing read state.",
"Failed to publish read state.", "Failed to publish read state.",
); );
console.debug(
`[ReadStateManager] publish accepted createdAt=${createdAt}`,
);
for (const key of Object.keys(contexts)) {
if (this.lastPublishedContexts[key] !== contexts[key]) {
this.contextSourceCreatedAt.set(key, createdAt);
}
}
this.lastPublishedContexts = contexts; this.lastPublishedContexts = contexts;
this.forcedContexts.clear(); this.forcedContexts.clear();
for (const key of Object.keys(contexts)) {
this.contextSourceCreatedAt.set(key, createdAt);
}
this.maxFetchedCreatedAt = Math.max( this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt, this.maxFetchedCreatedAt,
event.created_at, event.created_at,
@@ -435,7 +489,11 @@ export class ReadStateManager {
await this.mergeEvents(events); await this.mergeEvents(events);
this.persistLocalState(); this.persistLocalState();
} catch { } catch (error) {
console.debug(
"[ReadStateManager] fetchOwnBlobBeforePublish failed:",
error,
);
// Per NIP-RS, proceed with reachable data and merge on a later fetch. // Per NIP-RS, proceed with reachable data and merge on a later fetch.
} }
} }
@@ -486,11 +544,24 @@ export class ReadStateManager {
); );
} }
drainSyncedRollbacks(): ReadonlySet<string> {
const drained = this.pendingSyncedRollbacks;
this.pendingSyncedRollbacks = new Set<string>();
return drained;
}
drainSyncedAdvances(): ReadonlySet<string> {
const drained = this.pendingSyncedAdvances;
this.pendingSyncedAdvances = new Set<string>();
return drained;
}
private notifyListeners(): void { private notifyListeners(): void {
for (const listener of this.listeners) { for (const listener of this.listeners) {
try { try {
listener(); listener();
} catch { } catch (error) {
console.debug("[ReadStateManager] listener threw:", error);
// Don't let a broken listener break the manager // Don't let a broken listener break the manager
} }
} }

View File

@@ -31,7 +31,8 @@ function mergeLocalStorageKey(
contexts.set(channelId, unixSeconds); contexts.set(channelId, unixSeconds);
} }
} }
} catch { } catch (error) {
console.debug("[ReadStateManager] storage: contexts JSON corrupt:", error);
// Corrupt localStorage, ignore. // Corrupt localStorage, ignore.
} }
} }
@@ -50,7 +51,11 @@ function readPublishableContextIds(pubkey: string): Set<string> {
result.add(value); result.add(value);
} }
} }
} catch { } catch (error) {
console.debug(
"[ReadStateManager] storage: publishableContextIds JSON corrupt:",
error,
);
// Corrupt localStorage, ignore. // Corrupt localStorage, ignore.
} }
@@ -71,7 +76,11 @@ function readContextSourceCreatedAt(pubkey: string): Map<string, number> {
result.set(key, value); result.set(key, value);
} }
} }
} catch { } catch (error) {
console.debug(
"[ReadStateManager] storage: sourceCreatedAt JSON corrupt:",
error,
);
// Corrupt localStorage, ignore. // Corrupt localStorage, ignore.
} }

View File

@@ -5,6 +5,8 @@ import type { RelayClient } from "@/shared/api/relayClientSession";
const noopGetTimestamp = () => null; const noopGetTimestamp = () => null;
const noopMarkRead = () => {}; const noopMarkRead = () => {};
const noopMarkUnread = () => {}; 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. * React hook that creates and manages a ReadStateManager instance.
@@ -78,6 +80,14 @@ export function useReadState(
[], [],
); );
const drainSyncedRollbacks = React.useCallback((): ReadonlySet<string> => {
return managerRef.current?.drainSyncedRollbacks() ?? new Set<string>();
}, []);
const drainSyncedAdvances = React.useCallback((): ReadonlySet<string> => {
return managerRef.current?.drainSyncedAdvances() ?? new Set<string>();
}, []);
const isReady = Boolean( const isReady = Boolean(
pubkey && relayClient && initializedPubkey === pubkey, pubkey && relayClient && initializedPubkey === pubkey,
); );
@@ -89,6 +99,8 @@ export function useReadState(
markContextRead: noopMarkRead, markContextRead: noopMarkRead,
markContextUnread: noopMarkUnread, markContextUnread: noopMarkUnread,
seedContextRead: noopMarkRead, seedContextRead: noopMarkRead,
drainSyncedRollbacks: noopDrainRollbacks,
drainSyncedAdvances: noopDrainAdvances,
readStateVersion: 0, readStateVersion: 0,
}; };
} }
@@ -99,6 +111,8 @@ export function useReadState(
markContextRead, markContextRead,
markContextUnread, markContextUnread,
seedContextRead, seedContextRead,
drainSyncedRollbacks,
drainSyncedAdvances,
readStateVersion, readStateVersion,
}; };
} }

View File

@@ -247,6 +247,8 @@ export function useUnreadChannels(
isReady: isReadStateReady, isReady: isReadStateReady,
markContextRead, markContextRead,
markContextUnread, markContextUnread,
drainSyncedRollbacks,
drainSyncedAdvances,
readStateVersion, readStateVersion,
} = useReadState(pubkey, relayClient); } = useReadState(pubkey, relayClient);
@@ -272,6 +274,30 @@ export function useUnreadChannels(
// against. Cleared when the user opens the channel. // against. Cleared when the user opens the channel.
const forcedUnreadRef = React.useRef(new Set<string>()); 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.
// When a synced event advances a read marker (cross-device mark-as-read),
// remove from forcedUnreadRef so the dot clears immediately.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional drain trigger
React.useEffect(() => {
const rolled = drainSyncedRollbacks();
const advanced = drainSyncedAdvances();
let anyNew = false;
for (const channelId of rolled) {
if (!forcedUnreadRef.current.has(channelId)) {
forcedUnreadRef.current.add(channelId);
anyNew = true;
}
}
for (const channelId of advanced) {
if (forcedUnreadRef.current.delete(channelId)) {
anyNew = true;
}
}
if (anyNew) bumpLatestVersion();
}, [readStateVersion, drainSyncedRollbacks, drainSyncedAdvances]);
// Root event IDs of threads where the current user has replied at least once. // Root event IDs of threads where the current user has replied at least once.
// Used to determine if thread replies should trigger unread notifications. // Used to determine if thread replies should trigger unread notifications.
const participatedRootIdsRef = React.useRef(new Set<string>()); const participatedRootIdsRef = React.useRef(new Set<string>());

View File

@@ -526,6 +526,12 @@ declare global {
models?: Array<{ id: string; name: string | null }>; models?: Array<{ id: string; name: string | null }>;
denyReason?: string; denyReason?: string;
}) => void; }) => 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; return;
} }
if (event.kind === 30078) {
sendWsText(socket.handler, ["OK", event.id, true, ""]);
return;
}
const channelId = getChannelIdFromTags(event.tags); const channelId = getChannelIdFromTags(event.tags);
if (!channelId) { if (!channelId) {
sendWsText(socket.handler, [ sendWsText(socket.handler, [
@@ -5201,6 +5212,30 @@ export function maybeInstallE2eTauriMocks() {
window.dispatchEvent(new CustomEvent("sprout:e2e-home-feed-updated")); window.dispatchEvent(new CustomEvent("sprout:e2e-home-feed-updated"));
return item; 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) => { window.__SPROUT_E2E_SET_STALL_WEBSOCKET_SENDS__ = (stall) => {
const config = getConfig(); const config = getConfig();
if (!config?.mock) return; if (!config?.mock) return;
@@ -5652,6 +5687,10 @@ export function maybeInstallE2eTauriMocks() {
(payload as { createdAt?: number }).createdAt, (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": case "create_auth_event":
if (identity) { if (identity) {
return JSON.stringify( return JSON.stringify(

View File

@@ -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 expect(page.getByTestId("channel-unread-random")).toBeVisible();
await waitForBadgeState(page, { state: "dot" }); 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);
});

View File

@@ -37,6 +37,10 @@ enum _QuickAction { createChannel, createForum, newDm }
const double _kBannerHeight = 24.0; const double _kBannerHeight = 24.0;
bool _isUnread(Channel channel, ReadStateState readState) { bool _isUnread(Channel channel, ReadStateState readState) {
if (readState.syncedForcedChannelIds.contains(channel.id)) {
return true;
}
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt); final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt == null) { if (lastMessageAt == null) {
return false; return false;

View File

@@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../../shared/relay/nostr_models.dart'; import '../../../shared/relay/nostr_models.dart';
const readStateDTagPrefix = 'read-state:'; const readStateDTagPrefix = 'read-state:';
@@ -141,12 +143,18 @@ DecodedReadStateEvent? decodeReadStateEvent(
final String plaintext; final String plaintext;
try { try {
plaintext = decrypt(event.content); plaintext = decrypt(event.content);
} catch (_) { } catch (e) {
debugPrint(
'[ReadStateManager] decrypt failed for event ${event.id.substring(0, 8)}…: $e',
);
return null; return null;
} }
final blob = decodeReadStateBlob(plaintext); final blob = decodeReadStateBlob(plaintext);
if (blob == null) { if (blob == null) {
debugPrint(
'[ReadStateManager] blob decode failed for event ${event.id.substring(0, 8)}…',
);
return null; return null;
} }

View File

@@ -27,7 +27,8 @@ class ReadStateCrypto {
return null; return null;
} }
return ReadStateCrypto._(getConversationKey(privkeyHex, pubkey)); return ReadStateCrypto._(getConversationKey(privkeyHex, pubkey));
} catch (_) { } catch (e) {
debugPrint('[ReadStateManager] crypto init failed: $e');
return null; return null;
} }
} }
@@ -64,6 +65,8 @@ class ReadStateManager {
int _maxFetchedCreatedAt = 0; int _maxFetchedCreatedAt = 0;
final Set<String> _forcedContextIds = {}; final Set<String> _forcedContextIds = {};
final Map<String, int> _contextSourceCreatedAt = {}; final Map<String, int> _contextSourceCreatedAt = {};
final Set<String> _pendingSyncedRollbacks = {};
final Set<String> _pendingSyncedAdvances = {};
ReadStateManager({ ReadStateManager({
required this.pubkey, required this.pubkey,
@@ -91,6 +94,9 @@ class ReadStateManager {
Future<void> initialize() async { Future<void> initialize() async {
if (_initialized || _disposed) return; if (_initialized || _disposed) return;
_initialized = true; _initialized = true;
debugPrint(
'[ReadStateManager] initialize pubkey=${pubkey.substring(0, 8)}… clientId=${_clientId.substring(0, 8)}… slotId=$_slotId',
);
if (!_remoteEnabled || _relaySession == null) { if (!_remoteEnabled || _relaySession == null) {
_onChanged(); _onChanged();
@@ -104,6 +110,9 @@ class ReadStateManager {
} }
_onChanged(); _onChanged();
debugPrint(
'[ReadStateManager] initialize complete maxFetchedCreatedAt=$_maxFetchedCreatedAt contexts=${_effectiveState.length}',
);
} }
void markContextRead(String contextId, int unixTimestamp) { void markContextRead(String contextId, int unixTimestamp) {
@@ -138,7 +147,8 @@ class ReadStateManager {
} }
Future<void> reinitializeRemote() async { Future<void> reinitializeRemote() async {
if (_disposed || !_remoteEnabled) return; if (_disposed || !_remoteEnabled || !_initialized) return;
debugPrint('[ReadStateManager] reinitializeRemote');
if (_isPublishing) { if (_isPublishing) {
await _publishCompleter?.future; await _publishCompleter?.future;
} }
@@ -218,8 +228,8 @@ class ReadStateManager {
_mergeEvents(events); _mergeEvents(events);
_persistLocalState(); _persistLocalState();
_onChanged(); _onChanged();
} catch (_) { } catch (e) {
// Local state remains usable when relay history is unavailable. debugPrint('[ReadStateManager] fetchAndMerge failed: $e');
} }
} }
@@ -233,9 +243,7 @@ class ReadStateManager {
pubkey: pubkey, pubkey: pubkey,
decrypt: _crypto.decrypt, decrypt: _crypto.decrypt,
); );
if (decoded == null) { if (decoded == null) continue;
continue;
}
if (_isPlausibleCreatedAt(event.createdAt)) { if (_isPlausibleCreatedAt(event.createdAt)) {
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt); _maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
@@ -275,7 +283,7 @@ class ReadStateManager {
Future<void> _startLiveSubscription() async { Future<void> _startLiveSubscription() async {
try { try {
_unsubscribeLive = await _relaySession!.subscribe( final unsub = await _relaySession!.subscribe(
NostrFilter( NostrFilter(
kinds: const [EventKind.readState], kinds: const [EventKind.readState],
authors: [pubkey], authors: [pubkey],
@@ -286,22 +294,29 @@ class ReadStateManager {
), ),
_handleIncomingEvent, _handleIncomingEvent,
); );
} catch (_) { if (_disposed) {
// Non-fatal; history and local writes still work. unsub.call();
return;
}
_unsubscribeLive = unsub;
debugPrint('[ReadStateManager] live subscription established');
} catch (e) {
debugPrint('[ReadStateManager] live subscription FAILED: $e');
} }
} }
void _handleIncomingEvent(NostrEvent event) { void _handleIncomingEvent(NostrEvent event) {
if (_disposed) return; if (_disposed) return;
debugPrint(
'[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.createdAt}',
);
final decoded = decodeReadStateEvent( final decoded = decodeReadStateEvent(
event, event,
pubkey: pubkey, pubkey: pubkey,
decrypt: _crypto.decrypt, decrypt: _crypto.decrypt,
); );
if (decoded == null) { if (decoded == null) return;
return;
}
if (_isPlausibleCreatedAt(event.createdAt)) { if (_isPlausibleCreatedAt(event.createdAt)) {
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt); _maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
@@ -319,6 +334,16 @@ class ReadStateManager {
final current = _effectiveState[entry.key] ?? 0; final current = _effectiveState[entry.key] ?? 0;
if (event.createdAt > sourceCreatedAt) { if (event.createdAt > sourceCreatedAt) {
if (_effectiveState[entry.key] != entry.value) { 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; _effectiveState[entry.key] = entry.value;
changed = true; changed = true;
} }
@@ -331,6 +356,9 @@ class ReadStateManager {
changed = true; changed = true;
} }
} }
debugPrint(
'[ReadStateManager] incoming result changed=$changed clientId=${decoded.blob.clientId.substring(0, min(8, decoded.blob.clientId.length))}…',
);
if (decoded.blob.clientId == _clientId) { if (decoded.blob.clientId == _clientId) {
_lastPublishedContexts = Map<String, int>.from(decoded.blob.contexts); _lastPublishedContexts = Map<String, int>.from(decoded.blob.contexts);
@@ -369,6 +397,7 @@ class ReadStateManager {
final completer = Completer<void>(); final completer = Completer<void>();
_publishCompleter = completer; _publishCompleter = completer;
_isPublishing = true; _isPublishing = true;
debugPrint('[ReadStateManager] publish starting slotId=$_slotId');
try { try {
await _fetchOwnBlobBeforePublish(); await _fetchOwnBlobBeforePublish();
@@ -390,12 +419,15 @@ class ReadStateManager {
], ],
createdAt: createdAt, createdAt: createdAt,
); );
debugPrint('[ReadStateManager] publish accepted createdAt=$createdAt');
for (final key in contexts.keys) {
if (_lastPublishedContexts[key] != contexts[key]) {
_contextSourceCreatedAt[key] = createdAt;
}
}
_lastPublishedContexts = contexts; _lastPublishedContexts = contexts;
_forcedContextIds.clear(); _forcedContextIds.clear();
for (final key in contexts.keys) {
_contextSourceCreatedAt[key] = createdAt;
}
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt); _maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt);
_persistLocalState(); _persistLocalState();
} catch (error) { } catch (error) {
@@ -438,8 +470,8 @@ class ReadStateManager {
if (!_disposed) { if (!_disposed) {
_onChanged(); _onChanged();
} }
} catch (_) { } catch (e) {
// Per NIP-RS, proceed with reachable data and merge later. debugPrint('[ReadStateManager] fetchOwnBlobBeforePublish failed: $e');
} }
} }
@@ -455,6 +487,18 @@ class ReadStateManager {
return true; return true;
} }
Set<String> drainSyncedRollbacks() {
final drained = Set<String>.from(_pendingSyncedRollbacks);
_pendingSyncedRollbacks.clear();
return drained;
}
Set<String> drainSyncedAdvances() {
final drained = Set<String>.from(_pendingSyncedAdvances);
_pendingSyncedAdvances.clear();
return drained;
}
Map<String, int> _currentContexts() { Map<String, int> _currentContexts() {
final contexts = <String, int>{}; final contexts = <String, int>{};
for (final entry in _effectiveState.entries) { for (final entry in _effectiveState.entries) {

View File

@@ -13,19 +13,22 @@ class ReadStateState {
final String? pubkey; final String? pubkey;
final Map<String, int> contexts; final Map<String, int> contexts;
final int version; final int version;
final Set<String> syncedForcedChannelIds;
const ReadStateState({ const ReadStateState({
required this.isReady, required this.isReady,
required this.pubkey, required this.pubkey,
required this.contexts, required this.contexts,
required this.version, required this.version,
this.syncedForcedChannelIds = const {},
}); });
const ReadStateState.inert() const ReadStateState.inert()
: isReady = false, : isReady = false,
pubkey = null, pubkey = null,
contexts = const {}, contexts = const {},
version = 0; version = 0,
syncedForcedChannelIds = const {};
int? effectiveTimestamp(String contextId) => contexts[contextId]; int? effectiveTimestamp(String contextId) => contexts[contextId];
@@ -40,6 +43,7 @@ class ReadStateState {
pubkey: pubkey, pubkey: pubkey,
contexts: Map.unmodifiable({...contexts, contextId: timestamp}), contexts: Map.unmodifiable({...contexts, contextId: timestamp}),
version: version + 1, version: version + 1,
syncedForcedChannelIds: syncedForcedChannelIds,
); );
} }
} }
@@ -47,12 +51,14 @@ class ReadStateState {
class ReadStateNotifier extends Notifier<ReadStateState> { class ReadStateNotifier extends Notifier<ReadStateState> {
ReadStateManager? _manager; ReadStateManager? _manager;
bool _isInitialized = false; bool _isInitialized = false;
final Set<String> _syncedForcedChannelIds = {};
@override @override
ReadStateState build() { ReadStateState build() {
_manager?.dispose(flushPending: false); _manager?.dispose(flushPending: false);
_manager = null; _manager = null;
_isInitialized = false; _isInitialized = false;
_syncedForcedChannelIds.clear();
final relayConfig = ref.watch(relayConfigProvider); final relayConfig = ref.watch(relayConfigProvider);
ref.watch(relaySessionProvider); ref.watch(relaySessionProvider);
@@ -125,6 +131,7 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
} }
void markContextRead(String contextId, int unixTimestamp) { void markContextRead(String contextId, int unixTimestamp) {
_syncedForcedChannelIds.remove(contextId);
_manager?.markContextRead(contextId, unixTimestamp); _manager?.markContextRead(contextId, unixTimestamp);
} }
@@ -138,6 +145,10 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
void _emitManagerState(ReadStateManager manager) { void _emitManagerState(ReadStateManager manager) {
if (_manager != manager) return; if (_manager != manager) return;
final rollbacks = manager.drainSyncedRollbacks();
final advances = manager.drainSyncedAdvances();
_syncedForcedChannelIds.addAll(rollbacks);
_syncedForcedChannelIds.removeAll(advances);
state = _stateFromManager( state = _stateFromManager(
manager, manager,
isReady: _isInitialized, isReady: _isInitialized,
@@ -155,6 +166,9 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
pubkey: manager.pubkey, pubkey: manager.pubkey,
contexts: manager.effectiveContexts, contexts: manager.effectiveContexts,
version: (previousVersion ?? 0) + 1, version: (previousVersion ?? 0) + 1,
syncedForcedChannelIds: Set.unmodifiable(
Set<String>.from(_syncedForcedChannelIds),
),
); );
} }
} }
@@ -174,7 +188,8 @@ String? _normalizePubkey(String? value) {
String? _safeDerivedPubkey(SignedEventRelay relay) { String? _safeDerivedPubkey(SignedEventRelay relay) {
try { try {
return _normalizePubkey(relay.pubkey); return _normalizePubkey(relay.pubkey);
} catch (_) { } catch (e) {
debugPrint('[ReadStateManager] pubkey derivation failed: $e');
return null; return null;
} }
} }

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -110,7 +111,8 @@ class ReadStateStorage {
final Object? parsed; final Object? parsed;
try { try {
parsed = jsonDecode(raw); parsed = jsonDecode(raw);
} catch (_) { } catch (e) {
debugPrint('[ReadStateManager] storage: contexts JSON corrupt: $e');
return {}; return {};
} }
@@ -141,7 +143,10 @@ class ReadStateStorage {
final Object? parsed; final Object? parsed;
try { try {
parsed = jsonDecode(raw); parsed = jsonDecode(raw);
} catch (_) { } catch (e) {
debugPrint(
'[ReadStateManager] storage: publishableContextIds JSON corrupt: $e',
);
return {}; return {};
} }
@@ -162,7 +167,10 @@ class ReadStateStorage {
final Object? parsed; final Object? parsed;
try { try {
parsed = jsonDecode(raw); parsed = jsonDecode(raw);
} catch (_) { } catch (e) {
debugPrint(
'[ReadStateManager] storage: sourceCreatedAt JSON corrupt: $e',
);
return {}; return {};
} }

View File

@@ -33,11 +33,17 @@ final unreadBadgeProvider = Provider<UnreadBadgeState>((ref) {
for (final channel in channels) { for (final channel in channels) {
if (!channel.isMember || channel.isArchived) continue; if (!channel.isMember || channel.isArchived) continue;
final isSyncedForced = readState.syncedForcedChannelIds.contains(
channel.id,
);
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt); final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt == null) continue; if (lastMessageAt == null && !isSyncedForced) continue;
final readAt = readState.effectiveTimestamp(channel.id); 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 (!isUnread) continue;
if (channel.isDm) { if (channel.isDm) {