mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile+desktop): cross-device read state sync + diagnostic logging (#843)
This commit is contained in:
@@ -62,6 +62,9 @@ export class ReadStateManager {
|
||||
private maxFetchedCreatedAt = 0;
|
||||
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) {
|
||||
this.pubkey = pubkey;
|
||||
@@ -75,17 +78,25 @@ 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}`,
|
||||
);
|
||||
|
||||
this.hydrateFromLocalStorage();
|
||||
|
||||
await this.fetchAndMerge();
|
||||
if (this.destroyed) return;
|
||||
await this.startLiveSubscription();
|
||||
if (this.destroyed) return;
|
||||
if (!this.isIdenticalToLastPublished(this.currentContexts())) {
|
||||
this.schedulePublish();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
console.debug(
|
||||
`[ReadStateManager] initialize complete maxFetchedCreatedAt=${this.maxFetchedCreatedAt} contexts=${this.effectiveState.size}`,
|
||||
);
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
@@ -152,6 +163,7 @@ export class ReadStateManager {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
// Flush any pending writes immediately
|
||||
if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
@@ -177,7 +189,8 @@ export class ReadStateManager {
|
||||
since: Math.floor(Date.now() / 1_000) - READ_STATE_HORIZON_SECONDS,
|
||||
limit: READ_STATE_FETCH_LIMIT,
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.debug("[ReadStateManager] fetchAndMerge failed:", error);
|
||||
// If fetch fails, proceed with local state only
|
||||
return;
|
||||
}
|
||||
@@ -219,7 +232,11 @@ export class ReadStateManager {
|
||||
client_id: parsed.client_id,
|
||||
contexts: sanitizeContexts(parsed.contexts),
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
`[ReadStateManager] mergeEvents decrypt failed event=${event.id.substring(0, 8)}…:`,
|
||||
error,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -260,7 +277,11 @@ export class ReadStateManager {
|
||||
localStorage.setItem(slotIdKey(this.pubkey), this.slotId);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
`[ReadStateManager] conflict check decrypt failed event=${event.id.substring(0, 8)}…:`,
|
||||
error,
|
||||
);
|
||||
// Decrypt failure — skip this event
|
||||
}
|
||||
}
|
||||
@@ -286,14 +307,24 @@ export class ReadStateManager {
|
||||
void this.handleIncomingEvent(event);
|
||||
},
|
||||
);
|
||||
if (this.destroyed) {
|
||||
unsub();
|
||||
return;
|
||||
}
|
||||
this.unsubscribeLive = unsub;
|
||||
} catch {
|
||||
console.debug("[ReadStateManager] live subscription established");
|
||||
} catch (error) {
|
||||
console.debug("[ReadStateManager] live subscription FAILED:", error);
|
||||
// Non-fatal: we can still work with local state
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingEvent(event: RelayEvent): Promise<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}`,
|
||||
);
|
||||
|
||||
const dTags = event.tags.filter((t) => t[0] === "d");
|
||||
if (dTags.length !== 1) return;
|
||||
@@ -320,7 +351,11 @@ export class ReadStateManager {
|
||||
client_id: parsed.client_id,
|
||||
contexts: sanitizeContexts(parsed.contexts),
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
`[ReadStateManager] incoming event decrypt/parse failed event=${event.id.substring(0, 8)}…:`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,6 +366,16 @@ export class ReadStateManager {
|
||||
const current = this.effectiveState.get(ctx) ?? 0;
|
||||
if (event.created_at > sourceCreatedAt) {
|
||||
if (this.effectiveState.get(ctx) !== ts) {
|
||||
if (ts < current && current > 0) {
|
||||
this.pendingSyncedRollbacks.add(ctx);
|
||||
this.pendingSyncedAdvances.delete(ctx);
|
||||
console.debug(
|
||||
`[ReadStateManager] synced rollback ctx=${ctx.substring(0, 12)}… from=${current} to=${ts}`,
|
||||
);
|
||||
} else if (ts > current) {
|
||||
this.pendingSyncedAdvances.add(ctx);
|
||||
this.pendingSyncedRollbacks.delete(ctx);
|
||||
}
|
||||
this.effectiveState.set(ctx, ts);
|
||||
anyAdvanced = true;
|
||||
}
|
||||
@@ -344,6 +389,9 @@ export class ReadStateManager {
|
||||
anyAdvanced = true;
|
||||
}
|
||||
}
|
||||
console.debug(
|
||||
`[ReadStateManager] incoming result anyAdvanced=${anyAdvanced} clientId=${blob.client_id.substring(0, 8)}…`,
|
||||
);
|
||||
|
||||
if (anyAdvanced) {
|
||||
this.persistLocalState();
|
||||
@@ -368,6 +416,7 @@ export class ReadStateManager {
|
||||
}
|
||||
|
||||
private async publish(): Promise<void> {
|
||||
console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`);
|
||||
await this.fetchOwnBlobBeforePublish();
|
||||
|
||||
// Build blob from contexts this client is allowed to publish.
|
||||
@@ -408,12 +457,17 @@ export class ReadStateManager {
|
||||
"Timed out publishing read state.",
|
||||
"Failed to publish read state.",
|
||||
);
|
||||
console.debug(
|
||||
`[ReadStateManager] publish accepted createdAt=${createdAt}`,
|
||||
);
|
||||
|
||||
for (const key of Object.keys(contexts)) {
|
||||
if (this.lastPublishedContexts[key] !== contexts[key]) {
|
||||
this.contextSourceCreatedAt.set(key, createdAt);
|
||||
}
|
||||
}
|
||||
this.lastPublishedContexts = contexts;
|
||||
this.forcedContexts.clear();
|
||||
for (const key of Object.keys(contexts)) {
|
||||
this.contextSourceCreatedAt.set(key, createdAt);
|
||||
}
|
||||
this.maxFetchedCreatedAt = Math.max(
|
||||
this.maxFetchedCreatedAt,
|
||||
event.created_at,
|
||||
@@ -435,7 +489,11 @@ export class ReadStateManager {
|
||||
|
||||
await this.mergeEvents(events);
|
||||
this.persistLocalState();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
"[ReadStateManager] fetchOwnBlobBeforePublish failed:",
|
||||
error,
|
||||
);
|
||||
// Per NIP-RS, proceed with reachable data and merge on a later fetch.
|
||||
}
|
||||
}
|
||||
@@ -486,11 +544,24 @@ export class ReadStateManager {
|
||||
);
|
||||
}
|
||||
|
||||
drainSyncedRollbacks(): ReadonlySet<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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { RelayClient } from "@/shared/api/relayClientSession";
|
||||
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.
|
||||
@@ -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(
|
||||
pubkey && relayClient && initializedPubkey === pubkey,
|
||||
);
|
||||
@@ -89,6 +99,8 @@ export function useReadState(
|
||||
markContextRead: noopMarkRead,
|
||||
markContextUnread: noopMarkUnread,
|
||||
seedContextRead: noopMarkRead,
|
||||
drainSyncedRollbacks: noopDrainRollbacks,
|
||||
drainSyncedAdvances: noopDrainAdvances,
|
||||
readStateVersion: 0,
|
||||
};
|
||||
}
|
||||
@@ -99,6 +111,8 @@ export function useReadState(
|
||||
markContextRead,
|
||||
markContextUnread,
|
||||
seedContextRead,
|
||||
drainSyncedRollbacks,
|
||||
drainSyncedAdvances,
|
||||
readStateVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,6 +247,8 @@ export function useUnreadChannels(
|
||||
isReady: isReadStateReady,
|
||||
markContextRead,
|
||||
markContextUnread,
|
||||
drainSyncedRollbacks,
|
||||
drainSyncedAdvances,
|
||||
readStateVersion,
|
||||
} = useReadState(pubkey, relayClient);
|
||||
|
||||
@@ -272,6 +274,30 @@ export function useUnreadChannels(
|
||||
// against. Cleared when the user opens the channel.
|
||||
const forcedUnreadRef = React.useRef(new Set<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.
|
||||
// Used to determine if thread replies should trigger unread notifications.
|
||||
const participatedRootIdsRef = React.useRef(new Set<string>());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -37,6 +37,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ class ReadStateCrypto {
|
||||
return null;
|
||||
}
|
||||
return ReadStateCrypto._(getConversationKey(privkeyHex, pubkey));
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
debugPrint('[ReadStateManager] crypto init failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -64,6 +65,8 @@ class ReadStateManager {
|
||||
int _maxFetchedCreatedAt = 0;
|
||||
final Set<String> _forcedContextIds = {};
|
||||
final Map<String, int> _contextSourceCreatedAt = {};
|
||||
final Set<String> _pendingSyncedRollbacks = {};
|
||||
final Set<String> _pendingSyncedAdvances = {};
|
||||
|
||||
ReadStateManager({
|
||||
required this.pubkey,
|
||||
@@ -91,6 +94,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 +110,9 @@ class ReadStateManager {
|
||||
}
|
||||
|
||||
_onChanged();
|
||||
debugPrint(
|
||||
'[ReadStateManager] initialize complete maxFetchedCreatedAt=$_maxFetchedCreatedAt contexts=${_effectiveState.length}',
|
||||
);
|
||||
}
|
||||
|
||||
void markContextRead(String contextId, int unixTimestamp) {
|
||||
@@ -138,7 +147,8 @@ class ReadStateManager {
|
||||
}
|
||||
|
||||
Future<void> reinitializeRemote() async {
|
||||
if (_disposed || !_remoteEnabled) return;
|
||||
if (_disposed || !_remoteEnabled || !_initialized) return;
|
||||
debugPrint('[ReadStateManager] reinitializeRemote');
|
||||
if (_isPublishing) {
|
||||
await _publishCompleter?.future;
|
||||
}
|
||||
@@ -218,8 +228,8 @@ class ReadStateManager {
|
||||
_mergeEvents(events);
|
||||
_persistLocalState();
|
||||
_onChanged();
|
||||
} catch (_) {
|
||||
// Local state remains usable when relay history is unavailable.
|
||||
} catch (e) {
|
||||
debugPrint('[ReadStateManager] fetchAndMerge failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,9 +243,7 @@ class ReadStateManager {
|
||||
pubkey: pubkey,
|
||||
decrypt: _crypto.decrypt,
|
||||
);
|
||||
if (decoded == null) {
|
||||
continue;
|
||||
}
|
||||
if (decoded == null) continue;
|
||||
|
||||
if (_isPlausibleCreatedAt(event.createdAt)) {
|
||||
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
|
||||
@@ -275,7 +283,7 @@ class ReadStateManager {
|
||||
|
||||
Future<void> _startLiveSubscription() async {
|
||||
try {
|
||||
_unsubscribeLive = await _relaySession!.subscribe(
|
||||
final unsub = await _relaySession!.subscribe(
|
||||
NostrFilter(
|
||||
kinds: const [EventKind.readState],
|
||||
authors: [pubkey],
|
||||
@@ -286,22 +294,29 @@ class ReadStateManager {
|
||||
),
|
||||
_handleIncomingEvent,
|
||||
);
|
||||
} catch (_) {
|
||||
// Non-fatal; history and local writes still work.
|
||||
if (_disposed) {
|
||||
unsub.call();
|
||||
return;
|
||||
}
|
||||
_unsubscribeLive = unsub;
|
||||
debugPrint('[ReadStateManager] live subscription established');
|
||||
} catch (e) {
|
||||
debugPrint('[ReadStateManager] live subscription FAILED: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleIncomingEvent(NostrEvent event) {
|
||||
if (_disposed) return;
|
||||
debugPrint(
|
||||
'[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.createdAt}',
|
||||
);
|
||||
|
||||
final decoded = decodeReadStateEvent(
|
||||
event,
|
||||
pubkey: pubkey,
|
||||
decrypt: _crypto.decrypt,
|
||||
);
|
||||
if (decoded == null) {
|
||||
return;
|
||||
}
|
||||
if (decoded == null) return;
|
||||
|
||||
if (_isPlausibleCreatedAt(event.createdAt)) {
|
||||
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
|
||||
@@ -319,6 +334,16 @@ class ReadStateManager {
|
||||
final current = _effectiveState[entry.key] ?? 0;
|
||||
if (event.createdAt > sourceCreatedAt) {
|
||||
if (_effectiveState[entry.key] != entry.value) {
|
||||
if (entry.value < current && current > 0) {
|
||||
_pendingSyncedRollbacks.add(entry.key);
|
||||
_pendingSyncedAdvances.remove(entry.key);
|
||||
debugPrint(
|
||||
'[ReadStateManager] synced rollback ctx=${entry.key.substring(0, min(12, entry.key.length))}… from=$current to=${entry.value}',
|
||||
);
|
||||
} else if (entry.value > current) {
|
||||
_pendingSyncedAdvances.add(entry.key);
|
||||
_pendingSyncedRollbacks.remove(entry.key);
|
||||
}
|
||||
_effectiveState[entry.key] = entry.value;
|
||||
changed = true;
|
||||
}
|
||||
@@ -331,6 +356,9 @@ class ReadStateManager {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
debugPrint(
|
||||
'[ReadStateManager] incoming result changed=$changed clientId=${decoded.blob.clientId.substring(0, min(8, decoded.blob.clientId.length))}…',
|
||||
);
|
||||
|
||||
if (decoded.blob.clientId == _clientId) {
|
||||
_lastPublishedContexts = Map<String, int>.from(decoded.blob.contexts);
|
||||
@@ -369,6 +397,7 @@ class ReadStateManager {
|
||||
final completer = Completer<void>();
|
||||
_publishCompleter = completer;
|
||||
_isPublishing = true;
|
||||
debugPrint('[ReadStateManager] publish starting slotId=$_slotId');
|
||||
try {
|
||||
await _fetchOwnBlobBeforePublish();
|
||||
|
||||
@@ -390,12 +419,15 @@ class ReadStateManager {
|
||||
],
|
||||
createdAt: createdAt,
|
||||
);
|
||||
debugPrint('[ReadStateManager] publish accepted createdAt=$createdAt');
|
||||
|
||||
for (final key in contexts.keys) {
|
||||
if (_lastPublishedContexts[key] != contexts[key]) {
|
||||
_contextSourceCreatedAt[key] = createdAt;
|
||||
}
|
||||
}
|
||||
_lastPublishedContexts = contexts;
|
||||
_forcedContextIds.clear();
|
||||
for (final key in contexts.keys) {
|
||||
_contextSourceCreatedAt[key] = createdAt;
|
||||
}
|
||||
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt);
|
||||
_persistLocalState();
|
||||
} catch (error) {
|
||||
@@ -438,8 +470,8 @@ class ReadStateManager {
|
||||
if (!_disposed) {
|
||||
_onChanged();
|
||||
}
|
||||
} catch (_) {
|
||||
// Per NIP-RS, proceed with reachable data and merge later.
|
||||
} catch (e) {
|
||||
debugPrint('[ReadStateManager] fetchOwnBlobBeforePublish failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +487,18 @@ class ReadStateManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
Set<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() {
|
||||
final contexts = <String, int>{};
|
||||
for (final entry in _effectiveState.entries) {
|
||||
|
||||
@@ -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,10 @@ 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,
|
||||
@@ -155,6 +166,9 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
|
||||
pubkey: manager.pubkey,
|
||||
contexts: manager.effectiveContexts,
|
||||
version: (previousVersion ?? 0) + 1,
|
||||
syncedForcedChannelIds: Set.unmodifiable(
|
||||
Set<String>.from(_syncedForcedChannelIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -174,7 +188,8 @@ String? _normalizePubkey(String? value) {
|
||||
String? _safeDerivedPubkey(SignedEventRelay relay) {
|
||||
try {
|
||||
return _normalizePubkey(relay.pubkey);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
debugPrint('[ReadStateManager] pubkey derivation failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user