Preserve thread notifications when opening channels

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-17 19:48:06 +01:00
parent 076081bfc6
commit 146d7b1f45
13 changed files with 192 additions and 70 deletions
@@ -28,3 +28,18 @@ test("channel activity read state honors a channel marker without a message mark
300,
);
});
test("channel activity read state ignores the passive timeline marker", () => {
const markers = new Map([
["general", 100],
["channel-timeline:general", 500],
]);
assert.equal(
resolveChannelActivityFeedItemReadAt(
{ id: "reply-general", channelId: "general" },
(contextId) => markers.get(contextId) ?? null,
),
100,
);
});
@@ -54,12 +54,12 @@ export function useChannelActivityProjection({
const threadReadAt = getOwnReadAt(`thread:${rootId}`);
if (!channelId) return threadReadAt;
const channelReadAt = getChannelReadAt(channelId);
const channelReadAt = getOwnReadAt(channelId);
if (threadReadAt === null) return channelReadAt;
if (channelReadAt === null) return threadReadAt;
return Math.max(threadReadAt, channelReadAt);
},
[getChannelReadAt, getOwnReadAt],
[getOwnReadAt],
);
const markThreadRead = React.useCallback(
(rootId: string, timestamp: number) =>
@@ -366,16 +366,18 @@ export function pruneObservedUnreadByMarkers(
eventsByChannel: Map<string, Map<string, ObservedUnreadEvent>>,
latestByChannel: Map<string, number>,
getChannelReadAt: (channelId: string) => number | null,
getChannelTimelineReadAt: (channelId: string) => number | null,
getOwnTimestamp: (contextId: string) => number | null,
): boolean {
let changed = false;
for (const [channelId, eventsById] of eventsByChannel) {
const channelReadAt = getChannelReadAt(channelId);
const channelTimelineReadAt = getChannelTimelineReadAt(channelId);
const toDelete: string[] = [];
for (const event of eventsById.values()) {
const readAt = observedUnreadEventReadAt(
event,
channelReadAt,
event.rootId === null ? channelTimelineReadAt : channelReadAt,
(rootId) => getOwnTimestamp(`thread:${rootId}`),
(messageId) => getOwnTimestamp(`msg:${messageId}`),
);
@@ -208,7 +208,7 @@ export function makeObservedEvent({
return {
id,
createdAt,
rootId: rootId ?? `root-${id}`,
rootId: rootId === undefined ? `root-${id}` : rootId,
highPriority,
countsTowardBadge,
countsTowardAppBadge,
@@ -254,6 +254,7 @@ export async function mountHook(props, refs) {
isReady,
readStateVersion,
getTs,
getTimelineTs,
getOwn,
onPruned,
}) {
@@ -263,6 +264,7 @@ export async function mountHook(props, refs) {
isReady,
readStateVersion,
getTs,
getTimelineTs ?? getTs,
getOwn,
refs.eventsRef,
refs.latestRef,
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
channelTimelineContextKey,
isMsgContextKey,
isThreadContextKey,
maxReadAt,
@@ -22,6 +23,13 @@ test("msgContextKey_prefixesId_returnsMsgKey", () => {
assert.equal(msgContextKey(EVENT_ID), `msg:${EVENT_ID}`);
});
test("channelTimelineContextKey_separatesPassiveTimelineReads", () => {
assert.equal(
channelTimelineContextKey("channel-1"),
"channel-timeline:channel-1",
);
});
test("isMsgContextKey_wellFormedKey_returnsTrue", () => {
assert.equal(isMsgContextKey(`msg:${EVENT_ID}`), true);
});
@@ -34,6 +34,7 @@ export const READ_STATE_MAX_SLOTS = 8;
// two key families apart.
export const MSG_PREFIX = "msg:";
export const THREAD_PREFIX = "thread:";
export const CHANNEL_TIMELINE_PREFIX = "channel-timeline:";
const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/;
@@ -49,6 +50,18 @@ export function msgContextKey(messageId: string): string {
return `${MSG_PREFIX}${messageId}`;
}
/**
* Read frontier for a channel's top-level timeline only.
*
* Passive channel viewing advances this context instead of the bare channel
* context. Thread and message contexts inherit the bare channel frontier, so
* this separation lets the visible timeline become read without acknowledging
* unopened thread activity that happens to be older than a later root message.
*/
export function channelTimelineContextKey(channelId: string): string {
return `${CHANNEL_TIMELINE_PREFIX}${channelId}`;
}
// 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}` {
@@ -22,6 +22,7 @@ import {
computeChannelUnreadMarker,
computeThreadUnreadMarker,
} from "@/features/messages/lib/unreadMarker";
import { isThreadReply } from "@/features/messages/lib/threading";
import type { TimelineMessage } from "@/features/messages/types";
import { isConversationalUnreadKind } from "@/shared/constants/kinds";
@@ -392,7 +393,10 @@ export function useChannelUnreadState({
if (!message) return false;
const { firstUnreadReplyId } = computeThreadUnreadMarker(
[message],
getMessageReadAt,
(id) =>
activeChannelId && !isThreadReply(message.tags ?? [])
? getChannelReadAt(activeChannelId)
: getMessageReadAt(id),
currentPubkey,
isMsgForcedUnread,
);
@@ -400,6 +404,8 @@ export function useChannelUnreadState({
},
[
messageById,
activeChannelId,
getChannelReadAt,
getMessageReadAt,
currentPubkey,
isMsgForcedUnread,
@@ -14,9 +14,11 @@ import {
import {
addThreadActivityItems,
channelCatchUpEventKinds,
} from "./useUnreadChannels.ts";
import {
resolveChannelReadMarker,
resolveObservedUnreadRootId,
} from "./useUnreadChannels.ts";
} from "./unreadReadState.ts";
import {
isChannelUnreadTriggerKind,
trackSeenEvent,
@@ -0,0 +1,37 @@
import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
function parseTimestamp(value: string | null | undefined) {
if (!value) return null;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
function toUnixSeconds(isoOrMs: string | null | undefined): number | null {
const ms = parseTimestamp(isoOrMs);
return ms === null ? null : Math.floor(ms / 1_000);
}
// Fold the caller's timeline position with the newest event observed live so
// explicit reads also cover events that arrived ahead of channel metadata.
export function resolveChannelReadMarker(
callerReadAt: string | null | undefined,
observedLatest: number | undefined,
): { markAt: number | null; clearObserved: boolean } {
const callerUnix = toUnixSeconds(callerReadAt);
const markAt = Math.max(callerUnix ?? 0, observedLatest ?? 0) || null;
return {
markAt,
clearObserved:
markAt !== null &&
observedLatest !== undefined &&
observedLatest <= markAt,
};
}
export function resolveObservedUnreadRootId(tags: string[][]): string | null {
return isBroadcastReply(tags) ? null : getThreadReference(tags).rootId;
}
@@ -42,6 +42,7 @@ const DEFAULT_PROPS = {
isReady: true,
readStateVersion: 0,
getTs: () => null,
getTimelineTs: () => null,
getOwn: () => null,
};
@@ -232,6 +233,16 @@ test("marker prune: thread and channel markers prune covered events; sibling cha
createdAt: NOW_S + 10,
rootId: "root-sv",
});
const evtTimeline = makeObservedEvent({
id: "evt-timeline",
createdAt: NOW_S - 10,
rootId: null,
});
const evtThreadBeforeTimeline = makeObservedEvent({
id: "evt-thread-before-timeline",
createdAt: NOW_S - 10,
rootId: "root-before-timeline",
});
const stored = new Map();
const ch1 = new Map();
@@ -245,6 +256,10 @@ test("marker prune: thread and channel markers prune covered events; sibling cha
const chB = new Map();
chB.set("evt-survivor", evtSurvivor);
stored.set("channel-b", chB);
const chTimeline = new Map();
chTimeline.set("evt-timeline", evtTimeline);
chTimeline.set("evt-thread-before-timeline", evtThreadBeforeTimeline);
stored.set("channel-timeline", chTimeline);
writeObservedUnreadToStorage(PUBKEY, RELAY, stored);
let pruneCount = 0;
@@ -259,6 +274,8 @@ test("marker prune: thread and channel markers prune covered events; sibling cha
isReady: true,
readStateVersion: 1,
getTs: (channelId) => (channelId === "channel-a" ? NOW_S - 5 : null),
getTimelineTs: (channelId) =>
channelId === "channel-timeline" ? NOW_S - 5 : null,
getOwn: (ctx) => (ctx === "thread:root-a" ? NOW_S - 5 : null),
onPruned: () => {
pruneCount += 1;
@@ -281,6 +298,16 @@ test("marker prune: thread and channel markers prune covered events; sibling cha
);
// Sibling: channel-b unaffected.
assert.ok(eventsRef.current.has("channel-b"), "channel-b must survive");
assert.ok(
!eventsRef.current.get("channel-timeline")?.has("evt-timeline"),
"the top-level event must be pruned by the timeline marker",
);
assert.ok(
eventsRef.current
.get("channel-timeline")
?.has("evt-thread-before-timeline"),
"the timeline marker must not prune older thread activity",
);
assert.equal(pruneCount, 1, "onPruned must fire exactly once");
await harness.unmount();
@@ -59,7 +59,8 @@ export function useObservedUnreadPersistence(
normalizedRelayUrl: string,
isReadStateReady: boolean,
readStateVersion: number,
getEffectiveTimestamp: (channelId: string) => number | null,
getChannelReadAt: (channelId: string) => number | null,
getChannelTimelineReadAt: (channelId: string) => number | null,
getOwnTimestamp: (contextId: string) => number | null,
observedUnreadEventsByChannelRef: React.MutableRefObject<
Map<string, Map<string, ObservedUnreadEvent>>
@@ -133,7 +134,8 @@ export function useObservedUnreadPersistence(
const changed = pruneObservedUnreadByMarkers(
observedUnreadEventsByChannelRef.current,
latestByChannelRef.current,
getEffectiveTimestamp,
getChannelReadAt,
getChannelTimelineReadAt,
getOwnTimestamp,
);
if (changed) {
@@ -15,7 +15,15 @@ import {
recordObservedUnreadEvent,
type ObservedUnreadEvent,
} from "@/features/channels/unreadChannelCounts";
import {
channelTimelineContextKey,
maxReadAt,
} from "@/features/channels/readState/readStateFormat";
import { useReadState } from "@/features/channels/readState/useReadState";
import {
resolveChannelReadMarker,
resolveObservedUnreadRootId,
} from "@/features/channels/unreadReadState";
import { makeRootIdStore } from "@/features/channels/unreadRootIdStore";
import {
forcedUnreadStore,
@@ -84,48 +92,6 @@ const authoredStore = makeRootIdStore("buzz-thread-authored.v1");
const mentionedStore = makeRootIdStore("buzz-thread-mentioned.v1");
const mutedStore = makeRootIdStore("buzz-thread-muted.v1");
function parseTimestamp(value: string | null | undefined) {
if (!value) {
return null;
}
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
function toUnixSeconds(isoOrMs: string | null | undefined): number | null {
const ms = parseTimestamp(isoOrMs);
return ms === null ? null : Math.floor(ms / 1_000);
}
// Resolve where the read marker should land when a channel is marked read.
// Folds the caller's timeline position together with the newest event this
// client has observed live (`observedLatest`), so an explicit "mark read" still
// covers messages that arrived faster than channel metadata — this fold is
// load-bearing for the Esc shortcut, sidebar mark-read, and empty-channel open,
// all of which pass a null/stale caller value. `clearObserved` reports whether
// the resulting marker covers the observed timestamp, signalling the caller to
// drop its observed refs so the unread memo sees `latest === undefined` until a
// genuinely newer event arrives.
export function resolveChannelReadMarker(
callerReadAt: string | null | undefined,
observedLatest: number | undefined,
): { markAt: number | null; clearObserved: boolean } {
const callerUnix = toUnixSeconds(callerReadAt);
const markAt = Math.max(callerUnix ?? 0, observedLatest ?? 0) || null;
return {
markAt,
clearObserved:
markAt !== null &&
observedLatest !== undefined &&
observedLatest <= markAt,
};
}
export function resolveObservedUnreadRootId(tags: string[][]): string | null {
return isBroadcastReply(tags) ? null : getThreadReference(tags).rootId;
}
export function useUnreadChannels(
channels: Channel[],
activeChannel: Channel | null,
@@ -147,7 +113,7 @@ export function useUnreadChannels(
: "";
const {
getEffectiveTimestamp,
getEffectiveTimestamp: getContextReadAt,
isReady: isReadStateReady,
markContextRead,
drainSyncedAdvances,
@@ -155,6 +121,14 @@ export function useUnreadChannels(
readStateVersion,
getOwnTimestamp,
} = useReadState(pubkey, relayClient);
const getChannelTimelineReadAt = React.useCallback(
(channelId: string) =>
maxReadAt(
getContextReadAt(channelId),
getOwnTimestamp(channelTimelineContextKey(channelId)),
),
[getContextReadAt, getOwnTimestamp],
);
// Per-channel latest observed external trigger timestamp (unix seconds) and
// per-event metadata. Derived relay evidence, not source-of-truth; the unread
@@ -247,7 +221,8 @@ export function useUnreadChannels(
normalizedRelayUrl,
isReadStateReady,
readStateVersion,
getEffectiveTimestamp,
getContextReadAt,
getChannelTimelineReadAt,
getOwnTimestamp,
observedUnreadEventsByChannelRef,
latestByChannelRef,
@@ -319,7 +294,10 @@ export function useUnreadChannels(
observedLatest,
);
if (markAt === null) return;
markContextRead(channelId, markAt);
markContextRead(
topLevelOnly ? channelTimelineContextKey(channelId) : channelId,
markAt,
);
// Delegate destructive observed-ref removal to the fenced owner operation —
// the parent must not delete from latestByChannelRef or
// observedUnreadEventsByChannelRef directly on the clear-observed path,
@@ -603,12 +581,13 @@ export function useUnreadChannels(
void Promise.all(
toFetch.map(async (channelId): Promise<CatchUpResult> => {
try {
const readAt = getEffectiveTimestamp(channelId);
const channelReadAt = getContextReadAt(channelId);
const channelTimelineReadAt = getChannelTimelineReadAt(channelId);
const channel = channels.find((c) => c.id === channelId);
// NIP-01 `since` is inclusive of `created_at >= since`. The +1
// makes the relay-side filter strict-newer; the client-side
// `> readAt` check below is the belt to the suspenders.
const sinceParam = readAt === null ? 0 : readAt + 1;
const sinceParam = channelReadAt === null ? 0 : channelReadAt + 1;
const events = await relayClient.fetchEvents({
kinds: [...channelCatchUpEventKinds(channel?.channelType)],
@@ -658,7 +637,15 @@ export function useUnreadChannels(
) {
continue;
}
if (readAt !== null && event.created_at <= readAt) continue;
const evtRef = getThreadReference(event.tags);
const isThreadedReply =
evtRef.parentId !== null && !isBroadcastReply(event.tags);
const eventReadAt = isThreadedReply
? channelReadAt
: channelTimelineReadAt;
if (eventReadAt !== null && event.created_at <= eventReadAt) {
continue;
}
const eventChannelId =
event.tags.find((t) => t[0] === "h")?.[1] ?? null;
if (
@@ -673,9 +660,6 @@ export function useUnreadChannels(
) {
continue;
}
const evtRef = getThreadReference(event.tags);
const isThreadedReply =
evtRef.parentId !== null && !isBroadcastReply(event.tags);
if (event.created_at > maxExternal) {
maxExternal = event.created_at;
}
@@ -743,7 +727,7 @@ export function useUnreadChannels(
didAdvance = true;
}
if (maxExternal > 0) {
const readAtNow = getEffectiveTimestamp(channelId) ?? 0;
const readAtNow = getContextReadAt(channelId) ?? 0;
if (maxExternal > readAtNow) {
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (maxExternal > current) {
@@ -785,7 +769,8 @@ export function useUnreadChannels(
};
}, [
channelIdsKey,
getEffectiveTimestamp,
getChannelTimelineReadAt,
getContextReadAt,
isReadStateReady,
normalizedPubkey,
normalizedRelayUrl,
@@ -829,11 +814,12 @@ export function useUnreadChannels(
const observedEvents = observedUnreadEventsByChannelRef.current.get(
channel.id,
);
const channelReadAt = getEffectiveTimestamp(channel.id);
const channelReadAt = getContextReadAt(channel.id);
const channelTimelineReadAt = getChannelTimelineReadAt(channel.id);
const readAtForObservedEvent = (event: ObservedUnreadEvent) =>
observedUnreadEventReadAt(
event,
channelReadAt,
event.rootId === null ? channelTimelineReadAt : channelReadAt,
(rootId) => getOwnTimestamp(`thread:${rootId}`),
(messageId) => getOwnTimestamp(`msg:${messageId}`),
);
@@ -892,7 +878,8 @@ export function useUnreadChannels(
}, [
activeChannelId,
channels,
getEffectiveTimestamp,
getChannelTimelineReadAt,
getContextReadAt,
getOwnTimestamp,
isReadStateReady,
latestVersion,
@@ -918,7 +905,7 @@ export function useUnreadChannels(
delete forcedUnreadRef.current[channelId];
const unixSeconds =
latestByChannelRef.current.get(channelId) ??
getEffectiveTimestamp(channelId) ??
getChannelTimelineReadAt(channelId) ??
null;
if (unixSeconds !== null) {
markContextRead(channelId, unixSeconds);
@@ -933,7 +920,7 @@ export function useUnreadChannels(
// (Fenced record writes in handleChannelMessage and catch-up remain in the parent.)
observedPersistence.clearAll();
bumpLatestVersion();
}, [getEffectiveTimestamp, markContextRead, observedPersistence, pubkey]);
}, [getChannelTimelineReadAt, markContextRead, observedPersistence, pubkey]);
// Identity-stable snapshots of the membership sets for the notify gate.
// Re-derived only when membershipVersion bumps (a set actually changed), so
@@ -969,7 +956,7 @@ export function useUnreadChannels(
// off the same NIP-RS read marker without instantiating a second
// ReadStateManager. readStateVersion is the invalidation signal callers
// should include in memo deps.
getEffectiveTimestamp,
getEffectiveTimestamp: getChannelTimelineReadAt,
getOwnTimestamp,
readStateVersion,
setContextParentResolver,
@@ -409,6 +409,25 @@ test.describe("channel activity hover preview", () => {
await expect(groupedRow).toContainText("2 unread");
});
test("keeps older thread activity after opening a channel with newer timeline activity", async ({
page,
}) => {
await seedChannelActivity(page, { includeAgent: false });
await emitMockMessage(page, "Newer top-level timeline message", {
pubkey: SELF_PUBKEY,
createdAt: Math.floor(Date.now() / 1_000) + 180,
});
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
await page.getByTestId("channel-general").click({ button: "right" });
await page.getByRole("menuitem", { name: "Mark as read" }).click();
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
});
test("marks projected thread activity read from the active channel menu", async ({
page,
}) => {
@@ -657,9 +676,11 @@ test.describe("channel activity hover preview", () => {
.filter({ hasText: "Keep this separate timeline message unread." });
await manualUnreadRow.hover();
await page.getByTestId(`more-actions-${manualUnreadMessage.id}`).click();
await page
.getByTestId(`mark-read-toggle-${manualUnreadMessage.id}`)
.click();
const markUnreadItem = page.getByTestId(
`mark-read-toggle-${manualUnreadMessage.id}`,
);
await expect(markUnreadItem).toHaveText("Mark unread");
await markUnreadItem.click();
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const groupedRootId = "grouped-inbox-root-preserve-manual";