Skip to content

Commit 0dcb951

Browse files
authored
fix(mobile+desktop): cross-device read state sync + diagnostic logging (block#843)
1 parent 40c7e44 commit 0dcb951

12 files changed

Lines changed: 413 additions & 40 deletions

File tree

desktop/src/features/channels/readState/readStateManager.ts

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export class ReadStateManager {
6262
private maxFetchedCreatedAt = 0;
6363
private forcedContexts = new Set<string>();
6464
private contextSourceCreatedAt = new Map<string, number>();
65+
private pendingSyncedRollbacks = new Set<string>();
66+
private pendingSyncedAdvances = new Set<string>();
67+
private destroyed = false;
6568

6669
constructor(pubkey: string, relayClient: RelayClient) {
6770
this.pubkey = pubkey;
@@ -75,17 +78,25 @@ export class ReadStateManager {
7578
}
7679

7780
async initialize(): Promise<void> {
78-
if (this.initialized) return;
81+
if (this.initialized || this.destroyed) return;
82+
console.debug(
83+
`[ReadStateManager] initialize pubkey=${this.pubkey.substring(0, 8)}… clientId=${this.clientId.substring(0, 8)}… slotId=${this.slotId}`,
84+
);
7985

8086
this.hydrateFromLocalStorage();
8187

8288
await this.fetchAndMerge();
89+
if (this.destroyed) return;
8390
await this.startLiveSubscription();
91+
if (this.destroyed) return;
8492
if (!this.isIdenticalToLastPublished(this.currentContexts())) {
8593
this.schedulePublish();
8694
}
8795

8896
this.initialized = true;
97+
console.debug(
98+
`[ReadStateManager] initialize complete maxFetchedCreatedAt=${this.maxFetchedCreatedAt} contexts=${this.effectiveState.size}`,
99+
);
89100
this.notifyListeners();
90101
}
91102

@@ -152,6 +163,7 @@ export class ReadStateManager {
152163
}
153164

154165
destroy(): void {
166+
this.destroyed = true;
155167
// Flush any pending writes immediately
156168
if (this.debounceTimer !== null) {
157169
window.clearTimeout(this.debounceTimer);
@@ -177,7 +189,8 @@ export class ReadStateManager {
177189
since: Math.floor(Date.now() / 1_000) - READ_STATE_HORIZON_SECONDS,
178190
limit: READ_STATE_FETCH_LIMIT,
179191
});
180-
} catch {
192+
} catch (error) {
193+
console.debug("[ReadStateManager] fetchAndMerge failed:", error);
181194
// If fetch fails, proceed with local state only
182195
return;
183196
}
@@ -219,7 +232,11 @@ export class ReadStateManager {
219232
client_id: parsed.client_id,
220233
contexts: sanitizeContexts(parsed.contexts),
221234
};
222-
} catch {
235+
} catch (error) {
236+
console.debug(
237+
`[ReadStateManager] mergeEvents decrypt failed event=${event.id.substring(0, 8)}…:`,
238+
error,
239+
);
223240
continue;
224241
}
225242

@@ -260,7 +277,11 @@ export class ReadStateManager {
260277
localStorage.setItem(slotIdKey(this.pubkey), this.slotId);
261278
break;
262279
}
263-
} catch {
280+
} catch (error) {
281+
console.debug(
282+
`[ReadStateManager] conflict check decrypt failed event=${event.id.substring(0, 8)}…:`,
283+
error,
284+
);
264285
// Decrypt failure — skip this event
265286
}
266287
}
@@ -286,14 +307,24 @@ export class ReadStateManager {
286307
void this.handleIncomingEvent(event);
287308
},
288309
);
310+
if (this.destroyed) {
311+
unsub();
312+
return;
313+
}
289314
this.unsubscribeLive = unsub;
290-
} catch {
315+
console.debug("[ReadStateManager] live subscription established");
316+
} catch (error) {
317+
console.debug("[ReadStateManager] live subscription FAILED:", error);
291318
// Non-fatal: we can still work with local state
292319
}
293320
}
294321

295322
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
296323
if (event.pubkey !== this.pubkey) return;
324+
if (this.destroyed) return;
325+
console.debug(
326+
`[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`,
327+
);
297328

298329
const dTags = event.tags.filter((t) => t[0] === "d");
299330
if (dTags.length !== 1) return;
@@ -320,7 +351,11 @@ export class ReadStateManager {
320351
client_id: parsed.client_id,
321352
contexts: sanitizeContexts(parsed.contexts),
322353
};
323-
} catch {
354+
} catch (error) {
355+
console.debug(
356+
`[ReadStateManager] incoming event decrypt/parse failed event=${event.id.substring(0, 8)}…:`,
357+
error,
358+
);
324359
return;
325360
}
326361

@@ -331,6 +366,16 @@ export class ReadStateManager {
331366
const current = this.effectiveState.get(ctx) ?? 0;
332367
if (event.created_at > sourceCreatedAt) {
333368
if (this.effectiveState.get(ctx) !== ts) {
369+
if (ts < current && current > 0) {
370+
this.pendingSyncedRollbacks.add(ctx);
371+
this.pendingSyncedAdvances.delete(ctx);
372+
console.debug(
373+
`[ReadStateManager] synced rollback ctx=${ctx.substring(0, 12)}… from=${current} to=${ts}`,
374+
);
375+
} else if (ts > current) {
376+
this.pendingSyncedAdvances.add(ctx);
377+
this.pendingSyncedRollbacks.delete(ctx);
378+
}
334379
this.effectiveState.set(ctx, ts);
335380
anyAdvanced = true;
336381
}
@@ -344,6 +389,9 @@ export class ReadStateManager {
344389
anyAdvanced = true;
345390
}
346391
}
392+
console.debug(
393+
`[ReadStateManager] incoming result anyAdvanced=${anyAdvanced} clientId=${blob.client_id.substring(0, 8)}…`,
394+
);
347395

348396
if (anyAdvanced) {
349397
this.persistLocalState();
@@ -368,6 +416,7 @@ export class ReadStateManager {
368416
}
369417

370418
private async publish(): Promise<void> {
419+
console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`);
371420
await this.fetchOwnBlobBeforePublish();
372421

373422
// Build blob from contexts this client is allowed to publish.
@@ -408,12 +457,17 @@ export class ReadStateManager {
408457
"Timed out publishing read state.",
409458
"Failed to publish read state.",
410459
);
460+
console.debug(
461+
`[ReadStateManager] publish accepted createdAt=${createdAt}`,
462+
);
411463

412-
this.lastPublishedContexts = contexts;
413-
this.forcedContexts.clear();
414464
for (const key of Object.keys(contexts)) {
415-
this.contextSourceCreatedAt.set(key, createdAt);
465+
if (this.lastPublishedContexts[key] !== contexts[key]) {
466+
this.contextSourceCreatedAt.set(key, createdAt);
467+
}
416468
}
469+
this.lastPublishedContexts = contexts;
470+
this.forcedContexts.clear();
417471
this.maxFetchedCreatedAt = Math.max(
418472
this.maxFetchedCreatedAt,
419473
event.created_at,
@@ -435,7 +489,11 @@ export class ReadStateManager {
435489

436490
await this.mergeEvents(events);
437491
this.persistLocalState();
438-
} catch {
492+
} catch (error) {
493+
console.debug(
494+
"[ReadStateManager] fetchOwnBlobBeforePublish failed:",
495+
error,
496+
);
439497
// Per NIP-RS, proceed with reachable data and merge on a later fetch.
440498
}
441499
}
@@ -486,11 +544,24 @@ export class ReadStateManager {
486544
);
487545
}
488546

547+
drainSyncedRollbacks(): ReadonlySet<string> {
548+
const drained = this.pendingSyncedRollbacks;
549+
this.pendingSyncedRollbacks = new Set<string>();
550+
return drained;
551+
}
552+
553+
drainSyncedAdvances(): ReadonlySet<string> {
554+
const drained = this.pendingSyncedAdvances;
555+
this.pendingSyncedAdvances = new Set<string>();
556+
return drained;
557+
}
558+
489559
private notifyListeners(): void {
490560
for (const listener of this.listeners) {
491561
try {
492562
listener();
493-
} catch {
563+
} catch (error) {
564+
console.debug("[ReadStateManager] listener threw:", error);
494565
// Don't let a broken listener break the manager
495566
}
496567
}

desktop/src/features/channels/readState/readStateStorage.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ function mergeLocalStorageKey(
3131
contexts.set(channelId, unixSeconds);
3232
}
3333
}
34-
} catch {
34+
} catch (error) {
35+
console.debug("[ReadStateManager] storage: contexts JSON corrupt:", error);
3536
// Corrupt localStorage, ignore.
3637
}
3738
}
@@ -50,7 +51,11 @@ function readPublishableContextIds(pubkey: string): Set<string> {
5051
result.add(value);
5152
}
5253
}
53-
} catch {
54+
} catch (error) {
55+
console.debug(
56+
"[ReadStateManager] storage: publishableContextIds JSON corrupt:",
57+
error,
58+
);
5459
// Corrupt localStorage, ignore.
5560
}
5661

@@ -71,7 +76,11 @@ function readContextSourceCreatedAt(pubkey: string): Map<string, number> {
7176
result.set(key, value);
7277
}
7378
}
74-
} catch {
79+
} catch (error) {
80+
console.debug(
81+
"[ReadStateManager] storage: sourceCreatedAt JSON corrupt:",
82+
error,
83+
);
7584
// Corrupt localStorage, ignore.
7685
}
7786

desktop/src/features/channels/readState/useReadState.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { RelayClient } from "@/shared/api/relayClientSession";
55
const noopGetTimestamp = () => null;
66
const noopMarkRead = () => {};
77
const noopMarkUnread = () => {};
8+
const noopDrainRollbacks = (): ReadonlySet<string> => new Set<string>();
9+
const noopDrainAdvances = (): ReadonlySet<string> => new Set<string>();
810

911
/**
1012
* React hook that creates and manages a ReadStateManager instance.
@@ -78,6 +80,14 @@ export function useReadState(
7880
[],
7981
);
8082

83+
const drainSyncedRollbacks = React.useCallback((): ReadonlySet<string> => {
84+
return managerRef.current?.drainSyncedRollbacks() ?? new Set<string>();
85+
}, []);
86+
87+
const drainSyncedAdvances = React.useCallback((): ReadonlySet<string> => {
88+
return managerRef.current?.drainSyncedAdvances() ?? new Set<string>();
89+
}, []);
90+
8191
const isReady = Boolean(
8292
pubkey && relayClient && initializedPubkey === pubkey,
8393
);
@@ -89,6 +99,8 @@ export function useReadState(
8999
markContextRead: noopMarkRead,
90100
markContextUnread: noopMarkUnread,
91101
seedContextRead: noopMarkRead,
102+
drainSyncedRollbacks: noopDrainRollbacks,
103+
drainSyncedAdvances: noopDrainAdvances,
92104
readStateVersion: 0,
93105
};
94106
}
@@ -99,6 +111,8 @@ export function useReadState(
99111
markContextRead,
100112
markContextUnread,
101113
seedContextRead,
114+
drainSyncedRollbacks,
115+
drainSyncedAdvances,
102116
readStateVersion,
103117
};
104118
}

desktop/src/features/channels/useUnreadChannels.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ export function useUnreadChannels(
247247
isReady: isReadStateReady,
248248
markContextRead,
249249
markContextUnread,
250+
drainSyncedRollbacks,
251+
drainSyncedAdvances,
250252
readStateVersion,
251253
} = useReadState(pubkey, relayClient);
252254

@@ -272,6 +274,30 @@ export function useUnreadChannels(
272274
// against. Cleared when the user opens the channel.
273275
const forcedUnreadRef = React.useRef(new Set<string>());
274276

277+
// When a synced event rolls back a read marker (cross-device mark-as-unread),
278+
// merge into forcedUnreadRef so the badge appears immediately without waiting
279+
// for a catch-up REQ that already ran with the old (higher) marker.
280+
// When a synced event advances a read marker (cross-device mark-as-read),
281+
// remove from forcedUnreadRef so the dot clears immediately.
282+
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional drain trigger
283+
React.useEffect(() => {
284+
const rolled = drainSyncedRollbacks();
285+
const advanced = drainSyncedAdvances();
286+
let anyNew = false;
287+
for (const channelId of rolled) {
288+
if (!forcedUnreadRef.current.has(channelId)) {
289+
forcedUnreadRef.current.add(channelId);
290+
anyNew = true;
291+
}
292+
}
293+
for (const channelId of advanced) {
294+
if (forcedUnreadRef.current.delete(channelId)) {
295+
anyNew = true;
296+
}
297+
}
298+
if (anyNew) bumpLatestVersion();
299+
}, [readStateVersion, drainSyncedRollbacks, drainSyncedAdvances]);
300+
275301
// Root event IDs of threads where the current user has replied at least once.
276302
// Used to determine if thread replies should trigger unread notifications.
277303
const participatedRootIdsRef = React.useRef(new Set<string>());

desktop/src/testing/e2eBridge.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,12 @@ declare global {
526526
models?: Array<{ id: string; name: string | null }>;
527527
denyReason?: string;
528528
}) => void;
529+
__SPROUT_E2E_EMIT_MOCK_READ_STATE__?: (input: {
530+
clientId: string;
531+
contexts: Record<string, number>;
532+
createdAt: number;
533+
slotId: string;
534+
}) => unknown;
529535
}
530536
}
531537

@@ -5098,6 +5104,11 @@ function sendToMockSocket(args: {
50985104
return;
50995105
}
51005106

5107+
if (event.kind === 30078) {
5108+
sendWsText(socket.handler, ["OK", event.id, true, ""]);
5109+
return;
5110+
}
5111+
51015112
const channelId = getChannelIdFromTags(event.tags);
51025113
if (!channelId) {
51035114
sendWsText(socket.handler, [
@@ -5201,6 +5212,30 @@ export function maybeInstallE2eTauriMocks() {
52015212
window.dispatchEvent(new CustomEvent("sprout:e2e-home-feed-updated"));
52025213
return item;
52035214
};
5215+
window.__SPROUT_E2E_EMIT_MOCK_READ_STATE__ = ({
5216+
clientId,
5217+
contexts,
5218+
createdAt,
5219+
slotId,
5220+
}) => {
5221+
const blob = JSON.stringify({
5222+
v: 1,
5223+
client_id: clientId,
5224+
contexts,
5225+
});
5226+
const event = createMockEvent(
5227+
30078,
5228+
blob,
5229+
[
5230+
["d", `read-state:${slotId}`],
5231+
["t", "read-state"],
5232+
],
5233+
getMockMemberPubkey(config),
5234+
createdAt,
5235+
);
5236+
emitMockLiveEvent(GLOBAL_MOCK_SUBSCRIPTION, event);
5237+
return event;
5238+
};
52045239
window.__SPROUT_E2E_SET_STALL_WEBSOCKET_SENDS__ = (stall) => {
52055240
const config = getConfig();
52065241
if (!config?.mock) return;
@@ -5652,6 +5687,10 @@ export function maybeInstallE2eTauriMocks() {
56525687
(payload as { createdAt?: number }).createdAt,
56535688
),
56545689
);
5690+
case "nip44_encrypt_to_self":
5691+
return (payload as { plaintext: string }).plaintext;
5692+
case "nip44_decrypt_from_self":
5693+
return (payload as { ciphertext: string }).ciphertext;
56555694
case "create_auth_event":
56565695
if (identity) {
56575696
return JSON.stringify(

0 commit comments

Comments
 (0)