Skip to content

Commit 4d1d828

Browse files
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7wpfleger96
andcommitted
fix(desktop): correct thread-unread badge staleness and phantom channel counts
Three live defects in the shipped thread-unread badge (#1069): - Flaky badge: participatedRootIds/authoredRootIds were bare in-place-mutated ref Sets, so isNotifiedForThread's useCallback never re-created and a badge whose participation was discovered async stayed suppressed. A membershipVersion reducer now re-derives identity-stable snapshots for the gate while live consumers keep reading the mutable refs. - Badge wouldn't clear on read: the threadUnreadCounts memo read a frozen open-time frontier snapshot and omitted readStateVersion from its deps. The snapshot now advances monotonically toward the live thread marker (never the latest reply, so collapsed-branch unreads survive) keyed on readStateVersion. - Phantom channel-pill counts: the marker counted relay-signed system rows and job-lifecycle events. A call-site kind predicate excludes them; unreadMarker stays kind-agnostic. Undefined kinds are treated as conversational so pending rows are never dropped. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent 4f93d52 commit 4d1d828

8 files changed

Lines changed: 355 additions & 43 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { computeThreadUnreadMarker } from "@/features/messages/lib/unreadMarker";
2+
import type { TimelineMessage } from "@/features/messages/types";
3+
4+
/**
5+
* Per-thread unread reply counts for the summary rows in the main timeline.
6+
*
7+
* Counts are computed only for threads the user has notification interest in
8+
* (`isNotified`), aligning the badge display with the read-state write path,
9+
* and measured against the per-root frontier snapshot rather than the live
10+
* marker so badges stay stable for the session and don't flash when
11+
* markChannelRead advances the channel marker. The snapshot is advanced toward
12+
* the live marker on read upstream of this function, so a read thread's badge
13+
* clears here once its frontier passes the last reply.
14+
*
15+
* @param messages Timeline messages (top-level entries plus their replies) in
16+
* chronological order.
17+
* @param frontiers Per-root read frontier in unix seconds, or null/undefined
18+
* when the thread was never read (every reply counts unread).
19+
* @param isNotified Whether a thread root is one the user is notified for.
20+
* @param currentPubkey Replies authored by this pubkey never count as unread.
21+
*/
22+
export function computeThreadBadgeCounts(
23+
messages: TimelineMessage[],
24+
frontiers: ReadonlyMap<string, number | null> | undefined,
25+
isNotified: (rootId: string) => boolean,
26+
currentPubkey?: string,
27+
): Map<string, number> {
28+
const counts = new Map<string, number>();
29+
for (const message of messages) {
30+
if (message.parentId) continue;
31+
if (!isNotified(message.id)) continue;
32+
const directReplies = messages.filter((m) => m.parentId === message.id);
33+
if (directReplies.length === 0) continue;
34+
const { unreadCount } = computeThreadUnreadMarker(
35+
directReplies,
36+
frontiers?.get(message.id) ?? null,
37+
currentPubkey,
38+
);
39+
if (unreadCount > 0) {
40+
counts.set(message.id, unreadCount);
41+
}
42+
}
43+
return counts;
44+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { nextThreadBadgeFrontier } from "./threadBadgeFrontier.ts";
5+
import { seedThreadBadgeFrontiers } from "./threadBadgeFrontier.ts";
6+
7+
const msg = (id, parentId) => ({ id, parentId });
8+
const seedAll = () => true;
9+
10+
test("nextThreadBadgeFrontier_unseededNullMarker_seedsNull", () => {
11+
// Thread never read: snapshot seeds to null (everything unread).
12+
assert.equal(nextThreadBadgeFrontier(undefined, null), null);
13+
});
14+
15+
test("nextThreadBadgeFrontier_unseededWithMarker_seedsToMarker", () => {
16+
assert.equal(nextThreadBadgeFrontier(undefined, 100), 100);
17+
});
18+
19+
test("nextThreadBadgeFrontier_readAdvancesMarker_advancesSnapshot", () => {
20+
// Snapshot frozen at open (null), user reads → live marker 200 → badge clears.
21+
assert.equal(nextThreadBadgeFrontier(null, 200), 200);
22+
});
23+
24+
test("nextThreadBadgeFrontier_markerNewerThanStored_advances", () => {
25+
assert.equal(nextThreadBadgeFrontier(100, 250), 250);
26+
});
27+
28+
test("nextThreadBadgeFrontier_markerOlderThanStored_keepsStored", () => {
29+
// Monotonic: a stale lower marker never lowers the snapshot.
30+
assert.equal(nextThreadBadgeFrontier(250, 100), 250);
31+
});
32+
33+
test("nextThreadBadgeFrontier_markerNullAfterSeed_keepsStored", () => {
34+
// Live marker reads null (never read) but snapshot already advanced — hold.
35+
assert.equal(nextThreadBadgeFrontier(150, null), 150);
36+
});
37+
38+
test("nextThreadBadgeFrontier_markerEqualsStored_unchanged", () => {
39+
assert.equal(nextThreadBadgeFrontier(150, 150), 150);
40+
});
41+
42+
test("nextThreadBadgeFrontier_storedNullMarkerZero_advancesToZero", () => {
43+
// Zero is a valid frontier (epoch); null is strictly lower than any number.
44+
assert.equal(nextThreadBadgeFrontier(null, 0), 0);
45+
});
46+
47+
test("seedThreadBadgeFrontiers_threadWithReplies_seedsToMarker", () => {
48+
const frontiers = new Map();
49+
const messages = [msg("root", null), msg("r1", "root")];
50+
seedThreadBadgeFrontiers(frontiers, messages, seedAll, (id) =>
51+
id === "root" ? 100 : null,
52+
);
53+
assert.equal(frontiers.get("root"), 100);
54+
});
55+
56+
test("seedThreadBadgeFrontiers_threadWithoutReplies_skipped", () => {
57+
const frontiers = new Map();
58+
seedThreadBadgeFrontiers(frontiers, [msg("root", null)], seedAll, () => 100);
59+
assert.equal(frontiers.has("root"), false);
60+
});
61+
62+
test("seedThreadBadgeFrontiers_notNotified_skipped", () => {
63+
const frontiers = new Map();
64+
const messages = [msg("root", null), msg("r1", "root")];
65+
seedThreadBadgeFrontiers(
66+
frontiers,
67+
messages,
68+
() => false,
69+
() => 100,
70+
);
71+
assert.equal(frontiers.has("root"), false);
72+
});
73+
74+
test("seedThreadBadgeFrontiers_replyEntry_neverSeeded", () => {
75+
// A reply is never a badge root even if its id collides with a notified set.
76+
const frontiers = new Map();
77+
const messages = [msg("r1", "root"), msg("r2", "root")];
78+
seedThreadBadgeFrontiers(frontiers, messages, seedAll, () => 100);
79+
assert.equal(frontiers.size, 0);
80+
});
81+
82+
test("seedThreadBadgeFrontiers_reseed_advancesMonotonically", () => {
83+
const frontiers = new Map([["root", 100]]);
84+
const messages = [msg("root", null), msg("r1", "root")];
85+
// Re-render after the live marker advanced to 250 on read.
86+
seedThreadBadgeFrontiers(frontiers, messages, seedAll, () => 250);
87+
assert.equal(frontiers.get("root"), 250);
88+
// A stale lower marker never lowers an already-advanced snapshot.
89+
seedThreadBadgeFrontiers(frontiers, messages, seedAll, () => 100);
90+
assert.equal(frontiers.get("root"), 250);
91+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { TimelineMessage } from "@/features/messages/types";
2+
3+
// Decide the next value for a thread's badge frontier snapshot. The snapshot is
4+
// seeded once at channel-open (reflecting "what was unread on open") and then
5+
// advanced monotonically toward the live thread read marker as the user reads,
6+
// so the badge clears after a read without waiting for channel re-entry.
7+
//
8+
// The advance target is ALWAYS the live marker (what the user actually
9+
// consumed), never "latest reply": a subsequent reply newer than the marker
10+
// re-raises the badge, and a collapsed-branch reply the marker never covered
11+
// stays unread. Monotonic `Math.max` guards against a stale lower marker.
12+
//
13+
// Returns the value the snapshot should hold:
14+
// - `stored === undefined` (unseeded): seed to the live marker.
15+
// - otherwise: the greater of the stored snapshot and the live marker, where
16+
// `null` (never read) is the lowest possible frontier.
17+
export function nextThreadBadgeFrontier(
18+
stored: number | null | undefined,
19+
liveMarker: number | null,
20+
): number | null {
21+
if (stored === undefined) {
22+
return liveMarker;
23+
}
24+
if (liveMarker === null) {
25+
return stored;
26+
}
27+
if (stored === null) {
28+
return liveMarker;
29+
}
30+
return Math.max(stored, liveMarker);
31+
}
32+
33+
// Seed/advance the per-root badge frontier snapshots for one channel, in place.
34+
// Captures only top-level notified threads that have replies; each entry is
35+
// seeded once at open then advanced toward the live marker on subsequent reads
36+
// (see nextThreadBadgeFrontier). Called during render so snapshots reflect
37+
// "what was unread on open," matching the openFrontierRef pattern.
38+
export function seedThreadBadgeFrontiers(
39+
channelFrontiers: Map<string, number | null>,
40+
messages: TimelineMessage[],
41+
isNotified: (rootId: string) => boolean,
42+
getReadAt: (rootId: string) => number | null,
43+
): void {
44+
for (const message of messages) {
45+
if (message.parentId) continue;
46+
if (!isNotified(message.id)) continue;
47+
if (!messages.some((m) => m.parentId === message.id)) continue;
48+
channelFrontiers.set(
49+
message.id,
50+
nextThreadBadgeFrontier(
51+
channelFrontiers.get(message.id),
52+
getReadAt(message.id),
53+
),
54+
);
55+
}
56+
}

desktop/src/features/channels/ui/ChannelScreen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export function ChannelScreen({
9191
unfollowThread,
9292
isFollowingThread,
9393
isNotifiedForThread,
94+
readStateVersion,
9495
setTopbarSearchHidden,
9596
} = useAppShell();
9697
const {
@@ -369,6 +370,7 @@ export function ChannelScreen({
369370
markChannelUnread,
370371
markThreadRead,
371372
isNotifiedForThread,
373+
readStateVersion,
372374
});
373375
const editTargetMessage = React.useMemo(
374376
() =>

desktop/src/features/channels/ui/useChannelUnreadState.ts

Lines changed: 38 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
subtreeMaxCreatedAt,
99
} from "@/features/channels/lib/subtreeCreatedAt";
1010
import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
11+
import { computeThreadBadgeCounts } from "@/features/channels/lib/threadBadgeCounts";
12+
import { seedThreadBadgeFrontiers } from "@/features/channels/lib/threadBadgeFrontier";
1113
import {
1214
buildThreadPanelDataFromIndex,
1315
buildThreadPanelIndex,
@@ -17,6 +19,7 @@ import {
1719
computeThreadUnreadMarker,
1820
} from "@/features/messages/lib/unreadMarker";
1921
import type { TimelineMessage } from "@/features/messages/types";
22+
import { isConversationalUnreadKind } from "@/shared/constants/kinds";
2023

2124
import { useWelcomeInitialUnreadSuppression } from "./useWelcomeInitialUnreadSuppression";
2225

@@ -33,6 +36,7 @@ type UseChannelUnreadStateOptions = {
3336
markChannelUnread: (channelId: string) => void;
3437
markThreadRead: (rootId: string, timestamp: number) => void;
3538
isNotifiedForThread: (rootId: string) => boolean;
39+
readStateVersion: number;
3640
};
3741

3842
/**
@@ -60,6 +64,7 @@ export function useChannelUnreadState({
6064
markChannelUnread,
6165
markThreadRead,
6266
isNotifiedForThread,
67+
readStateVersion,
6368
}: UseChannelUnreadStateOptions) {
6469
// Capture the read frontier as it stood the instant this channel was opened,
6570
// BEFORE the mark-read effect (in ChannelScreen) advances it to latest.
@@ -166,10 +171,14 @@ export function useChannelUnreadState({
166171

167172
// Oldest unread top-level message + count from the open-time frontier.
168173
// Keyed per channel so the pill/divider survive the mark-read effect.
174+
// Non-conversational kinds (system rows, job-lifecycle events) are filtered
175+
// out first so they don't inflate the pill; see isConversationalUnreadKind.
169176
const { firstUnreadMessageId, unreadCount } = React.useMemo(
170177
() =>
171178
computeChannelUnreadMarker(
172-
timelineMessages,
179+
timelineMessages.filter((message) =>
180+
isConversationalUnreadKind(message.kind),
181+
),
173182
openFrontierSeconds,
174183
isActiveChannelForcedUnread || isActiveWelcomeInitialUnreadSuppressed,
175184
currentPubkey,
@@ -300,17 +309,12 @@ export function useChannelUnreadState({
300309
channelFrontiers = new Map();
301310
threadBadgeFrontiersRef.current.set(activeChannelId, channelFrontiers);
302311
}
303-
for (const message of timelineMessages) {
304-
if (message.parentId) continue;
305-
if (!isNotifiedForThread(message.id)) continue;
306-
if (channelFrontiers.has(message.id)) continue;
307-
// Only capture for messages that have thread replies
308-
const hasReplies = timelineMessages.some(
309-
(m) => m.parentId === message.id,
310-
);
311-
if (!hasReplies) continue;
312-
channelFrontiers.set(message.id, getThreadReadAt(message.id));
313-
}
312+
seedThreadBadgeFrontiers(
313+
channelFrontiers,
314+
timelineMessages,
315+
isNotifiedForThread,
316+
getThreadReadAt,
317+
);
314318
}
315319
// Clear the thread badge frontiers on channel leave (same cleanup as
316320
// openFrontierRef) so re-visiting captures fresh snapshots.
@@ -321,36 +325,29 @@ export function useChannelUnreadState({
321325
threadBadgeFrontiersRef.current.delete(channelId);
322326
};
323327
}, [activeChannelId]);
324-
// Compute per-thread unread counts for summary rows in the main timeline.
325-
// Only compute for threads the user has notification interest in — this
326-
// aligns the badge display with the read-state write path. Uses the
327-
// snapshotted frontier (threadBadgeFrontiersRef) so badges are stable for
328-
// the session and don't flash when markChannelRead advances the channel
329-
// marker.
330-
const threadUnreadCounts = React.useMemo(() => {
331-
const counts = new Map<string, number>();
332-
const channelFrontiers = activeChannelId
333-
? threadBadgeFrontiersRef.current.get(activeChannelId)
334-
: undefined;
335-
for (const message of timelineMessages) {
336-
if (message.parentId) continue;
337-
if (!isNotifiedForThread(message.id)) continue;
338-
const directReplies = timelineMessages.filter(
339-
(m) => m.parentId === message.id,
340-
);
341-
if (directReplies.length === 0) continue;
342-
const frontier = channelFrontiers?.get(message.id) ?? null;
343-
const { unreadCount } = computeThreadUnreadMarker(
344-
directReplies,
345-
frontier,
328+
// Per-thread unread counts for the main-timeline summary rows. Pure logic
329+
// lives in computeThreadBadgeCounts; readStateVersion is an intentional
330+
// recompute trigger so the badge re-reads the snapshot the seed block above
331+
// advanced toward the live marker on mark-read.
332+
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
333+
const threadUnreadCounts = React.useMemo(
334+
() =>
335+
computeThreadBadgeCounts(
336+
timelineMessages,
337+
activeChannelId
338+
? threadBadgeFrontiersRef.current.get(activeChannelId)
339+
: undefined,
340+
isNotifiedForThread,
346341
currentPubkey,
347-
);
348-
if (unreadCount > 0) {
349-
counts.set(message.id, unreadCount);
350-
}
351-
}
352-
return counts;
353-
}, [activeChannelId, currentPubkey, timelineMessages, isNotifiedForThread]);
342+
),
343+
[
344+
activeChannelId,
345+
currentPubkey,
346+
timelineMessages,
347+
isNotifiedForThread,
348+
readStateVersion,
349+
],
350+
);
354351

355352
const handleMarkUnread = React.useCallback(() => {
356353
if (!activeChannelId) return;

0 commit comments

Comments
 (0)