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
@@ -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>());
+39
View File
@@ -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(
+129
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 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) {