diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b4b2ef77b1..afa5f3ea89 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -405,6 +405,7 @@ export function AppShell() { feedItemState.unreadSet, threadActivityFeedItems, getThreadReadAt, + getMessageReadAt, ); const dueReminderBadge = useDueReminderBadgeCount( diff --git a/desktop/src/features/channels/readState/readStateFormat.test.mjs b/desktop/src/features/channels/readState/readStateFormat.test.mjs index 248692a91c..417e40b02d 100644 --- a/desktop/src/features/channels/readState/readStateFormat.test.mjs +++ b/desktop/src/features/channels/readState/readStateFormat.test.mjs @@ -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); }); diff --git a/desktop/src/features/channels/readState/readStateFormat.ts b/desktop/src/features/channels/readState/readStateFormat.ts index 4a37d18330..74ba002c9c 100644 --- a/desktop/src/features/channels/readState/readStateFormat.ts +++ b/desktop/src/features/channels/readState/readStateFormat.ts @@ -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 { + return markers.reduce((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 { diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index ce93b0b3d0..aa613c82ac 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -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]]; } } diff --git a/desktop/src/features/channels/unreadChannelCounts.ts b/desktop/src/features/channels/unreadChannelCounts.ts index 2a4239cfe6..cb33c62975 100644 --- a/desktop/src/features/channels/unreadChannelCounts.ts +++ b/desktop/src/features/channels/unreadChannelCounts.ts @@ -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); } diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index 521414e8ae..266bca2dfb 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -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")], diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 8cf6808d9e..f65f4bd822 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -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( diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index d9a735be92..7d8c53395c 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -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, diff --git a/desktop/src/features/home/useHomeInboxReadState.test.mjs b/desktop/src/features/home/useHomeInboxReadState.test.mjs index 426188c0af..a6f4c90bcc 100644 --- a/desktop/src/features/home/useHomeInboxReadState.test.mjs +++ b/desktop/src/features/home/useHomeInboxReadState.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/features/home/useHomeInboxReadState.ts b/desktop/src/features/home/useHomeInboxReadState.ts index b37cc31381..167c87337a 100644 --- a/desktop/src/features/home/useHomeInboxReadState.ts +++ b/desktop/src/features/home/useHomeInboxReadState.ts @@ -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, diff --git a/desktop/src/features/notifications/hooks.test.mjs b/desktop/src/features/notifications/hooks.test.mjs index 2fa5711227..e5b90f3acc 100644 --- a/desktop/src/features/notifications/hooks.test.mjs +++ b/desktop/src/features/notifications/hooks.test.mjs @@ -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"]); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 17a958c2b1..e039d0eaeb 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -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:` 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, diff --git a/desktop/src/features/notifications/lib/homeBadge.ts b/desktop/src/features/notifications/lib/homeBadge.ts index 3355ec749b..deac9b73a5 100644 --- a/desktop/src/features/notifications/lib/homeBadge.ts +++ b/desktop/src/features/notifications/lib/homeBadge.ts @@ -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) { + 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; + }, +): 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 = []; + + 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); +} diff --git a/docs/nips/NIP-RS.md b/docs/nips/NIP-RS.md index 9dbf714ebc..1ca30ea08f 100644 --- a/docs/nips/NIP-RS.md +++ b/docs/nips/NIP-RS.md @@ -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:`, 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:` and `msg:`, 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:` -for a thread, where `` 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:` for a thread, where `` 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:` for a message, where `` 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 -`{: }` 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 `{: }` map. ##### Hierarchical Frontier Rule @@ -154,46 +165,65 @@ value. For a thread, the parent is its channel: effective(thread:) = max(merged[thread:], merged[]) ``` -A thread is unread iff `latestReplyAt > effective(thread:)`, 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): + +``` +effective(msg:) = max(merged[msg:], merged[]) +``` + +When a surface evaluates a reply inside a known thread, it MAY additionally fold +in the thread frontier: + +``` +effective(reply) = max(effective(msg:), effective(thread:)) +``` -If the thread root event (and therefore its parent channel) cannot be resolved -from the event graph, `effective(thread:)` degrades to -`merged[thread:]` alone. +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:)`. 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:)` or +`effective(msg:)` degrades to its own merged value alone. ##### Write Discipline -Marking a thread read MUST advance only its own `thread:` 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:` context. +Marking an individual message read MUST advance only its own `msg:` +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:` 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:)`. Clients MAY -drop such dominated entries before publishing to bound blob size, consistent -with the Debounce and Pruning section. +A `thread:` or `msg:` 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:` 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:` or `msg:` 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:` 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:` and +`msg:` 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:` entries for +individual replies. #### Timestamp Accuracy