fix(desktop): clear unread projections from message read markers (#1242)

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-24 09:39:16 -07:00
committed by GitHub
co-authored by Pinky
parent 86d7748ebd
commit 733c766eba
14 changed files with 446 additions and 122 deletions
+1
View File
@@ -405,6 +405,7 @@ export function AppShell() {
feedItemState.unreadSet,
threadActivityFeedItems,
getThreadReadAt,
getMessageReadAt,
);
const dueReminderBadge = useDueReminderBadgeCount(
@@ -1,18 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isMsgContextKey, msgContextKey } from "./readStateFormat.ts";
import {
isMsgContextKey,
isThreadContextKey,
maxReadAt,
msgContextKey,
} from "./readStateFormat.ts";
const EVENT_ID = "a".repeat(64);
test("maxReadAt_usesNewestNonNullMarker", () => {
assert.equal(maxReadAt(null, 10, 5, null, 30), 30);
});
test("maxReadAt_allNull_returnsNull", () => {
assert.equal(maxReadAt(null, null), null);
});
test("msgContextKey_prefixesId_returnsMsgKey", () => {
assert.equal(msgContextKey("abc123"), "msg:abc123");
assert.equal(msgContextKey(EVENT_ID), `msg:${EVENT_ID}`);
});
test("isMsgContextKey_wellFormedKey_returnsTrue", () => {
assert.equal(isMsgContextKey("msg:abc123"), true);
assert.equal(isMsgContextKey(`msg:${EVENT_ID}`), true);
});
test("isThreadContextKey_wellFormedKey_returnsTrue", () => {
assert.equal(isThreadContextKey(`thread:${EVENT_ID}`), true);
});
test("isMsgContextKey_threadKey_returnsFalse", () => {
assert.equal(isMsgContextKey(`thread:${"a".repeat(64)}`), false);
assert.equal(isMsgContextKey(`thread:${EVENT_ID}`), false);
});
test("isMsgContextKey_channelKey_returnsFalse", () => {
@@ -23,11 +42,19 @@ test("isMsgContextKey_emptyId_returnsFalse", () => {
assert.equal(isMsgContextKey("msg:"), false);
});
test("isMsgContextKey_shortId_returnsFalse", () => {
assert.equal(isMsgContextKey("msg:abc123"), false);
});
test("isMsgContextKey_msgPrefixWrappingThreadKey_returnsFalse", () => {
// A thread key accidentally re-prefixed must not pass as a message key.
assert.equal(isMsgContextKey(`msg:thread:${"a".repeat(64)}`), false);
assert.equal(isMsgContextKey(`msg:thread:${EVENT_ID}`), false);
});
test("isThreadContextKey_shortId_returnsFalse", () => {
assert.equal(isThreadContextKey("thread:abc123"), false);
});
test("msgContextKey_output_roundTripsThroughValidator", () => {
assert.equal(isMsgContextKey(msgContextKey("event-id")), true);
assert.equal(isMsgContextKey(msgContextKey(EVENT_ID)), true);
});
@@ -18,17 +18,30 @@ export const MAX_CONTEXTS = 10_000;
export const MSG_PREFIX = "msg:";
export const THREAD_PREFIX = "thread:";
const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/;
export function maxReadAt(...markers: Array<number | null>): number | null {
return markers.reduce<number | null>((latest, marker) => {
if (marker === null) return latest;
if (latest === null || marker > latest) return marker;
return latest;
}, null);
}
export function msgContextKey(messageId: string): string {
return `${MSG_PREFIX}${messageId}`;
}
// A well-formed per-message context key: the msg: prefix with a non-empty id
// that does NOT itself start with thread: (guards against a thread key being
// mistaken for, or double-prefixed into, a message key).
// Spec-conformance helpers for well-known interoperable context keys. Runtime
// folding/eviction remains prefix-based so opaque client-local keys still work.
export function isThreadContextKey(value: string): value is `thread:${string}` {
if (!value.startsWith(THREAD_PREFIX)) return false;
return EVENT_ID_PATTERN.test(value.slice(THREAD_PREFIX.length));
}
export function isMsgContextKey(value: string): value is `msg:${string}` {
if (!value.startsWith(MSG_PREFIX)) return false;
const id = value.slice(MSG_PREFIX.length);
return id.length > 0 && !id.startsWith(THREAD_PREFIX);
return EVENT_ID_PATTERN.test(value.slice(MSG_PREFIX.length));
}
export function localReadStateKey(pubkey: string): string {
@@ -10,6 +10,8 @@ import {
READ_STATE_D_TAG_PREFIX,
READ_STATE_FETCH_LIMIT,
READ_STATE_HORIZON_SECONDS,
MSG_PREFIX,
THREAD_PREFIX,
isValidBlob,
isValidReadStateDTag,
sanitizeContexts,
@@ -593,23 +595,23 @@ export class ReadStateManager {
contexts[ctx] = ts;
}
// Evict oldest thread:* entries when approaching the MAX_CONTEXTS cap
// (10,000). Channel keys are never evicted — only thread read-state keys
// are eligible. This prevents silent blob rejection by isValidBlob().
// Evict oldest per-thread/per-message entries when approaching the
// MAX_CONTEXTS cap (10,000). Channel keys are never evicted. This prevents
// silent blob rejection by isValidBlob().
const EVICTION_THRESHOLD = 8_000;
const contextCount = Object.keys(contexts).length;
if (contextCount > EVICTION_THRESHOLD) {
const threadEntries: [string, number][] = [];
const detailEntries: [string, number][] = [];
for (const [key, ts] of Object.entries(contexts)) {
if (key.startsWith("thread:")) {
threadEntries.push([key, ts]);
if (key.startsWith(THREAD_PREFIX) || key.startsWith(MSG_PREFIX)) {
detailEntries.push([key, ts]);
}
}
// Sort oldest-first (lowest timestamp = oldest)
threadEntries.sort((a, b) => a[1] - b[1]);
detailEntries.sort((a, b) => a[1] - b[1]);
const toEvict = contextCount - EVICTION_THRESHOLD;
for (let i = 0; i < Math.min(toEvict, threadEntries.length); i++) {
delete contexts[threadEntries[i][0]];
for (let i = 0; i < Math.min(toEvict, detailEntries.length); i++) {
delete contexts[detailEntries[i][0]];
}
}
@@ -1,3 +1,5 @@
import { maxReadAt } from "@/features/channels/readState/readStateFormat";
export type ObservedUnreadEvent = {
id: string;
createdAt: number;
@@ -122,13 +124,13 @@ export function observedUnreadEventReadAt(
event: ObservedUnreadEvent,
channelReadAt: number | null,
getThreadOwnMarker: (rootId: string) => number | null,
getMessageOwnMarker: (messageId: string) => number | null = () => null,
): number | null {
if (event.rootId === null) return channelReadAt;
const markers = [channelReadAt, getMessageOwnMarker(event.id)];
const threadReadAt = getThreadOwnMarker(event.rootId);
if (threadReadAt === null) return channelReadAt;
if (channelReadAt === null || threadReadAt > channelReadAt) {
return threadReadAt;
if (event.rootId !== null) {
markers.push(getThreadOwnMarker(event.rootId));
}
return channelReadAt;
return maxReadAt(...markers);
}
@@ -118,6 +118,34 @@ test("observedUnreadEventReadAt_openedThreadReplyUsesThreadMarker", () => {
assert.equal(event.createdAt > readAt, false);
});
test("observedUnreadEventReadAt_openedMessageReplyUsesMessageMarker", () => {
const event = observed("reply", 500, "root-1");
const readAt = observedUnreadEventReadAt(
event,
300,
() => null,
(messageId) => (messageId === "reply" ? 500 : null),
);
assert.equal(readAt, 500);
assert.equal(event.createdAt > readAt, false);
});
test("observedUnreadEventReadAt_usesNewestAvailableMarker", () => {
const event = observed("reply", 500, "root-1");
assert.equal(
observedUnreadEventReadAt(
event,
300,
() => 450,
() => 400,
),
450,
);
});
test("observedUnreadEventReadAt_topLevelUsesChannelMarker", () => {
assert.equal(
observedUnreadEventReadAt(observed("top", 500), 300, () => 900),
@@ -156,12 +184,13 @@ function observed(
};
}
function readAtFor(channelMarker, threadMarkers) {
function readAtFor(channelMarker, threadMarkers, messageMarkers = new Map()) {
return (event) =>
observedUnreadEventReadAt(
event,
channelMarker,
(rootId) => threadMarkers.get(rootId) ?? null,
(messageId) => messageMarkers.get(messageId) ?? null,
);
}
@@ -211,6 +240,32 @@ test("sidebarPipeline_openThreadClearsOnlyUnreadThreadContribution", () => {
);
});
test("sidebarPipeline_openedReplyMessageMarkerClearsDelayedReplay", () => {
const channelId = "chan";
const rootId = "root-1";
const reply = observed("reply", 500, rootId);
const observedByChannel = new Map();
recordObservedUnreadEvent(observedByChannel, channelId, reply, 20);
const beforeOpenReadAt = readAtFor(300, new Map(), new Map());
assert.equal(
countUnreadObservedEvents(
observedByChannel.get(channelId),
beforeOpenReadAt,
),
1,
);
const afterOpenReadAt = readAtFor(300, new Map(), new Map([["reply", 500]]));
assert.equal(
countUnreadObservedEvents(
observedByChannel.get(channelId),
afterOpenReadAt,
),
0,
);
});
test("latestObservedEvent_latestThreadReadDoesNotImplyChannelClear", () => {
const events = new Map([
["older", observed("older", 400, "root-older")],
@@ -826,8 +826,11 @@ export function useUnreadChannels(
);
const channelReadAt = getEffectiveTimestamp(channel.id);
const readAtForObservedEvent = (event: ObservedUnreadEvent) =>
observedUnreadEventReadAt(event, channelReadAt, (rootId) =>
getOwnTimestamp(`thread:${rootId}`),
observedUnreadEventReadAt(
event,
channelReadAt,
(rootId) => getOwnTimestamp(`thread:${rootId}`),
(messageId) => getOwnTimestamp(`msg:${messageId}`),
);
const unreadCount = countUnreadObservedEvents(
@@ -125,6 +125,7 @@ export function HomeView({
const {
getChannelReadAt,
getThreadReadAt,
getMessageReadAt,
feedItemState,
markChannelRead,
markThreadRead,
@@ -202,6 +203,7 @@ export function HomeView({
items: inboxItems,
getChannelReadAt,
getThreadReadAt,
getMessageReadAt,
readStateVersion,
localDoneSet: doneSet,
localUnreadSet: unreadSet,
@@ -5,6 +5,7 @@ import {
getGroupedChannelReadTimestamp,
getGroupedInboxItemIds,
hasGroupedUnreadOverride,
resolveInboxItemReadAt,
} from "./useHomeInboxReadState.ts";
const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
@@ -123,3 +124,67 @@ test("grouped unread override matches any item represented by the row", () => {
false,
);
});
test("thread inbox row without a marker ignores local done fallback", () => {
const replyItem = feedItem({
id: "reply-event",
createdAt: 200,
tags: [
["h", CHANNEL_ID],
["e", "root-event", "", "root"],
["e", "parent-event", "", "reply"],
],
});
assert.equal(
resolveInboxItemReadAt(inboxItem([replyItem]), {
getChannelReadAt: () => 100,
getThreadReadAt: () => null,
getMessageReadAt: () => null,
}),
null,
);
});
test("thread inbox row read state includes per-message marker", () => {
const replyItem = feedItem({
id: "reply-event",
createdAt: 200,
tags: [
["h", CHANNEL_ID],
["e", "root-event", "", "root"],
["e", "parent-event", "", "reply"],
],
});
assert.equal(
resolveInboxItemReadAt(inboxItem([replyItem]), {
getChannelReadAt: () => 100,
getThreadReadAt: () => null,
getMessageReadAt: (messageId) =>
messageId === "reply-event" ? 200 : null,
}),
200,
);
});
test("thread inbox row read state uses newest thread or message marker", () => {
const replyItem = feedItem({
id: "reply-event",
createdAt: 200,
tags: [
["h", CHANNEL_ID],
["e", "root-event", "", "root"],
["e", "parent-event", "", "reply"],
],
});
assert.equal(
resolveInboxItemReadAt(inboxItem([replyItem]), {
getChannelReadAt: () => 100,
getThreadReadAt: () => 250,
getMessageReadAt: () => 200,
}),
250,
);
});
@@ -1,6 +1,7 @@
import * as React from "react";
import type { InboxItem } from "@/features/home/lib/inbox";
import { maxReadAt } from "@/features/channels/readState/readStateFormat";
import {
getThreadReference,
isThreadReply,
@@ -13,6 +14,8 @@ type UseHomeInboxReadStateOptions = {
getChannelReadAt: (channelId: string) => number | null;
/** NIP-RS read marker resolver for thread-backed items (unix seconds, or null when unknown). */
getThreadReadAt: (rootId: string, channelId?: string | null) => number | null;
/** NIP-RS read marker resolver for per-message items (unix seconds, or null when unknown). */
getMessageReadAt?: (messageId: string) => number | null;
/** Invalidation signal for the channel-marker projection. */
readStateVersion: number;
/** Local fallback "done" set (used only for items with no channelId). */
@@ -78,6 +81,29 @@ export function hasGroupedUnreadOverride(
return getGroupedInboxItemIds(item).some((id) => localUnreadSet.has(id));
}
export function resolveInboxItemReadAt(
item: InboxItem,
options: {
getChannelReadAt: (channelId: string) => number | null;
getMessageReadAt?: (messageId: string) => number | null;
getThreadReadAt: (
rootId: string,
channelId?: string | null,
) => number | null;
},
): number | null {
const channelId = item.item.channelId;
const threadRootId = getInboxThreadRootId(item);
if (threadRootId) {
return maxReadAt(
options.getThreadReadAt(threadRootId, channelId),
options.getMessageReadAt?.(item.item.id) ?? null,
);
}
return channelId ? options.getChannelReadAt(channelId) : null;
}
/**
* Projects Home inbox read-state from the shared NIP-RS read marker, with
* the local `useFeedItemState` done-set as a fallback for items that don't
@@ -92,6 +118,7 @@ export function useHomeInboxReadState({
items,
getChannelReadAt,
getThreadReadAt,
getMessageReadAt,
readStateVersion,
localDoneSet,
localUnreadSet = EMPTY_ITEM_SET,
@@ -115,23 +142,23 @@ export function useHomeInboxReadState({
continue;
}
const channelId = item.item.channelId;
const threadRootId = getInboxThreadRootId(item);
if (threadRootId) {
const readAt = getThreadReadAt(threadRootId, channelId);
if (readAt !== null && item.latestActivityAt <= readAt) {
const readAt = resolveInboxItemReadAt(item, {
getChannelReadAt,
getMessageReadAt,
getThreadReadAt,
});
if (readAt !== null) {
if (item.latestActivityAt <= readAt) {
result.add(item.id);
}
continue;
}
if (channelId) {
const readAt = getChannelReadAt(channelId);
if (readAt !== null && item.latestActivityAt <= readAt) {
result.add(item.id);
}
if (threadRootId !== null || item.item.channelId) {
continue;
}
if (localDoneSet.has(item.id)) {
result.add(item.id);
}
@@ -140,6 +167,7 @@ export function useHomeInboxReadState({
}, [
getChannelReadAt,
getThreadReadAt,
getMessageReadAt,
items,
localDoneSet,
localUnreadSet,
@@ -3,6 +3,8 @@ import test from "node:test";
import {
buildHomeBadgeFeedItems,
isHomeBadgeFeedItemUnread,
resolveHomeBadgeFeedItemReadAt,
shouldCountTowardHomeBadgeSubtotal,
} from "./lib/homeBadge.ts";
@@ -110,6 +112,50 @@ test("home badge subtotal still counts non-DM thread-only rows", () => {
);
});
test("home badge thread reply read state includes per-message markers", () => {
const item = {
...feedItem("reply-1"),
channelId: "stream-channel",
createdAt: 500,
tags: ROOT_TAGS,
};
const readAt = resolveHomeBadgeFeedItemReadAt(item, {
getChannelReadAt: () => 300,
getThreadReadAt: () => null,
getMessageReadAt: (messageId) => (messageId === "reply-1" ? 500 : null),
});
assert.equal(readAt, 500);
assert.equal(
isHomeBadgeFeedItemUnread(item, {
getChannelReadAt: () => 300,
getThreadReadAt: () => null,
getMessageReadAt: () => 500,
seenFeedIdSet: new Set(),
}),
false,
);
});
test("home badge thread reply read state uses newest channel thread or message marker", () => {
const item = {
...feedItem("reply-1"),
channelId: "stream-channel",
createdAt: 500,
tags: ROOT_TAGS,
};
assert.equal(
resolveHomeBadgeFeedItemReadAt(item, {
getChannelReadAt: () => 300,
getThreadReadAt: () => 550,
getMessageReadAt: () => 400,
}),
550,
);
});
test("home badge subtotal counts locally unread rows before channel exclusion", () => {
const highPriorityChannelIds = new Set(["stream-channel"]);
+14 -25
View File
@@ -1,10 +1,6 @@
import * as React from "react";
import { useHomeFeedQuery } from "@/features/home/hooks";
import {
getThreadReference,
isThreadReply,
} from "@/features/messages/lib/threading";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
@@ -30,6 +26,7 @@ import {
} from "./use-feed-desktop-notifications";
import {
buildHomeBadgeFeedItems,
isHomeBadgeFeedItemUnread,
shouldCountTowardHomeBadgeSubtotal,
} from "./lib/homeBadge";
@@ -374,6 +371,11 @@ export function useHomeFeedNotificationState(
rootId: string,
channelId?: string | null,
) => number | null = () => null,
// Per-message read marker lookup, shared with channel thread badges. When
// provided, a thread activity row is treated as read if the specific reply
// has been revealed in-channel, even if the aggregate `thread:<root>` marker
// has not been advanced by opening Home.
getMessageReadAt: (messageId: string) => number | null = () => null,
) {
useFeedDesktopNotifications(
feed,
@@ -442,27 +444,13 @@ export function useHomeFeedNotificationState(
) {
continue;
}
const threadRootId = isThreadReply(item.tags)
? getThreadReference(item.tags).rootId
: null;
let isUnread: boolean;
if (isLocallyUnread) {
isUnread = true;
} else if (threadRootId) {
const readAt = getThreadReadAt(threadRootId, item.channelId);
isUnread =
readAt !== null
? item.createdAt > readAt
: !seenFeedIdSet.has(item.id);
} else if (item.channelId) {
const readAt = getChannelReadAt(item.channelId);
isUnread =
readAt !== null
? item.createdAt > readAt
: !seenFeedIdSet.has(item.id);
} else {
isUnread = !seenFeedIdSet.has(item.id);
}
const isUnread = isHomeBadgeFeedItemUnread(item, {
getChannelReadAt,
getMessageReadAt,
getThreadReadAt,
isLocallyUnread,
seenFeedIdSet,
});
if (!isUnread) continue;
total++;
if (
@@ -482,6 +470,7 @@ export function useHomeFeedNotificationState(
}, [
currentFeedItems,
getChannelReadAt,
getMessageReadAt,
getThreadReadAt,
highPriorityChannelIds,
isHomeActive,
@@ -1,7 +1,9 @@
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { maxReadAt } from "@/features/channels/readState/readStateFormat";
import {
getThreadReference,
isBroadcastReply,
isThreadReply,
} from "@/features/messages/lib/threading";
function dedupeFeedItemsById(items: readonly FeedItem[]): FeedItem[] {
@@ -56,3 +58,60 @@ export function shouldCountTowardHomeBadgeSubtotal(
threadRef.parentId !== null && !isBroadcastReply(item.tags);
return isThreadedReply && item.channelType !== "dm";
}
type FeedItemReadState = Pick<
FeedItem,
"channelId" | "createdAt" | "id" | "tags"
>;
export function feedItemThreadRootId(item: Pick<FeedItem, "tags">) {
return isThreadReply(item.tags) ? getThreadReference(item.tags).rootId : null;
}
export function isHomeBadgeFeedItemUnread(
item: FeedItemReadState,
options: {
getChannelReadAt: (channelId: string) => number | null;
getMessageReadAt?: (messageId: string) => number | null;
getThreadReadAt: (
rootId: string,
channelId?: string | null,
) => number | null;
isLocallyUnread?: boolean;
seenFeedIdSet: ReadonlySet<string>;
},
): boolean {
if (options.isLocallyUnread) {
return true;
}
const readAt = resolveHomeBadgeFeedItemReadAt(item, options);
return readAt !== null
? item.createdAt > readAt
: !options.seenFeedIdSet.has(item.id);
}
export function resolveHomeBadgeFeedItemReadAt(
item: FeedItemReadState,
options: {
getChannelReadAt: (channelId: string) => number | null;
getMessageReadAt?: (messageId: string) => number | null;
getThreadReadAt: (
rootId: string,
channelId?: string | null,
) => number | null;
},
): number | null {
const threadRootId = feedItemThreadRootId(item);
const markers: Array<number | null> = [];
if (item.channelId && !threadRootId) {
markers.push(options.getChannelReadAt(item.channelId));
}
if (threadRootId) {
markers.push(options.getThreadReadAt(threadRootId, item.channelId));
markers.push(options.getMessageReadAt?.(item.id) ?? null);
}
return maxReadAt(...markers);
}
+91 -59
View File
@@ -25,7 +25,7 @@ This NIP defines a minimal, privacy-preserving protocol for propagating read sta
## Non-Goals
This NIP does not define a durable log of all read messages — blobs are best-effort recent activity hints bounded by a time horizon.
This NIP does not define cross-client interoperability on context ID format — context identifiers are opaque by default and meaningful only within a single client family, except for OPTIONAL well-known schemes defined in this NIP (currently only `thread:<root-event-id>`, defined under Thread Read Contexts), which are provided for cross-client thread-read interoperability.
This NIP does not define cross-client interoperability on context ID format — context identifiers are opaque by default and meaningful only within a single client family, except for OPTIONAL well-known schemes defined in this NIP (`thread:<root-event-id>` and `msg:<event-id>`, defined under Read Context Schemes), which are provided for cross-client thread/message-read interoperability.
This NIP does not define mark-as-unread — the merge rule is monotonic by design.
This NIP does not guarantee ordering of read events across devices.
This NIP does not require relay-side logic.
@@ -112,31 +112,42 @@ After decryption, clients MUST apply the following validation rules:
Context identifier format is not prescribed by this NIP. Clients choose identifiers appropriate to their context type (e.g., a NIP-28 channel event ID, a NIP-29 group address, a pubkey for DMs). Interoperability between different client implementations on context ID conventions is outside the scope of this NIP.
#### Thread Read Contexts (Optional)
#### Read Context Schemes (Optional)
This subsection defines an OPTIONAL well-known context scheme for tracking read
state of threads (reply chains) independently from the channel they belong to.
It is a pure interpretation layer over the flat `contexts` map — it introduces
no new fields, no nesting, and no change to the merge rule, event structure,
validation, or fetching. The schema version `v` remains `1`. Clients that do
not implement this subsection remain fully interoperable (see Backwards
Compatibility below).
This subsection defines OPTIONAL well-known context schemes for tracking read
state below a channel: thread-level read state for reply chains and per-message
read state for individual events. These schemes are pure interpretation layers
over the flat `contexts` map — they introduce no new fields, no nesting, and no
change to the merge rule, event structure, validation, or fetching. The schema
version `v` remains `1`. Clients that do not implement this subsection remain
fully interoperable (see Backwards Compatibility below).
A client implementing this scheme MUST use the context key `thread:<root-event-id>`
for a thread, where `<root-event-id>` is the 64-character lowercase hex event ID
of the thread's root event. A bare channel identifier (e.g., the NIP-28 channel
event ID) remains the channel context, exactly as before — this is grandfathered
existing behavior and is unchanged.
A client implementing thread read contexts MUST use the context key
`thread:<root-event-id>` for a thread, where `<root-event-id>` is the
64-character lowercase hex event ID of the thread's root event.
A client implementing per-message read contexts MUST use the context key
`msg:<event-id>` for a message, where `<event-id>` is the 64-character lowercase
hex event ID of that message. Per-message contexts are useful for clients that
reveal only part of a thread (for example, a thread panel with collapsed nested
branches): a client can mark the revealed reply events read without marking the
whole thread read.
A bare channel identifier (e.g., the NIP-28 channel event ID) remains the channel
context, exactly as before — this is grandfathered existing behavior and is
unchanged.
Keys beginning with `thread:` whose remainder does not match `^[0-9a-f]{64}$`
MUST be treated as ordinary opaque contexts, not as thread contexts. This
protects an existing client family that may already use a `thread:`-prefixed
convention from being misinterpreted under this scheme.
MUST be treated as ordinary opaque contexts, not as thread contexts. Keys
beginning with `msg:` whose remainder does not match `^[0-9a-f]{64}$` MUST be
treated as ordinary opaque contexts, not as per-message contexts. This protects
an existing client family that may already use one of these prefixes from being
misinterpreted under this scheme.
The relationship between a thread and its parent channel is DERIVED from the
Nostr event graph at evaluation time (the root event's channel reference, e.g.
its `h` tag) and MUST NOT be serialized into the blob. The blob remains a flat
`{<context-id>: <unix-timestamp>}` map.
The relationship between a thread or message and its parent channel is DERIVED
from the Nostr event graph at evaluation time (the root/message event's channel
reference, e.g. its `h` tag) and MUST NOT be serialized into the blob. The blob
remains a flat `{<context-id>: <unix-timestamp>}` map.
##### Hierarchical Frontier Rule
@@ -154,46 +165,65 @@ value. For a thread, the parent is its channel:
effective(thread:<root>) = max(merged[thread:<root>], merged[<channelId>])
```
A thread is unread iff `latestReplyAt > effective(thread:<root>)`, where
`latestReplyAt` is the `created_at` of the thread's most recent reply. Because
this rule is a `max()` over the same grow-only registers defined in the Merge
Rule, it remains a monotone state-based CvRDT — no change to the merge rule is
required. Marking a channel read clears unread state on any thread whose most
recent reply predates the channel frontier, since each thread's effective
frontier inherits the channel term; threads with replies newer than the channel
frontier remain unread until the thread itself is read.
For a per-message context, the parent is also its channel (not its thread or
parent message):
If the thread root event (and therefore its parent channel) cannot be resolved
from the event graph, `effective(thread:<root>)` degrades to
`merged[thread:<root>]` alone.
```
effective(msg:<event-id>) = max(merged[msg:<event-id>], merged[<channelId>])
```
When a surface evaluates a reply inside a known thread, it MAY additionally fold
in the thread frontier:
```
effective(reply) = max(effective(msg:<reply-id>), effective(thread:<root>))
```
A thread is unread iff at least one reply is unread. A reply is unread iff
`reply.created_at > effective(reply)` for clients that implement per-message
contexts; clients that only implement thread contexts MAY instead use
`latestReplyAt > effective(thread:<root>)`. Because both rules are `max()` over
the same grow-only registers defined in the Merge Rule, they remain monotone
state-based CvRDT interpretations — no change to the merge rule is required.
Marking a channel read clears unread state on any thread/message whose relevant
event predates the channel frontier, since each child context inherits the
channel term; replies newer than the channel frontier remain unread until their
own message marker or thread marker is advanced.
If the thread root or message event (and therefore its parent channel) cannot be
resolved from the event graph, `effective(thread:<root>)` or
`effective(msg:<event-id>)` degrades to its own merged value alone.
##### Write Discipline
Marking a thread read MUST advance only its own `thread:<root>` context. It MUST
NOT advance the parent channel context. Otherwise, reading a single thread would
silently mark later top-level channel messages as read. Marking a channel read
advances only the channel context (which the hierarchical rule then propagates
to threads at read time). The channel context SHOULD advance to the maximum
`created_at` across the channel's top-level messages only, NOT including thread
replies. This keeps a thread unread when its `latestReplyAt` exceeds the newest
top-level message: opening a channel clears the channel timeline but leaves its
threads' unread state intact until each thread is read.
Marking a thread read MUST advance only its own `thread:<root>` context.
Marking an individual message read MUST advance only its own `msg:<event-id>`
context. Neither operation may advance the parent channel context. Otherwise,
reading a single thread or reply would silently mark later top-level channel
messages as read. Marking a channel read advances only the channel context
(which the hierarchical rule then propagates to child contexts at read time).
The channel context SHOULD advance to the maximum `created_at` across the
channel's top-level messages only, NOT including thread replies. This keeps a
thread unread when its replies exceed the newest top-level message: opening a
channel clears the channel timeline but leaves its threads/replies unread until
each thread or message is read.
##### Eviction
A `thread:<root>` entry whose value is `<= effective(parent)` is semantically
inert: the parent (channel) frontier already covers it, so its presence or
absence does not change the result of `effective(thread:<root>)`. Clients MAY
drop such dominated entries before publishing to bound blob size, consistent
with the Debounce and Pruning section.
A `thread:<root>` or `msg:<event-id>` entry whose value is
`<= effective(parent)` is semantically inert: the parent (channel) frontier
already covers it, so its presence or absence does not change the result of the
child context's effective frontier. Clients MAY drop such dominated entries
before publishing to bound blob size, consistent with the Debounce and Pruning
section.
This eviction is bounded best-effort, NOT a guaranteed garbage-collection or
per-key tombstone mechanism. Because the merge rule re-merges any context
present in another instance's blob (see Merge Rule and Live Subscription and
Convergence), a dropped `thread:<root>` key MAY be re-added by a peer instance
that still carries it. A dropped key stays gone only once it is dominated on
every instance or has aged past the time horizon everywhere. Clients SHOULD
treat thread-context eviction as a companion to the existing time-horizon
Convergence), a dropped `thread:<root>` or `msg:<event-id>` key MAY be
re-added by a peer instance that still carries it. A dropped key stays gone only
once it is dominated on every instance or has aged past the time horizon
everywhere. Clients SHOULD treat child-context eviction as a companion to the existing time-horizon
pruning, not as a standalone guarantee that the context count or blob size will
shrink immediately.
@@ -208,17 +238,18 @@ effect.
##### Backwards Compatibility
A client that does not implement this scheme treats a `thread:<root>` key as an
ordinary opaque context. It carries the key through the merge unchanged (already
required by the Merge Rule) and simply computes no thread-level unread state.
There is no validation change and no interop break: an unaware client and an
aware client can share a blob and both produce correct results for the contexts
they understand.
A client that does not implement this scheme treats `thread:<root>` and
`msg:<event-id>` keys as ordinary opaque contexts. It carries the keys through
the merge unchanged (already required by the Merge Rule) and simply computes no
thread/message-level unread state. There is no validation change and no interop
break: an unaware client and an aware client can share a blob and both produce
correct results for the contexts they understand.
##### Example
Two blobs merge to the following effective state for a thread `X` and its parent
channel:
Two blobs merge to the following effective state for a symbolically named thread
`X` and its parent channel (real `thread:` keys use 64-character lowercase hex
event IDs):
```json
{
@@ -238,7 +269,8 @@ A thread reply with `created_at = 140` is `<= 150`, so it reads as **read** (the
channel frontier already covers it). A reply with `created_at = 160` is `> 150`,
so the thread reads as **unread**. The thread's own entry (`100`) is dominated by
the channel frontier (`150`) and is therefore inert — a client MAY evict it
before publishing.
before publishing. The same rule applies to `msg:<event-id>` entries for
individual replies.
#### Timestamp Accuracy