mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user