fix(desktop): correct thread-unread badge flicker, stale clear, phantom count, mention gate, and nested count (#1080)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-17 16:12:18 -04:00
committed by GitHub
parent 4f93d52e15
commit c8e1120fc2
15 changed files with 868 additions and 189 deletions

View File

@@ -308,6 +308,7 @@ export function AppShell() {
setContextParentResolver,
participatedRootIds,
authoredRootIds,
mentionedRootIds,
threadActivityItems,
mutedRootIds,
muteThread,
@@ -364,8 +365,15 @@ export function AppShell() {
!mutedRootIds.has(rootId) &&
(followedRootIds.has(rootId) ||
participatedRootIds.has(rootId) ||
authoredRootIds.has(rootId)),
[followedRootIds, mutedRootIds, participatedRootIds, authoredRootIds],
authoredRootIds.has(rootId) ||
mentionedRootIds.has(rootId)),
[
followedRootIds,
mutedRootIds,
participatedRootIds,
authoredRootIds,
mentionedRootIds,
],
);
const handleFollowThread = React.useCallback(

View File

@@ -3,6 +3,7 @@ import test from "node:test";
import { computeThreadUnreadMarker } from "../../messages/lib/unreadMarker.ts";
import {
buildDirectRepliesByParentId,
directRepliesMaxCreatedAt,
subtreeMaxCreatedAt,
} from "./subtreeCreatedAt.ts";
@@ -186,3 +187,29 @@ test("expandAfterOpen_dividerFromSnapshot_holds_whileLiveFrontierConsumes", () =
// ...even though the live frontier has consumed the whole branch.
assert.equal(consumeFromLive.firstUnreadReplyId, null);
});
test("buildDirectRepliesByParentId_groupsDirectRepliesByParent_inOrder", () => {
const messages = [
{ id: "root", parentId: null, createdAt: 100 },
{ id: "r1", parentId: "root", createdAt: 200 },
{ id: "deep", parentId: "r1", createdAt: 300 },
{ id: "r2", parentId: "root", createdAt: 250 },
];
const index = buildDirectRepliesByParentId(messages);
// Only DIRECT children, in timeline order — not transitive descendants.
assert.deepEqual(
index.get("root")?.map((m) => m.id),
["r1", "r2"],
);
assert.deepEqual(
index.get("r1")?.map((m) => m.id),
["deep"],
);
// A top-level message with no replies is absent (the seed/count guard).
assert.equal(index.has("r2"), false);
});
test("buildDirectRepliesByParentId_topLevelOnly_returnsEmpty", () => {
const messages = [{ id: "root", parentId: null, createdAt: 100 }];
assert.equal(buildDirectRepliesByParentId(messages).size, 0);
});

View File

@@ -74,6 +74,24 @@ export function buildDirectReplyIdsByParentId(
return map;
}
/**
* Maps each parent message id to its direct-reply objects in timeline order.
* Built once so per-thread badge consumers resolve direct replies in O(1)
* instead of re-scanning the whole timeline per top-level message.
*/
export function buildDirectRepliesByParentId<T extends ReplyGraphMessage>(
messages: readonly T[],
): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const message of messages) {
if (!message.parentId) continue;
const currentReplies = map.get(message.parentId) ?? [];
currentReplies.push(message);
map.set(message.parentId, currentReplies);
}
return map;
}
/** Maps each message id to its `createdAt`. */
export function buildCreatedAtByMessageId(
messages: readonly ReplyGraphMessage[],

View File

@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import test from "node:test";
import { computeThreadBadgeCounts } from "./threadBadgeCounts.ts";
import { buildDirectRepliesByParentId } from "./subtreeCreatedAt.ts";
// Minimal TimelineMessage shape the badge counter reads: id, parentId,
// createdAt, pubkey. createdAt defaults high so replies count unread against a
// null frontier unless a test sets it lower.
const msg = (id, parentId, createdAt = 100, pubkey = "author") => ({
id,
parentId,
createdAt,
pubkey,
});
const countAll = () => true;
const counts = (messages, frontiers, isNotified = countAll, currentPubkey) =>
computeThreadBadgeCounts(
messages,
buildDirectRepliesByParentId(messages),
frontiers,
isNotified,
currentPubkey,
);
test("computeThreadBadgeCounts_directRepliesOnly_countsEach", () => {
const messages = [msg("root", null), msg("a", "root"), msg("b", "root")];
assert.equal(counts(messages, undefined).get("root"), 2);
});
test("computeThreadBadgeCounts_nestedReply_countsTowardRoot", () => {
// root -> a -> b: b is a reply-to-a-reply. Pre-fix it lived under a's key
// and was never tallied toward root; the subtree walk must count it.
const messages = [msg("root", null), msg("a", "root"), msg("b", "a")];
assert.equal(counts(messages, undefined).get("root"), 2);
});
test("computeThreadBadgeCounts_deepChain_countsWholeSubtree", () => {
// root -> a -> b -> c -> d: every descendant tallies toward the root.
const messages = [
msg("root", null),
msg("a", "root"),
msg("b", "a"),
msg("c", "b"),
msg("d", "c"),
];
assert.equal(counts(messages, undefined).get("root"), 4);
});
test("computeThreadBadgeCounts_branchingSubtree_countsAllBranches", () => {
// root -> a -> {b, c}; root -> d. Four descendants across two branches.
const messages = [
msg("root", null),
msg("a", "root"),
msg("b", "a"),
msg("c", "a"),
msg("d", "root"),
];
assert.equal(counts(messages, undefined).get("root"), 4);
});
test("computeThreadBadgeCounts_rootWithNoReplies_omitted", () => {
const messages = [msg("root", null)];
assert.equal(counts(messages, undefined).has("root"), false);
});
test("computeThreadBadgeCounts_notNotified_omitted", () => {
const messages = [msg("root", null), msg("a", "root"), msg("b", "a")];
assert.equal(counts(messages, undefined, () => false).size, 0);
});
test("computeThreadBadgeCounts_frontierCoversNestedReplies_excludesRead", () => {
// Frontier 150: a (100) is read, only nested b (200) remains unread.
const messages = [
msg("root", null),
msg("a", "root", 100),
msg("b", "a", 200),
];
const frontiers = new Map([["root", 150]]);
assert.equal(counts(messages, frontiers).get("root"), 1);
});
test("computeThreadBadgeCounts_frontierCoversWholeSubtree_omitsRoot", () => {
const messages = [
msg("root", null),
msg("a", "root", 100),
msg("b", "a", 120),
];
const frontiers = new Map([["root", 150]]);
assert.equal(counts(messages, frontiers).has("root"), false);
});
test("computeThreadBadgeCounts_selfAuthoredNestedReply_notCounted", () => {
// A nested reply authored by the current user never counts as unread.
const messages = [
msg("root", null),
msg("a", "root", 100, "other"),
msg("b", "a", 200, "ME"),
];
assert.equal(counts(messages, undefined, countAll, "me").get("root"), 1);
});
test("computeThreadBadgeCounts_multipleRoots_eachCountsOwnSubtree", () => {
const messages = [
msg("root1", null),
msg("a", "root1"),
msg("b", "a"),
msg("root2", null),
msg("c", "root2"),
];
const result = counts(messages, undefined);
assert.equal(result.get("root1"), 2);
assert.equal(result.get("root2"), 1);
});

View File

@@ -0,0 +1,76 @@
import { computeThreadUnreadMarker } from "@/features/messages/lib/unreadMarker";
import type { TimelineMessage } from "@/features/messages/types";
/**
* All reply messages in a root's subtree — direct children plus every deeper
* descendant, walked through the direct-replies adjacency map. A reply-to-a-
* reply must count toward the root's badge, so the badge tally needs the whole
* subtree rather than the root's direct children alone.
*
* Terminates without a visited-set: buildDirectRepliesByParentId places each
* message under exactly one parent key, and the only caller seeds the walk from
* true roots (parentId === null), so a node in a malformed parent cycle — whose
* members all key off each other, never off a root — is unreachable from any
* root's bucket. Seeding from a non-root id, or a builder that filed one node
* under two keys, would break that invariant.
*/
function collectSubtreeReplies(
rootId: string,
directRepliesByParentId: ReadonlyMap<string, TimelineMessage[]>,
): TimelineMessage[] {
const replies: TimelineMessage[] = [];
const pending = [...(directRepliesByParentId.get(rootId) ?? [])];
while (pending.length > 0) {
const reply = pending.pop();
if (!reply) continue;
replies.push(reply);
pending.push(...(directRepliesByParentId.get(reply.id) ?? []));
}
return replies;
}
/**
* Per-thread unread reply counts for the summary rows in the main timeline.
*
* Counts are computed only for threads the user has notification interest in
* (`isNotified`) and measured against the per-root frontier snapshot rather
* than the live marker, so badges stay stable for the session (see
* nextThreadBadgeFrontier for the snapshot-advance-on-read rationale). The
* count spans the root's WHOLE subtree, so a reply nested under another reply
* still tallies toward the root's badge.
*
* @param messages Top-level timeline entries in chronological order.
* @param directRepliesByParentId Direct replies keyed by parent id, walked to
* collect each root's full descendant subtree.
* @param frontiers Per-root read frontier in unix seconds, or null/undefined
* when the thread was never read (every reply counts unread).
* @param isNotified Whether a thread root is one the user is notified for.
* @param currentPubkey Replies authored by this pubkey never count as unread.
*/
export function computeThreadBadgeCounts(
messages: TimelineMessage[],
directRepliesByParentId: ReadonlyMap<string, TimelineMessage[]>,
frontiers: ReadonlyMap<string, number | null> | undefined,
isNotified: (rootId: string) => boolean,
currentPubkey?: string,
): Map<string, number> {
const counts = new Map<string, number>();
for (const message of messages) {
if (message.parentId) continue;
if (!isNotified(message.id)) continue;
const subtreeReplies = collectSubtreeReplies(
message.id,
directRepliesByParentId,
);
if (subtreeReplies.length === 0) continue;
const { unreadCount } = computeThreadUnreadMarker(
subtreeReplies,
frontiers?.get(message.id) ?? null,
currentPubkey,
);
if (unreadCount > 0) {
counts.set(message.id, unreadCount);
}
}
return counts;
}

View File

@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import { nextThreadBadgeFrontier } from "./threadBadgeFrontier.ts";
import { seedThreadBadgeFrontiers } from "./threadBadgeFrontier.ts";
import { buildDirectRepliesByParentId } from "./subtreeCreatedAt.ts";
const msg = (id, parentId) => ({ id, parentId });
const seedAll = () => true;
const seed = (frontiers, messages, isNotified, getReadAt) =>
seedThreadBadgeFrontiers(
frontiers,
messages,
buildDirectRepliesByParentId(messages),
isNotified,
getReadAt,
);
test("nextThreadBadgeFrontier_unseededNullMarker_seedsNull", () => {
// Thread never read: snapshot seeds to null (everything unread).
assert.equal(nextThreadBadgeFrontier(undefined, null), null);
});
test("nextThreadBadgeFrontier_unseededWithMarker_seedsToMarker", () => {
assert.equal(nextThreadBadgeFrontier(undefined, 100), 100);
});
test("nextThreadBadgeFrontier_readAdvancesMarker_advancesSnapshot", () => {
// Snapshot frozen at open (null), user reads → live marker 200 → badge clears.
assert.equal(nextThreadBadgeFrontier(null, 200), 200);
});
test("nextThreadBadgeFrontier_markerNewerThanStored_advances", () => {
assert.equal(nextThreadBadgeFrontier(100, 250), 250);
});
test("nextThreadBadgeFrontier_markerOlderThanStored_keepsStored", () => {
// Monotonic: a stale lower marker never lowers the snapshot.
assert.equal(nextThreadBadgeFrontier(250, 100), 250);
});
test("nextThreadBadgeFrontier_markerNullAfterSeed_keepsStored", () => {
// Live marker reads null (never read) but snapshot already advanced — hold.
assert.equal(nextThreadBadgeFrontier(150, null), 150);
});
test("nextThreadBadgeFrontier_markerEqualsStored_unchanged", () => {
assert.equal(nextThreadBadgeFrontier(150, 150), 150);
});
test("nextThreadBadgeFrontier_storedNullMarkerZero_advancesToZero", () => {
// Zero is a valid frontier (epoch); null is strictly lower than any number.
assert.equal(nextThreadBadgeFrontier(null, 0), 0);
});
test("seedThreadBadgeFrontiers_threadWithReplies_seedsToMarker", () => {
const frontiers = new Map();
const messages = [msg("root", null), msg("r1", "root")];
seed(frontiers, messages, seedAll, (id) => (id === "root" ? 100 : null));
assert.equal(frontiers.get("root"), 100);
});
test("seedThreadBadgeFrontiers_threadWithoutReplies_skipped", () => {
const frontiers = new Map();
seed(frontiers, [msg("root", null)], seedAll, () => 100);
assert.equal(frontiers.has("root"), false);
});
test("seedThreadBadgeFrontiers_notNotified_skipped", () => {
const frontiers = new Map();
const messages = [msg("root", null), msg("r1", "root")];
seed(
frontiers,
messages,
() => false,
() => 100,
);
assert.equal(frontiers.has("root"), false);
});
test("seedThreadBadgeFrontiers_replyEntry_neverSeeded", () => {
// A reply is never a badge root even if its id collides with a notified set.
const frontiers = new Map();
const messages = [msg("r1", "root"), msg("r2", "root")];
seed(frontiers, messages, seedAll, () => 100);
assert.equal(frontiers.size, 0);
});
test("seedThreadBadgeFrontiers_reseed_advancesMonotonically", () => {
const frontiers = new Map([["root", 100]]);
const messages = [msg("root", null), msg("r1", "root")];
// Re-render after the live marker advanced to 250 on read.
seed(frontiers, messages, seedAll, () => 250);
assert.equal(frontiers.get("root"), 250);
// A stale lower marker never lowers an already-advanced snapshot.
seed(frontiers, messages, seedAll, () => 100);
assert.equal(frontiers.get("root"), 250);
});

View File

@@ -0,0 +1,57 @@
import type { TimelineMessage } from "@/features/messages/types";
// Decide the next value for a thread's badge frontier snapshot. The snapshot is
// seeded once at channel-open (reflecting "what was unread on open") and then
// advanced monotonically toward the live thread read marker as the user reads,
// so the badge clears after a read without waiting for channel re-entry.
//
// The advance target is ALWAYS the live marker (what the user actually
// consumed), never "latest reply": a subsequent reply newer than the marker
// re-raises the badge, and a collapsed-branch reply the marker never covered
// stays unread. Monotonic `Math.max` guards against a stale lower marker.
//
// Returns the value the snapshot should hold:
// - `stored === undefined` (unseeded): seed to the live marker.
// - otherwise: the greater of the stored snapshot and the live marker, where
// `null` (never read) is the lowest possible frontier.
export function nextThreadBadgeFrontier(
stored: number | null | undefined,
liveMarker: number | null,
): number | null {
if (stored === undefined) {
return liveMarker;
}
if (liveMarker === null) {
return stored;
}
if (stored === null) {
return liveMarker;
}
return Math.max(stored, liveMarker);
}
// Seed/advance the per-root badge frontier snapshots for one channel, in place.
// Captures only top-level notified threads that have replies; each entry is
// seeded once at open then advanced toward the live marker on subsequent reads
// (see nextThreadBadgeFrontier). Called during render so snapshots reflect
// "what was unread on open," matching the openFrontierRef pattern.
export function seedThreadBadgeFrontiers(
channelFrontiers: Map<string, number | null>,
messages: TimelineMessage[],
directRepliesByParentId: ReadonlyMap<string, TimelineMessage[]>,
isNotified: (rootId: string) => boolean,
getReadAt: (rootId: string) => number | null,
): void {
for (const message of messages) {
if (message.parentId) continue;
if (!isNotified(message.id)) continue;
if (!directRepliesByParentId.has(message.id)) continue;
channelFrontiers.set(
message.id,
nextThreadBadgeFrontier(
channelFrontiers.get(message.id),
getReadAt(message.id),
),
);
}
}

View File

@@ -91,6 +91,7 @@ export function ChannelScreen({
unfollowThread,
isFollowingThread,
isNotifiedForThread,
readStateVersion,
setTopbarSearchHidden,
} = useAppShell();
const {
@@ -369,6 +370,7 @@ export function ChannelScreen({
markChannelUnread,
markThreadRead,
isNotifiedForThread,
readStateVersion,
});
const editTargetMessage = React.useMemo(
() =>

View File

@@ -2,12 +2,15 @@ import * as React from "react";
import {
buildCreatedAtByMessageId,
buildDirectRepliesByParentId,
buildDirectReplyIdsByParentId,
collectReplyDescendantIds,
directRepliesMaxCreatedAt,
subtreeMaxCreatedAt,
} from "@/features/channels/lib/subtreeCreatedAt";
import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
import { computeThreadBadgeCounts } from "@/features/channels/lib/threadBadgeCounts";
import { seedThreadBadgeFrontiers } from "@/features/channels/lib/threadBadgeFrontier";
import {
buildThreadPanelDataFromIndex,
buildThreadPanelIndex,
@@ -17,6 +20,7 @@ import {
computeThreadUnreadMarker,
} from "@/features/messages/lib/unreadMarker";
import type { TimelineMessage } from "@/features/messages/types";
import { isConversationalUnreadKind } from "@/shared/constants/kinds";
import { useWelcomeInitialUnreadSuppression } from "./useWelcomeInitialUnreadSuppression";
@@ -33,6 +37,7 @@ type UseChannelUnreadStateOptions = {
markChannelUnread: (channelId: string) => void;
markThreadRead: (rootId: string, timestamp: number) => void;
isNotifiedForThread: (rootId: string) => boolean;
readStateVersion: number;
};
/**
@@ -60,6 +65,7 @@ export function useChannelUnreadState({
markChannelUnread,
markThreadRead,
isNotifiedForThread,
readStateVersion,
}: UseChannelUnreadStateOptions) {
// Capture the read frontier as it stood the instant this channel was opened,
// BEFORE the mark-read effect (in ChannelScreen) advances it to latest.
@@ -115,6 +121,10 @@ export function useChannelUnreadState({
() => buildDirectReplyIdsByParentId(timelineMessages),
[timelineMessages],
);
const directRepliesByParentId = React.useMemo(
() => buildDirectRepliesByParentId(timelineMessages),
[timelineMessages],
);
const getFirstReplyIdForMessage = React.useCallback(
(messageId: string) => directReplyIdsByParentId.get(messageId)?.[0] ?? null,
[directReplyIdsByParentId],
@@ -166,10 +176,14 @@ export function useChannelUnreadState({
// Oldest unread top-level message + count from the open-time frontier.
// Keyed per channel so the pill/divider survive the mark-read effect.
// Non-conversational kinds (system rows, job-lifecycle events) are filtered
// out first so they don't inflate the pill; see isConversationalUnreadKind.
const { firstUnreadMessageId, unreadCount } = React.useMemo(
() =>
computeChannelUnreadMarker(
timelineMessages,
timelineMessages.filter((message) =>
isConversationalUnreadKind(message.kind),
),
openFrontierSeconds,
isActiveChannelForcedUnread || isActiveWelcomeInitialUnreadSuppressed,
currentPubkey,
@@ -300,17 +314,13 @@ export function useChannelUnreadState({
channelFrontiers = new Map();
threadBadgeFrontiersRef.current.set(activeChannelId, channelFrontiers);
}
for (const message of timelineMessages) {
if (message.parentId) continue;
if (!isNotifiedForThread(message.id)) continue;
if (channelFrontiers.has(message.id)) continue;
// Only capture for messages that have thread replies
const hasReplies = timelineMessages.some(
(m) => m.parentId === message.id,
);
if (!hasReplies) continue;
channelFrontiers.set(message.id, getThreadReadAt(message.id));
}
seedThreadBadgeFrontiers(
channelFrontiers,
timelineMessages,
directRepliesByParentId,
isNotifiedForThread,
getThreadReadAt,
);
}
// Clear the thread badge frontiers on channel leave (same cleanup as
// openFrontierRef) so re-visiting captures fresh snapshots.
@@ -321,36 +331,31 @@ export function useChannelUnreadState({
threadBadgeFrontiersRef.current.delete(channelId);
};
}, [activeChannelId]);
// Compute per-thread unread counts for summary rows in the main timeline.
// Only compute for threads the user has notification interest in — this
// aligns the badge display with the read-state write path. Uses the
// snapshotted frontier (threadBadgeFrontiersRef) so badges are stable for
// the session and don't flash when markChannelRead advances the channel
// marker.
const threadUnreadCounts = React.useMemo(() => {
const counts = new Map<string, number>();
const channelFrontiers = activeChannelId
? threadBadgeFrontiersRef.current.get(activeChannelId)
: undefined;
for (const message of timelineMessages) {
if (message.parentId) continue;
if (!isNotifiedForThread(message.id)) continue;
const directReplies = timelineMessages.filter(
(m) => m.parentId === message.id,
);
if (directReplies.length === 0) continue;
const frontier = channelFrontiers?.get(message.id) ?? null;
const { unreadCount } = computeThreadUnreadMarker(
directReplies,
frontier,
// Per-thread unread counts for the main-timeline summary rows. Pure logic
// lives in computeThreadBadgeCounts; readStateVersion is an intentional
// recompute trigger so the badge re-reads the snapshot the seed block above
// advanced toward the live marker on mark-read.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
const threadUnreadCounts = React.useMemo(
() =>
computeThreadBadgeCounts(
timelineMessages,
directRepliesByParentId,
activeChannelId
? threadBadgeFrontiersRef.current.get(activeChannelId)
: undefined,
isNotifiedForThread,
currentPubkey,
);
if (unreadCount > 0) {
counts.set(message.id, unreadCount);
}
}
return counts;
}, [activeChannelId, currentPubkey, timelineMessages, isNotifiedForThread]);
),
[
activeChannelId,
currentPubkey,
timelineMessages,
directRepliesByParentId,
isNotifiedForThread,
readStateVersion,
],
);
const handleMarkUnread = React.useCallback(() => {
if (!activeChannelId) return;

View File

@@ -10,6 +10,7 @@ import {
isBroadcastReply,
} from "@/features/messages/lib/threading";
import {
hasMentionForEvent,
isHighPriorityEventForUser,
shouldNotifyForEvent,
} from "@/features/notifications/lib/shouldNotify";
@@ -30,121 +31,48 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
// per-channel limit elsewhere in the app.
const CATCH_UP_LIMIT = 1000;
const PARTICIPATION_STORAGE_PREFIX = "buzz-thread-participation.v1";
const MAX_PARTICIPATION_ENTRIES = 1000;
function participationStorageKey(pubkey: string): string {
return `${PARTICIPATION_STORAGE_PREFIX}:${pubkey}`;
// All four thread root-id sets (participation, authored, mentioned, muted)
// share the same localStorage shape: a per-pubkey JSON array of ids, capped to
// the newest N entries on write and tolerant of malformed/absent data on read.
// One factory yields the read/write pair for each so the only difference is the
// key prefix. The closures capture the prefix lexically (no `this`), so a
// caller can alias one store's `write` into a variable and call it bare.
function makeRootIdStore(prefix: string, maxEntries = 1000) {
const storageKey = (pubkey: string) => `${prefix}:${pubkey}`;
return {
read(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(storageKey(pubkey));
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(
parsed.filter((id): id is string => typeof id === "string"),
);
} catch {
return new Set();
}
},
write(pubkey: string, rootIds: Set<string>): void {
try {
const arr = [...rootIds];
const capped =
arr.length > maxEntries ? arr.slice(arr.length - maxEntries) : arr;
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(capped));
} catch {
// Ignore storage errors (private browsing, quota exceeded).
}
},
};
}
function readParticipationFromStorage(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(participationStorageKey(pubkey));
if (!raw) {
return new Set();
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
return new Set();
}
}
function writeParticipationToStorage(
pubkey: string,
rootIds: Set<string>,
): void {
try {
const arr = [...rootIds];
const capped =
arr.length > MAX_PARTICIPATION_ENTRIES
? arr.slice(arr.length - MAX_PARTICIPATION_ENTRIES)
: arr;
window.localStorage.setItem(
participationStorageKey(pubkey),
JSON.stringify(capped),
);
} catch {
// Ignore storage errors (private browsing, quota exceeded).
}
}
const AUTHORED_STORAGE_PREFIX = "buzz-thread-authored.v1";
const MAX_AUTHORED_ENTRIES = 1000;
function authoredStorageKey(pubkey: string): string {
return `${AUTHORED_STORAGE_PREFIX}:${pubkey}`;
}
function readAuthoredFromStorage(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(authoredStorageKey(pubkey));
if (!raw) {
return new Set();
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
return new Set();
}
}
function writeAuthoredToStorage(pubkey: string, rootIds: Set<string>): void {
try {
const arr = [...rootIds];
const capped =
arr.length > MAX_AUTHORED_ENTRIES
? arr.slice(arr.length - MAX_AUTHORED_ENTRIES)
: arr;
window.localStorage.setItem(
authoredStorageKey(pubkey),
JSON.stringify(capped),
);
} catch {
// Ignore storage errors (private browsing, quota exceeded).
}
}
const MUTED_STORAGE_PREFIX = "buzz-thread-muted.v1";
const MAX_MUTED_ENTRIES = 1000;
function mutedStorageKey(pubkey: string): string {
return `${MUTED_STORAGE_PREFIX}:${pubkey}`;
}
function readMutedFromStorage(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(mutedStorageKey(pubkey));
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
return new Set();
}
}
function writeMutedToStorage(pubkey: string, rootIds: Set<string>): void {
try {
const arr = [...rootIds];
const capped =
arr.length > MAX_MUTED_ENTRIES
? arr.slice(arr.length - MAX_MUTED_ENTRIES)
: arr;
window.localStorage.setItem(
mutedStorageKey(pubkey),
JSON.stringify(capped),
);
} catch {
// Ignore storage errors (private browsing, quota exceeded).
}
}
const participationStore = makeRootIdStore("buzz-thread-participation.v1");
const authoredStore = makeRootIdStore("buzz-thread-authored.v1");
// Thread roots where an external message @-mentioned the current user. The
// badge gate ORs this in so a mention recipient who never participated,
// authored, or followed still gets the thread-unread badge.
const mentionedStore = makeRootIdStore("buzz-thread-mentioned.v1");
const mutedStore = makeRootIdStore("buzz-thread-muted.v1");
export type ThreadActivityItem = {
id: string;
@@ -359,6 +287,11 @@ export function useUnreadChannels(
// Used to notify the author when someone replies to their posts.
const authoredRootIdsRef = React.useRef(new Set<string>());
// Root event IDs of threads where an external message @-mentioned the user.
// ORed into the badge gate so a mention recipient who never participated,
// authored, or followed the thread still gets the thread-unread badge.
const mentionedRootIdsRef = React.useRef(new Set<string>());
// Root event IDs of threads the user has explicitly muted. Takes precedence
// over participation, follow, and authorship for notification suppression.
const mutedRootIdsRef = React.useRef(new Set<string>());
@@ -382,6 +315,15 @@ export function useUnreadChannels(
0,
);
// Version signal bumped only when the participated/authored/mentioned
// root-id sets change, so the gate snapshots (re-derived below) don't
// re-allocate on every observed external message the way reusing
// latestVersion would.
const [membershipVersion, bumpMembershipVersion] = React.useReducer(
(x: number) => x + 1,
0,
);
// Reset all in-session state when the identity or relay changes. Unread
// tracking depends only on NIP-RS read markers + observed relay events for
// this user; nothing here is persisted across restarts.
@@ -392,14 +334,18 @@ export function useUnreadChannels(
forcedUnreadRef.current = new Set();
caughtUpChannelsRef.current = new Set();
participatedRootIdsRef.current = pubkey
? readParticipationFromStorage(pubkey)
? participationStore.read(pubkey)
: new Set();
authoredRootIdsRef.current = pubkey
? readAuthoredFromStorage(pubkey)
? authoredStore.read(pubkey)
: new Set();
mutedRootIdsRef.current = pubkey ? readMutedFromStorage(pubkey) : new Set();
mentionedRootIdsRef.current = pubkey
? mentionedStore.read(pubkey)
: new Set();
mutedRootIdsRef.current = pubkey ? mutedStore.read(pubkey) : new Set();
threadActivityRef.current = pubkey ? readActivityFromStorage(pubkey) : [];
bumpLatestVersion();
bumpMembershipVersion();
}, [pubkey, relayClient]);
// `topLevelOnly` is the passive channel-open path (NIP-RS Option 1): the
@@ -454,6 +400,28 @@ export function useUnreadChannels(
}
}, []);
// Record the thread root of an EXTERNAL message that @-mentioned the user.
// Keyed on the thread root so the badge gate trips for a mention recipient
// who never participated/authored/followed. Top-level mentions (no rootId)
// are ignored — thread badges only exist for replies. Returns true when the
// set actually grew so callers can decide whether to bump the gate snapshot.
const recordMentionedRoot = React.useCallback(
(event: RelayEvent): boolean => {
if (normalizedPubkey === null) return false;
if (event.pubkey.toLowerCase() === normalizedPubkey) return false;
if (!hasMentionForEvent(event, normalizedPubkey)) return false;
const { rootId } = getThreadReference(event.tags);
if (rootId === null) return false;
const target = mentionedRootIdsRef.current;
const sizeBefore = target.size;
target.add(rootId);
if (target.size === sizeBefore) return false;
mentionedStore.write(normalizedPubkey, target);
return true;
},
[normalizedPubkey],
);
// Feed the in-session "latest external trigger" map from live channel
// events. Composes with any caller-supplied onChannelMessage handler.
// useLiveChannelUpdates already filters this callback to trigger kinds
@@ -468,6 +436,12 @@ export function useUnreadChannels(
bumpLatestVersion();
}
// A mention on a reply makes its thread badge-eligible even when the
// user never participated/authored/followed (the gate's missing term).
if (recordMentionedRoot(event)) {
bumpMembershipVersion();
}
// Track high-priority events (DMs, mentions, broadcasts) separately.
const channel = channelsRef.current.find((ch) => ch.id === channelId);
if (
@@ -488,25 +462,31 @@ export function useUnreadChannels(
callerOnChannelMessage?.(channelId, event);
},
[callerOnChannelMessage, normalizedPubkey],
[callerOnChannelMessage, normalizedPubkey, recordMentionedRoot],
);
const handleSelfChannelMessage = React.useCallback(
(event: RelayEvent) => {
const ref = getThreadReference(event.tags);
if (ref.rootId !== null) {
participatedRootIdsRef.current.add(ref.rootId);
if (normalizedPubkey !== null) {
writeParticipationToStorage(
normalizedPubkey,
participatedRootIdsRef.current,
);
}
} else {
authoredRootIdsRef.current.add(event.id);
if (normalizedPubkey !== null) {
writeAuthoredToStorage(normalizedPubkey, authoredRootIdsRef.current);
}
// Participation roots key on the thread root; authored roots (no thread
// ref) key on the event id itself.
const isParticipation = ref.rootId !== null;
const targetSet = isParticipation
? participatedRootIdsRef.current
: authoredRootIdsRef.current;
const sizeBefore = targetSet.size;
targetSet.add(ref.rootId ?? event.id);
if (normalizedPubkey !== null) {
const write = isParticipation
? participationStore.write
: authoredStore.write;
write(normalizedPubkey, targetSet);
}
// Only re-derive the gate snapshot when the set actually grew; a self-post
// to an already-tracked root is a no-op for the notify gate, so skipping
// the bump avoids a wasted snapshot re-allocation + gate recompute.
if (targetSet.size !== sizeBefore) {
bumpMembershipVersion();
}
bumpLatestVersion();
},
@@ -547,7 +527,7 @@ export function useUnreadChannels(
(rootId: string) => {
mutedRootIdsRef.current.add(rootId);
if (normalizedPubkey !== null) {
writeMutedToStorage(normalizedPubkey, mutedRootIdsRef.current);
mutedStore.write(normalizedPubkey, mutedRootIdsRef.current);
}
bumpLatestVersion();
},
@@ -558,7 +538,7 @@ export function useUnreadChannels(
(rootId: string) => {
mutedRootIdsRef.current.delete(rootId);
if (normalizedPubkey !== null) {
writeMutedToStorage(normalizedPubkey, mutedRootIdsRef.current);
mutedStore.write(normalizedPubkey, mutedRootIdsRef.current);
}
bumpLatestVersion();
},
@@ -612,6 +592,15 @@ export function useUnreadChannels(
let isCancelled = false;
// Snapshot membership sizes so the `.then` can detect whether the catch-up
// discovered new participated/authored/mentioned roots (pass 1 mutates the
// refs in place). A pure-participation or pure-mention discovery produces no
// maxExternal advance, so without this the notify gate would never
// invalidate to surface the badge.
const participatedSizeBefore = participatedRootIdsRef.current.size;
const authoredSizeBefore = authoredRootIdsRef.current.size;
const mentionedSizeBefore = mentionedRootIdsRef.current.size;
type CatchUpResult =
| {
channelId: string;
@@ -638,31 +627,31 @@ export function useUnreadChannels(
limit: CATCH_UP_LIMIT,
});
// Pass 1: build participation from self-authored thread replies
// and track self-authored top-level messages for author notifications
// Pass 1: build participation from self-authored thread replies,
// track self-authored top-level messages for author notifications,
// and capture external mentions so their threads gate a badge.
for (const event of events) {
if (
const isSelf =
normalizedPubkey !== null &&
event.pubkey.toLowerCase() === normalizedPubkey
) {
event.pubkey.toLowerCase() === normalizedPubkey;
if (isSelf) {
const ref = getThreadReference(event.tags);
if (ref.rootId !== null) {
participatedRootIdsRef.current.add(ref.rootId);
} else {
authoredRootIdsRef.current.add(event.id);
}
} else {
recordMentionedRoot(event);
}
}
if (normalizedPubkey !== null) {
writeParticipationToStorage(
participationStore.write(
normalizedPubkey,
participatedRootIdsRef.current,
);
writeAuthoredToStorage(
normalizedPubkey,
authoredRootIdsRef.current,
);
authoredStore.write(normalizedPubkey, authoredRootIdsRef.current);
}
// Pass 2: compute maxExternal and collect thread reply activity,
@@ -792,6 +781,13 @@ export function useUnreadChannels(
}
}
if (didAdvance) bumpLatestVersion();
if (
participatedRootIdsRef.current.size !== participatedSizeBefore ||
authoredRootIdsRef.current.size !== authoredSizeBefore ||
mentionedRootIdsRef.current.size !== mentionedSizeBefore
) {
bumpMembershipVersion();
}
});
return () => {
@@ -933,6 +929,26 @@ export function useUnreadChannels(
bumpLatestVersion();
}, [getEffectiveTimestamp, markContextRead]);
// Identity-stable snapshots of the membership sets for the notify gate.
// Re-derived only when membershipVersion bumps (a set actually changed), so
// `isNotifiedForThread`'s useCallback deps invalidate on async discovery
// while live consumers keep reading the mutable refs directly.
// biome-ignore lint/correctness/useExhaustiveDependencies: membershipVersion is the intentional re-derivation signal
const participatedRootIds = React.useMemo(
() => new Set(participatedRootIdsRef.current) as ReadonlySet<string>,
[membershipVersion],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: membershipVersion is the intentional re-derivation signal
const authoredRootIds = React.useMemo(
() => new Set(authoredRootIdsRef.current) as ReadonlySet<string>,
[membershipVersion],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: membershipVersion is the intentional re-derivation signal
const mentionedRootIds = React.useMemo(
() => new Set(mentionedRootIdsRef.current) as ReadonlySet<string>,
[membershipVersion],
);
return {
unreadChannelIds,
highPriorityUnreadChannelIds,
@@ -946,8 +962,9 @@ export function useUnreadChannels(
getEffectiveTimestamp,
readStateVersion,
setContextParentResolver,
participatedRootIds: participatedRootIdsRef.current as ReadonlySet<string>,
authoredRootIds: authoredRootIdsRef.current as ReadonlySet<string>,
participatedRootIds,
authoredRootIds,
mentionedRootIds,
threadActivityItems: threadActivityRef.current,
mutedRootIds: mutedRootIdsRef.current as ReadonlySet<string>,
muteThread,

View File

@@ -237,3 +237,24 @@ test("computeThreadUnreadMarker_noPubkey_countsNormally", () => {
assert.equal(marker.firstUnreadReplyId, "r1");
assert.equal(marker.unreadCount, 2);
});
test("computeChannelUnreadMarker_selfAuthoredMixedCase_skipsOwnMessages", () => {
// Identity and signer pubkeys differing only in hex case must still match.
const messages = [
{ ...topLevel("a", 10), pubkey: "ABCDEF" },
{ ...topLevel("b", 20), pubkey: "other" },
];
const marker = computeChannelUnreadMarker(messages, 5, false, "abcdef");
assert.equal(marker.firstUnreadMessageId, "b");
assert.equal(marker.unreadCount, 1);
});
test("computeThreadUnreadMarker_selfAuthoredMixedCase_skipsOwnReplies", () => {
const replies = [
{ id: "r1", createdAt: 10, pubkey: "ABCDEF" },
{ id: "r2", createdAt: 20, pubkey: "other" },
];
const marker = computeThreadUnreadMarker(replies, 5, "abcdef");
assert.equal(marker.firstUnreadReplyId, "r2");
assert.equal(marker.unreadCount, 1);
});

View File

@@ -43,6 +43,10 @@ export function computeChannelUnreadMarker(
return EMPTY_MARKER;
}
// Normalize once: signer-emitted and identity pubkeys are both lowercase hex
// today, but a case mismatch would otherwise miscount a self-post as unread.
const normalizedPubkey = currentPubkey?.toLowerCase();
let firstUnreadMessageId: string | null = null;
let unreadCount = 0;
@@ -50,7 +54,10 @@ export function computeChannelUnreadMarker(
if (message.parentId) {
continue;
}
if (currentPubkey && message.pubkey === currentPubkey) {
if (
normalizedPubkey &&
message.pubkey?.toLowerCase() === normalizedPubkey
) {
continue;
}
const isUnread =
@@ -101,11 +108,14 @@ export function computeThreadUnreadMarker(
frontierSeconds: number | null,
currentPubkey?: string,
): ThreadUnreadMarker {
// Normalize once: see computeChannelUnreadMarker for the case-mismatch guard.
const normalizedPubkey = currentPubkey?.toLowerCase();
let firstUnreadReplyId: string | null = null;
let unreadCount = 0;
for (const reply of replies) {
if (currentPubkey && reply.pubkey === currentPubkey) {
if (normalizedPubkey && reply.pubkey?.toLowerCase() === normalizedPubkey) {
continue;
}
const isUnread =

View File

@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isConversationalUnreadKind,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_STREAM_MESSAGE_DIFF,
KIND_SYSTEM_MESSAGE,
KIND_JOB_REQUEST,
KIND_JOB_ACCEPTED,
KIND_JOB_PROGRESS,
KIND_JOB_RESULT,
KIND_JOB_CANCEL,
KIND_JOB_ERROR,
} from "./kinds.ts";
test("isConversationalUnreadKind_streamMessage_counts", () => {
assert.equal(isConversationalUnreadKind(KIND_STREAM_MESSAGE), true);
});
test("isConversationalUnreadKind_streamMessageV2_counts", () => {
// 40002 is a real message edit/v2 — must stay counted.
assert.equal(isConversationalUnreadKind(KIND_STREAM_MESSAGE_V2), true);
});
test("isConversationalUnreadKind_streamMessageDiff_counts", () => {
// 40008 is a real message diff — must stay counted.
assert.equal(isConversationalUnreadKind(KIND_STREAM_MESSAGE_DIFF), true);
});
test("isConversationalUnreadKind_systemMessage_excluded", () => {
// 40099 channel_created / member_joined rows must not inflate the pill.
assert.equal(isConversationalUnreadKind(KIND_SYSTEM_MESSAGE), false);
});
test("isConversationalUnreadKind_allJobKinds_excluded", () => {
for (const kind of [
KIND_JOB_REQUEST,
KIND_JOB_ACCEPTED,
KIND_JOB_PROGRESS,
KIND_JOB_RESULT,
KIND_JOB_CANCEL,
KIND_JOB_ERROR,
]) {
assert.equal(isConversationalUnreadKind(kind), false, `kind ${kind}`);
}
});
test("isConversationalUnreadKind_undefinedKind_countsAsConversational", () => {
// Optimistic/pending rows whose kind has not populated must not be dropped.
assert.equal(isConversationalUnreadKind(undefined), true);
});
test("isConversationalUnreadKind_unknownKind_countsAsConversational", () => {
// An exclude-list, not an include-list: anything not explicitly excluded
// (e.g. a future conversational kind) is kept.
assert.equal(isConversationalUnreadKind(12345), true);
});

View File

@@ -64,3 +64,25 @@ export const CHANNEL_EVENT_KINDS = [
KIND_STREAM_MESSAGE_DIFF, // 40008 — message diffs
KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.)
] as const;
// Timeline kinds that are NOT conversational: relay-signed system rows
// (channel-created, member-joined) and job-lifecycle events. These render in
// the timeline but must not count toward the channel's unread pill — a freshly
// created channel carries one channel_created + N member_joined system rows
// that would otherwise show as phantom unreads ("4 unread, 1 message").
const NON_CONVERSATIONAL_UNREAD_KINDS: ReadonlySet<number> = new Set([
KIND_SYSTEM_MESSAGE, // 40099
KIND_JOB_REQUEST, // 43001
KIND_JOB_ACCEPTED, // 43002
KIND_JOB_PROGRESS, // 43003
KIND_JOB_RESULT, // 43004
KIND_JOB_CANCEL, // 43005
KIND_JOB_ERROR, // 43006
]);
// Whether a timeline message kind should count toward unread tallies. An
// undefined kind (optimistic/pending rows whose kind has not populated) is
// treated as conversational so a legitimately unread message is never dropped.
export function isConversationalUnreadKind(kind: number | undefined): boolean {
return kind === undefined || !NON_CONVERSATIONAL_UNREAD_KINDS.has(kind);
}

View File

@@ -783,4 +783,148 @@ test.describe("thread unread indicator screenshots", () => {
path: `${SHOTS}/12-sidebar-dot-all-replies.png`,
});
});
// Regression guard for BUG-2 (clear-on-read): opening an unread thread marks
// its visible direct replies read, and the depth-0 badge must clear to zero
// IN PLACE — without leaving and re-entering the channel. Every other test
// re-enters the channel to refresh the badge; none asserts that reading the
// thread alone clears it. The mechanism: the mark-read effect advances the
// thread frontier over the head + direct replies on open, bumping
// readStateVersion, which recomputes computeThreadBadgeCounts against the
// now-advanced snapshot. Before the fix the badge read a frozen open-time
// snapshot that mark-read never invalidated, so it persisted until channel
// re-entry. This walks badge=3 -> open thread -> close -> badge gone, all
// while staying in general.
test("13-thread-badge-clears-on-read-without-reentry", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
// Read frontier over an initial reply, then close the thread (same setup as
// test 01) so the subsequent replies land strictly past the frontier.
await emitMockMessage(page, "general", "First reply to welcome", {
parentEventId: "mock-general-welcome",
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: Math.floor(Date.now() / 1000) - 10,
});
const threadSummary = page.getByTestId("message-thread-summary").first();
await expect(threadSummary).toBeVisible();
await threadSummary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
// Leave, emit unread replies, return — badge appears (same as test 01).
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
const base = unreadTimestamp();
for (let i = 0; i < 3; i++) {
await emitMockMessage(page, "general", `Unread reply ${i + 1}`, {
parentEventId: "mock-general-welcome",
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: base + i,
});
}
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const badge = page.getByTestId("thread-unread-badge");
await expect(badge).toBeVisible();
await expect(badge).toContainText("3");
await page.screenshot({
path: `${SHOTS}/13-thread-badge-before-read.png`,
});
// The crux: open the thread (mark-read advances the frontier past all three
// direct replies), then close it. Stay in general the entire time — no
// channel switch. The badge must clear to zero off the readStateVersion
// recompute alone. Before the BUG-2 fix it would persist at 3 here.
await page.getByTestId("message-thread-summary").first().click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(badge).toHaveCount(0);
await page.screenshot({
path: `${SHOTS}/13-thread-badge-clears-on-read.png`,
});
});
// Regression guard for the mention-gate + subtree-count fixes. The viewer is
// a pure MENTION RECIPIENT of a nested reply in a thread they never authored,
// participated in, or followed: root `mock-general-alice` (Alice-authored) ->
// reply A (Alice) -> reply B (Alice, @-mentions self). This fails pre-fix on
// TWO independent defects:
// 1. The badge gate `isNotifiedForThread` had no mention term, so a
// recipient who never participated/authored/followed gated false and the
// badge never appeared at all.
// 2. `computeThreadBadgeCounts` counted only the root's DIRECT children, so
// the nested mention reply B (under A) was never tallied toward the root.
// After the gate fix the badge appears but undercounts (1, missing B); only
// after the subtree-count fix does it reach 2. Asserting `2` gates both.
test("14-mention-only-nested-thread-badge", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
// The viewer never replies, authors, or follows Alice's thread — they are a
// pure mention recipient. Leave general so it goes inactive, then emit the
// unread chain on a thread that was never read: reply A (direct child of
// the root) and reply B (nested under A) that @-mentions the viewer. With
// no prior read the root's frontier seeds to null, so the whole subtree
// counts unread — A + B = 2. The emitter derives B's true rootId from A's
// stored thread reference, so B lands in Alice's thread at depth 2.
const aliceSummary = page.locator(
'[data-thread-head-id="mock-general-alice"]',
);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
const base = unreadTimestamp();
const replyA = await emitMockMessage(page, "general", "Reply A (depth 1)", {
parentEventId: "mock-general-alice",
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: base,
});
await emitMockMessage(page, "general", "Reply B mentioning you (depth 2)", {
parentEventId: replyA?.id,
pubkey: TEST_IDENTITIES.alice.pubkey,
mentionPubkeys: [SELF_PUBKEY],
createdAt: base + 1,
});
// Return to general. The mention on the nested reply must surface a badge on
// Alice's root — proving the gate now honors mentions — and the count must
// span the subtree (A + B = 2), proving the count walks past direct
// children. Pre-fix: no badge; post-gate-only: badge "1"; both fixes: "2".
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const badge = aliceSummary.getByTestId("thread-unread-badge");
await expect(badge).toBeVisible();
await expect(badge).toContainText("2");
// Clearing a NESTED unread mirrors the channel-open read model: opening the
// thread consumes only the visible direct replies (A), so the badge drops
// to "1" — the mention reply B stays unread inside A's collapsed branch.
await aliceSummary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await expect(badge).toContainText("1");
// Drilling into A's branch advances the frontier over B, the deepest reply,
// and the badge clears in place. Stays in general — no channel switch.
await expandReply(page, replyA?.id ?? "");
await page.getByTestId("message-thread-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(badge).toHaveCount(0);
});
});