Fix monotonic read-state merges (#884)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-05 14:00:50 -07:00
committed by GitHub
co-authored by Pinky
parent 0a4783c6f8
commit 5268fac2d8
18 changed files with 325 additions and 234 deletions
+1 -4
View File
@@ -7,10 +7,7 @@ type AppShellContextValue = {
channelId: string,
readAt: string | null | undefined,
) => void;
markChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
markChannelUnread: (channelId: string) => void;
openChannelManagement: () => void;
// NIP-RS read marker for a channel as a unix-seconds timestamp, or null
// when unknown. Backed by the single AppShell-mounted ReadStateManager so
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyRemoteContextTimestamp } from "./readStateManager.ts";
test("applyRemoteContextTimestamp ignores older remote read markers from newer sync events", () => {
const effectiveState = new Map([["channel-1", 200]]);
const contextSourceCreatedAt = new Map([["channel-1", 10]]);
const result = applyRemoteContextTimestamp({
effectiveState,
contextSourceCreatedAt,
contextId: "channel-1",
timestamp: 100,
eventCreatedAt: 11,
});
assert.equal(result, "unchanged");
assert.equal(effectiveState.get("channel-1"), 200);
assert.equal(contextSourceCreatedAt.get("channel-1"), 11);
});
test("applyRemoteContextTimestamp advances to newer remote read markers", () => {
const effectiveState = new Map([["channel-1", 100]]);
const contextSourceCreatedAt = new Map([["channel-1", 10]]);
const result = applyRemoteContextTimestamp({
effectiveState,
contextSourceCreatedAt,
contextId: "channel-1",
timestamp: 200,
eventCreatedAt: 11,
});
assert.equal(result, "advanced");
assert.equal(effectiveState.get("channel-1"), 200);
assert.equal(contextSourceCreatedAt.get("channel-1"), 11);
});
test("applyRemoteContextTimestamp keeps read markers monotonic even if sync events arrive out of order", () => {
const effectiveState = new Map([["channel-1", 100]]);
const contextSourceCreatedAt = new Map([["channel-1", 11]]);
const result = applyRemoteContextTimestamp({
effectiveState,
contextSourceCreatedAt,
contextId: "channel-1",
timestamp: 200,
eventCreatedAt: 10,
});
assert.equal(result, "advanced");
assert.equal(effectiveState.get("channel-1"), 200);
assert.equal(contextSourceCreatedAt.get("channel-1"), 11);
});
@@ -47,6 +47,49 @@ function slotIdKey(pubkey: string): string {
return `${SLOT_ID_KEY_PREFIX}:${pubkey}`;
}
export type ApplyRemoteContextResult = "unchanged" | "advanced";
function resolveRemoteContextTimestamp(args: {
current: number;
timestamp: number;
}): { next: number; result: ApplyRemoteContextResult } {
const next = Math.max(args.current, args.timestamp);
return {
next,
result: next === args.current ? "unchanged" : "advanced",
};
}
export function applyRemoteContextTimestamp(args: {
effectiveState: Map<string, number>;
contextSourceCreatedAt: Map<string, number>;
contextId: string;
timestamp: number;
eventCreatedAt: number;
}): ApplyRemoteContextResult {
const {
effectiveState,
contextSourceCreatedAt,
contextId,
timestamp,
eventCreatedAt,
} = args;
const sourceCreatedAt = contextSourceCreatedAt.get(contextId) ?? 0;
const current = effectiveState.get(contextId) ?? 0;
const { next, result } = resolveRemoteContextTimestamp({
current,
timestamp,
});
if (result === "advanced") {
effectiveState.set(contextId, next);
}
if (eventCreatedAt > sourceCreatedAt) {
contextSourceCreatedAt.set(contextId, eventCreatedAt);
}
return result;
}
export class ReadStateManager {
private pubkey: string;
private relayClient: RelayClient;
@@ -60,9 +103,7 @@ export class ReadStateManager {
private unsubscribeLive: (() => void) | null = null;
private initialized = false;
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;
@@ -101,7 +142,6 @@ export class ReadStateManager {
}
markContextRead(contextId: string, unixTimestamp: number): void {
this.forcedContexts.delete(contextId);
this.advanceContext(contextId, unixTimestamp, { publishable: true });
this.contextSourceCreatedAt.set(
contextId,
@@ -113,16 +153,6 @@ export class ReadStateManager {
this.advanceContext(contextId, unixTimestamp, { publishable: false });
}
markContextUnread(contextId: string, lastMessageUnix: number): void {
const rollbackTo = lastMessageUnix - 1;
this.effectiveState.set(contextId, rollbackTo);
this.publishableContextIds.add(contextId);
this.forcedContexts.add(contextId);
this.persistLocalState();
this.notifyListeners();
this.schedulePublish();
}
private advanceContext(
contextId: string,
unixTimestamp: number,
@@ -241,16 +271,17 @@ export class ReadStateManager {
}
for (const [ctx, ts] of Object.entries(blob.contexts)) {
if (this.forcedContexts.has(ctx)) continue;
const sourceCreatedAt = this.contextSourceCreatedAt.get(ctx) ?? 0;
const current = this.effectiveState.get(ctx) ?? 0;
if (event.created_at > sourceCreatedAt) {
this.effectiveState.set(ctx, ts);
this.contextSourceCreatedAt.set(ctx, event.created_at);
} else if (event.created_at === sourceCreatedAt && ts !== current) {
this.effectiveState.set(ctx, ts);
const result = applyRemoteContextTimestamp({
effectiveState: this.effectiveState,
contextSourceCreatedAt: this.contextSourceCreatedAt,
contextId: ctx,
timestamp: ts,
eventCreatedAt: event.created_at,
});
if (result !== "unchanged") {
this.pendingSyncedAdvances.add(ctx);
this.publishableContextIds.add(ctx);
}
this.publishableContextIds.add(ctx);
}
if (blob.client_id === this.clientId) {
@@ -361,27 +392,15 @@ export class ReadStateManager {
let anyAdvanced = false;
for (const [ctx, ts] of Object.entries(blob.contexts)) {
if (this.forcedContexts.has(ctx)) continue;
const sourceCreatedAt = this.contextSourceCreatedAt.get(ctx) ?? 0;
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;
}
this.contextSourceCreatedAt.set(ctx, event.created_at);
} else if (event.created_at === sourceCreatedAt && ts !== current) {
this.effectiveState.set(ctx, ts);
const result = applyRemoteContextTimestamp({
effectiveState: this.effectiveState,
contextSourceCreatedAt: this.contextSourceCreatedAt,
contextId: ctx,
timestamp: ts,
eventCreatedAt: event.created_at,
});
if (result === "advanced") {
this.pendingSyncedAdvances.add(ctx);
anyAdvanced = true;
}
if (!this.publishableContextIds.has(ctx)) {
@@ -467,7 +486,6 @@ export class ReadStateManager {
}
}
this.lastPublishedContexts = contexts;
this.forcedContexts.clear();
this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt,
event.created_at,
@@ -544,12 +562,6 @@ 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>();
@@ -4,8 +4,6 @@ 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>();
/**
@@ -66,13 +64,6 @@ export function useReadState(
[],
);
const markContextUnread = React.useCallback(
(contextId: string, lastMessageUnix: number): void => {
managerRef.current?.markContextUnread(contextId, lastMessageUnix);
},
[],
);
const seedContextRead = React.useCallback(
(contextId: string, unixTimestamp: number): void => {
managerRef.current?.seedContextRead(contextId, unixTimestamp);
@@ -80,10 +71,6 @@ 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>();
}, []);
@@ -97,9 +84,7 @@ export function useReadState(
getEffectiveTimestamp: noopGetTimestamp,
isReady: false,
markContextRead: noopMarkRead,
markContextUnread: noopMarkUnread,
seedContextRead: noopMarkRead,
drainSyncedRollbacks: noopDrainRollbacks,
drainSyncedAdvances: noopDrainAdvances,
readStateVersion: 0,
};
@@ -109,9 +94,7 @@ export function useReadState(
getEffectiveTimestamp,
isReady,
markContextRead,
markContextUnread,
seedContextRead,
drainSyncedRollbacks,
drainSyncedAdvances,
readStateVersion,
};
@@ -34,7 +34,6 @@ import {
} from "@/features/messages/lib/formatTimelineMessages";
import { buildThreadPanelData } from "@/features/messages/lib/threadPanel";
import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
import type { TimelineMessage } from "@/features/messages/types";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
@@ -334,14 +333,10 @@ export function ChannelScreen({
: undefined,
[activeChannel, handleToggleReaction],
);
const handleMarkUnread = React.useCallback(
(message: TimelineMessage) => {
if (!activeChannelId) return;
const messageIso = new Date(message.createdAt * 1_000).toISOString();
markChannelUnread(activeChannelId, messageIso);
},
[activeChannelId, markChannelUnread],
);
const handleMarkUnread = React.useCallback(() => {
if (!activeChannelId) return;
markChannelUnread(activeChannelId);
}, [activeChannelId, markChannelUnread]);
const {
channelAgentSessionAgents,
closeAgentSession: handleCloseAgentSession,
@@ -246,8 +246,6 @@ export function useUnreadChannels(
getEffectiveTimestamp,
isReady: isReadStateReady,
markContextRead,
markContextUnread,
drainSyncedRollbacks,
drainSyncedAdvances,
readStateVersion,
} = useReadState(pubkey, relayClient);
@@ -268,35 +266,24 @@ export function useUnreadChannels(
channelsRef.current = channels;
// Channels manually marked unread this session (e.g., right-click → "mark
// unread"). The NIP-RS rollback (markContextUnread) is the cross-device
// mechanism; this in-session flag is what makes the badge appear *now* in
// the case where we don't yet have an observed latest timestamp to compare
// against. Cleared when the user opens the channel.
// unread"). Because NIP-RS read markers are monotonic, this in-session flag
// is what makes the badge appear *now* without lowering synced read state.
// 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]);
}, [readStateVersion, 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.
@@ -375,28 +362,14 @@ export function useUnreadChannels(
);
// Manually mark a channel unread (e.g., right-click → "mark unread"). Sets
// the in-session forced flag so the sidebar badge appears immediately, and
// rolls the NIP-RS read marker back so the unread state syncs across
// devices. The forced flag is cleared in markChannelRead when the user
// opens the channel. If lastMessageAt is unknown we still set the forced
// flag, but skip the NIP-RS rollback — without a target timestamp we have
// nothing honest to publish.
const markChannelUnread = React.useCallback(
(channelId: string, lastMessageAt: string | null | undefined) => {
if (!forcedUnreadRef.current.has(channelId)) {
forcedUnreadRef.current.add(channelId);
bumpLatestVersion();
}
const unixSeconds =
toUnixSeconds(lastMessageAt) ??
latestByChannelRef.current.get(channelId) ??
null;
if (unixSeconds !== null) {
markContextUnread(channelId, unixSeconds);
}
},
[markContextUnread],
);
// the in-session forced flag so the sidebar badge appears immediately. NIP-RS
// read markers are monotonic, so we do not publish a lower timestamp.
const markChannelUnread = React.useCallback((channelId: string) => {
if (!forcedUnreadRef.current.has(channelId)) {
forcedUnreadRef.current.add(channelId);
bumpLatestVersion();
}
}, []);
// Mark the active channel as read when it changes or new messages arrive.
// Honours the caller's contract that a null activeReadAt suppresses
@@ -16,11 +16,8 @@ type UseHomeInboxReadStateOptions = {
channelId: string,
readAt: string | null | undefined,
) => void;
/** Roll the NIP-RS read marker back to the given ISO timestamp. */
markChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
/** Mark a channel unread locally for the current session. */
markChannelUnread: (channelId: string) => void;
/** Local fallback: mark a non-channel item done. */
markDoneLocal: (id: string) => void;
/** Local fallback: undo a non-channel item done. */
@@ -35,9 +32,8 @@ type UseHomeInboxReadStateOptions = {
* "Mark as read/unread" actions on channel-backed items are routed through
* `markChannelRead`/`markChannelUnread` so the sidebar, home badge, and any
* other surfaces consuming the same ReadStateManager stay in lockstep.
* Caveat: marking an older item unread rolls the *entire* channel marker
* back to that item, so newer events in that channel become unread too
* that matches NIP-RS's channel-level granularity.
* Caveat: NIP-RS channel read markers are monotonic, so marking an older item
* unread is an in-session local affordance rather than synced state.
*/
export function useHomeInboxReadState({
items,
@@ -94,10 +90,7 @@ export function useHomeInboxReadState({
const item = itemById.get(itemId);
const channelId = item?.item.channelId ?? null;
if (item && channelId) {
markChannelUnread(
channelId,
new Date(item.latestActivityAt * 1_000).toISOString(),
);
markChannelUnread(channelId);
return;
}
undoDoneLocal(itemId);
@@ -119,10 +119,7 @@ type AppSidebarProps = {
onOpenBrowseChannels: () => void;
onOpenBrowseForums: () => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (channelId: string) => void;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
@@ -142,10 +142,7 @@ export function ChannelContextMenuItems({
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread?: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread?: (channelId: string) => void;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
onStarChannel?: (channelId: string) => void;
@@ -182,9 +179,7 @@ export function ChannelContextMenuItems({
Mark as read
</ContextMenuItem>
) : !hasUnread && onMarkChannelUnread ? (
<ContextMenuItem
onClick={() => onMarkChannelUnread(channel.id, channel.lastMessageAt)}
>
<ContextMenuItem onClick={() => onMarkChannelUnread(channel.id)}>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
@@ -340,10 +335,7 @@ export function ChannelGroupSection({
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (channelId: string) => void;
onSelectChannel: (channelId: string) => void;
onToggleCollapsed: () => void;
selectedChannelId: string | null;
@@ -517,10 +509,7 @@ export function CustomChannelSection({
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (channelId: string) => void;
onMarkSectionRead: () => void;
onAssignChannel: (channelId: string, sectionId: string) => void;
onUnassignChannel: (channelId: string) => void;
@@ -253,10 +253,7 @@ export function SidebarSection({
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread?: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread?: (channelId: string) => void;
onSelectChannel: (channelId: string) => void;
onToggleCollapsed?: () => void;
mutedChannelIds?: ReadonlySet<string>;
+13 -8
View File
@@ -206,7 +206,7 @@ test("mark-as-unread via context menu shows dot badge", async ({ page }) => {
await waitForBadgeState(page, { state: "dot" });
});
test("synced mark-as-unread from another device shows dot, synced mark-as-read clears it", async ({
test("remote read-state rollback is ignored while local mark-unread still shows dot", async ({
page,
}) => {
await page.goto("/");
@@ -271,8 +271,8 @@ test("synced mark-as-unread from another device shows dot, synced mark-as-read c
},
);
// Step 2: rollback — read timestamp drops (another device marks unread).
// createdAt must be strictly greater than step 1 to pass LWW gate.
// Step 2: a remote rollback carries an older read timestamp in a newer
// event. NIP-RS read markers are monotonic, so this must be ignored.
await page.evaluate(
({ clientId, slotId, channelId, ts, createdAt }) => {
(
@@ -300,10 +300,16 @@ test("synced mark-as-unread from another device shows dot, synced mark-as-read c
},
);
// The unread dot should appear.
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
// Step 3: advance — read timestamp moves forward (device marks read).
// Local mark-unread remains an in-session affordance and should still show
// the dot immediately without publishing a lower read timestamp.
await page.getByTestId("channel-random").click({ button: "right" });
await page.getByText("Mark unread").click();
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await waitForBadgeState(page, { state: "dot" });
// Step 3: remote advance clears the local forced-unread dot.
await page.evaluate(
({ clientId, slotId, channelId, ts, createdAt }) => {
(
@@ -326,11 +332,10 @@ test("synced mark-as-unread from another device shows dot, synced mark-as-read c
clientId: REMOTE_CLIENT_ID,
slotId: REMOTE_SLOT_ID,
channelId: RANDOM_CHANNEL_ID,
ts: now,
ts: now + 10,
createdAt: now + 10,
},
);
// The unread dot should disappear.
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
});
@@ -37,7 +37,7 @@ enum _QuickAction { createChannel, createForum, newDm }
const double _kBannerHeight = 24.0;
bool _isUnread(Channel channel, ReadStateState readState) {
if (readState.syncedForcedChannelIds.contains(channel.id)) {
if (readState.locallyForcedChannelIds.contains(channel.id)) {
return true;
}
@@ -1119,7 +1119,7 @@ class _ChannelTile extends ConsumerWidget {
} else {
ref
.read(readStateProvider.notifier)
.markContextUnread(channel.id, ts);
.markContextUnread(channel.id);
}
}
},
@@ -39,6 +39,8 @@ class ReadStateCrypto {
nip44Decrypt(conversationKey, ciphertext);
}
enum _ApplyRemoteContextResult { unchanged, advanced }
class ReadStateManager {
final String pubkey;
final ReadStateCrypto _crypto;
@@ -63,9 +65,7 @@ class ReadStateManager {
Completer<void>? _publishCompleter;
bool _remoteUnsupported = false;
int _maxFetchedCreatedAt = 0;
final Set<String> _forcedContextIds = {};
final Map<String, int> _contextSourceCreatedAt = {};
final Set<String> _pendingSyncedRollbacks = {};
final Set<String> _pendingSyncedAdvances = {};
ReadStateManager({
@@ -116,7 +116,6 @@ class ReadStateManager {
}
void markContextRead(String contextId, int unixTimestamp) {
_forcedContextIds.remove(contextId);
_advanceContext(contextId, unixTimestamp, publishable: true);
_contextSourceCreatedAt[contextId] = max(
currentUnixSeconds(),
@@ -124,17 +123,6 @@ class ReadStateManager {
);
}
void markContextUnread(String contextId, int lastMessageTimestamp) {
if (_disposed || lastMessageTimestamp <= 0) return;
final rollbackTo = lastMessageTimestamp - 1;
_effectiveState[contextId] = rollbackTo;
_publishableContextIds.add(contextId);
_forcedContextIds.add(contextId);
_persistLocalState();
_onChanged();
_schedulePublish();
}
void seedContextRead(String contextId, int unixTimestamp) {
_advanceContext(contextId, unixTimestamp, publishable: false);
}
@@ -255,17 +243,15 @@ class ReadStateManager {
}
for (final entry in decoded.blob.contexts.entries) {
if (_forcedContextIds.contains(entry.key)) continue;
final sourceCreatedAt = _contextSourceCreatedAt[entry.key] ?? 0;
final current = _effectiveState[entry.key] ?? 0;
if (event.createdAt > sourceCreatedAt) {
_effectiveState[entry.key] = entry.value;
_contextSourceCreatedAt[entry.key] = event.createdAt;
} else if (event.createdAt == sourceCreatedAt &&
entry.value != current) {
_effectiveState[entry.key] = entry.value;
final result = _applyRemoteContextTimestamp(
contextId: entry.key,
timestamp: entry.value,
eventCreatedAt: event.createdAt,
);
if (result == _ApplyRemoteContextResult.advanced) {
_pendingSyncedAdvances.add(entry.key);
_publishableContextIds.add(entry.key);
}
_publishableContextIds.add(entry.key);
}
if (decoded.blob.clientId == _clientId &&
@@ -329,27 +315,13 @@ class ReadStateManager {
var changed = false;
for (final entry in decoded.blob.contexts.entries) {
if (_forcedContextIds.contains(entry.key)) continue;
final sourceCreatedAt = _contextSourceCreatedAt[entry.key] ?? 0;
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;
}
_contextSourceCreatedAt[entry.key] = event.createdAt;
} else if (event.createdAt == sourceCreatedAt && entry.value != current) {
_effectiveState[entry.key] = entry.value;
final result = _applyRemoteContextTimestamp(
contextId: entry.key,
timestamp: entry.value,
eventCreatedAt: event.createdAt,
);
if (result == _ApplyRemoteContextResult.advanced) {
_pendingSyncedAdvances.add(entry.key);
changed = true;
}
if (_publishableContextIds.add(entry.key)) {
@@ -375,6 +347,27 @@ class ReadStateManager {
}
}
_ApplyRemoteContextResult _applyRemoteContextTimestamp({
required String contextId,
required int timestamp,
required int eventCreatedAt,
}) {
final sourceCreatedAt = _contextSourceCreatedAt[contextId] ?? 0;
final current = _effectiveState[contextId] ?? 0;
final next = max(current, timestamp);
final result = next == current
? _ApplyRemoteContextResult.unchanged
: _ApplyRemoteContextResult.advanced;
if (result == _ApplyRemoteContextResult.advanced) {
_effectiveState[contextId] = next;
}
if (eventCreatedAt > sourceCreatedAt) {
_contextSourceCreatedAt[contextId] = eventCreatedAt;
}
return result;
}
void _schedulePublish() {
if (!_remoteEnabled || _remoteUnsupported || _disposed) return;
@@ -427,7 +420,6 @@ class ReadStateManager {
}
}
_lastPublishedContexts = contexts;
_forcedContextIds.clear();
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt);
_persistLocalState();
} catch (error) {
@@ -487,12 +479,6 @@ 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();
@@ -517,7 +503,6 @@ class ReadStateManager {
_publishableContextIds
..clear()
..addAll(stored.publishableContextIds);
_forcedContextIds.clear();
_contextSourceCreatedAt
..clear()
..addAll(stored.sourceCreatedAt);
@@ -13,14 +13,14 @@ class ReadStateState {
final String? pubkey;
final Map<String, int> contexts;
final int version;
final Set<String> syncedForcedChannelIds;
final Set<String> locallyForcedChannelIds;
const ReadStateState({
required this.isReady,
required this.pubkey,
required this.contexts,
required this.version,
this.syncedForcedChannelIds = const {},
this.locallyForcedChannelIds = const {},
});
const ReadStateState.inert()
@@ -28,7 +28,7 @@ class ReadStateState {
pubkey = null,
contexts = const {},
version = 0,
syncedForcedChannelIds = const {};
locallyForcedChannelIds = const {};
int? effectiveTimestamp(String contextId) => contexts[contextId];
@@ -43,7 +43,7 @@ class ReadStateState {
pubkey: pubkey,
contexts: Map.unmodifiable({...contexts, contextId: timestamp}),
version: version + 1,
syncedForcedChannelIds: syncedForcedChannelIds,
locallyForcedChannelIds: locallyForcedChannelIds,
);
}
}
@@ -51,14 +51,14 @@ class ReadStateState {
class ReadStateNotifier extends Notifier<ReadStateState> {
ReadStateManager? _manager;
bool _isInitialized = false;
final Set<String> _syncedForcedChannelIds = {};
final Set<String> _locallyForcedChannelIds = {};
@override
ReadStateState build() {
_manager?.dispose(flushPending: false);
_manager = null;
_isInitialized = false;
_syncedForcedChannelIds.clear();
_locallyForcedChannelIds.clear();
final relayConfig = ref.watch(relayConfigProvider);
ref.watch(relaySessionProvider);
@@ -131,12 +131,19 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
}
void markContextRead(String contextId, int unixTimestamp) {
_syncedForcedChannelIds.remove(contextId);
_locallyForcedChannelIds.remove(contextId);
_manager?.markContextRead(contextId, unixTimestamp);
}
void markContextUnread(String contextId, int lastMessageTimestamp) {
_manager?.markContextUnread(contextId, lastMessageTimestamp);
void markContextUnread(String contextId) {
final manager = _manager;
if (manager == null) return;
_locallyForcedChannelIds.add(contextId);
state = _stateFromManager(
manager,
isReady: _isInitialized,
previousVersion: state.version,
);
}
void seedContextRead(String contextId, int unixTimestamp) {
@@ -145,10 +152,8 @@ 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);
_locallyForcedChannelIds.removeAll(advances);
state = _stateFromManager(
manager,
isReady: _isInitialized,
@@ -166,8 +171,8 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
pubkey: manager.pubkey,
contexts: manager.effectiveContexts,
version: (previousVersion ?? 0) + 1,
syncedForcedChannelIds: Set.unmodifiable(
Set<String>.from(_syncedForcedChannelIds),
locallyForcedChannelIds: Set.unmodifiable(
Set<String>.from(_locallyForcedChannelIds),
),
);
}
@@ -33,15 +33,15 @@ final unreadBadgeProvider = Provider<UnreadBadgeState>((ref) {
for (final channel in channels) {
if (!channel.isMember || channel.isArchived) continue;
final isSyncedForced = readState.syncedForcedChannelIds.contains(
final isLocallyForced = readState.locallyForcedChannelIds.contains(
channel.id,
);
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt == null && !isSyncedForced) continue;
if (lastMessageAt == null && !isLocallyForced) continue;
final readAt = readState.effectiveTimestamp(channel.id);
final isUnread =
isSyncedForced ||
isLocallyForced ||
readAt == null ||
(lastMessageAt != null && lastMessageAt > readAt);
if (!isUnread) continue;
@@ -332,6 +332,7 @@ class _FakeReadStateNotifier extends ReadStateNotifier {
pubkey: state.pubkey,
contexts: state.contexts,
version: state.version + 1,
locallyForcedChannelIds: state.locallyForcedChannelIds,
);
}
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sprout_mobile/features/channels/read_state/read_state_format.dart';
import 'package:sprout_mobile/features/channels/read_state/read_state_manager.dart';
import 'package:sprout_mobile/shared/relay/relay.dart';
@@ -104,6 +106,49 @@ void main() {
expect(manager.getEffectiveTimestamp('channel-2'), 43);
},
);
test('remote read-state rollback is ignored', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final crypto = ReadStateCrypto.tryCreate(
nsec: keychain.nsec,
pubkey: keychain.public,
);
final relay = _FakeRelaySession();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: relay,
signedEventRelay: _FakeSignedEventRelay(),
remoteEnabled: true,
onChanged: () {},
);
relay.historyEvents = [
_readStateEvent(
pubkey: keychain.public,
crypto: crypto,
clientId: 'remote-client',
slotId: 'remote-slot',
contexts: {'channel-1': 100},
createdAt: 100,
),
_readStateEvent(
pubkey: keychain.public,
crypto: crypto,
clientId: 'remote-client',
slotId: 'remote-slot',
contexts: {'channel-1': 50},
createdAt: 110,
),
];
await manager.initialize();
expect(manager.getEffectiveTimestamp('channel-1'), 100);
});
}
class _SubmittedEvent {
@@ -177,3 +222,43 @@ class _MissingScopeSignedEventRelay implements SignedEventRelay {
throw Exception('missing users:write');
}
}
NostrEvent _readStateEvent({
required String pubkey,
required ReadStateCrypto crypto,
required String clientId,
required String slotId,
required Map<String, int> contexts,
required int createdAt,
}) {
final blob = ReadStateBlob(clientId: clientId, contexts: contexts);
return NostrEvent(
id: 'event-$clientId-$createdAt',
pubkey: pubkey,
createdAt: createdAt,
kind: EventKind.readState,
tags: [
['d', '$readStateDTagPrefix$slotId'],
['t', 'read-state'],
],
content: crypto.encrypt(jsonEncode(blob.toJson())),
sig: 'sig',
);
}
class _FakeRelaySession extends RelaySessionNotifier {
List<NostrEvent> historyEvents = [];
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => historyEvents;
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
}
@@ -62,6 +62,7 @@ void main() {
ProviderContainer buildContainer({
required List<Channel> channels,
Map<String, int> readContexts = const {},
Set<String> locallyForcedChannelIds = const {},
bool readStateReady = true,
Map<String, int> highPriorityMap = const {},
}) {
@@ -80,6 +81,7 @@ void main() {
pubkey: 'me',
contexts: readContexts,
version: 1,
locallyForcedChannelIds: locallyForcedChannelIds,
),
),
),
@@ -259,6 +261,23 @@ void main() {
},
);
test(
'locally forced channel counts unread without publishing rollback',
() async {
final container = buildContainer(
channels: [makeChannel(id: 'ch-a', lastMessageAtSeconds: t20)],
readContexts: {'ch-a': t30},
locallyForcedChannelIds: {'ch-a'},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
},
);
test('channelsProvider in loading state returns (0, 0)', () {
// The provider returns const UnreadBadgeState() while channels are loading.
// We intentionally do NOT await the future here — the channels notifier