Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";

import { computeThreadReplyUnreadCounts } from "./threadReplyUnreadCounts.ts";
import {
computeThreadReplyUnreadCounts,
unionScopeMessages,
} from "./threadReplyUnreadCounts.ts";

// Open thread "root":
// root(100)
Expand Down Expand Up @@ -177,3 +180,55 @@ test("computeThreadReplyUnreadCounts_forcedUnread_relightsReadDescendant", () =>
assert.equal(counts.get("a"), 1); // a1 forced unread
assert.equal(counts.has("b"), false); // b subtree still read
});

// --- block/buzz#3799 (scope-b): depth-2+ replies outside the channel window ---

test("unionScopeMessages dedupes by id and preserves order", () => {
const windowMessages = [
{ id: "a", createdAt: 1, parentId: null },
{ id: "b", createdAt: 2, parentId: null },
];
const extras = [
{ id: "b", createdAt: 2, parentId: null },
{ id: "c", createdAt: 3, parentId: null },
];
assert.deepEqual(
unionScopeMessages(windowMessages, extras).map((m) => m.id),
["a", "b", "c"],
);
// Empty extras return the input reference unchanged (memo-friendly).
assert.equal(unionScopeMessages(windowMessages, []), windowMessages);
});

test("computeThreadReplyUnreadCounts with scoped union counts a depth-2 reply outside the channel window (#3799)", () => {
// Read-line 550: b2(600) is unread but lives ONLY in the open thread's
// fetched reply set (threadReplyEvents) — the channel-window projection
// never materialized it. The hook must union the scope before computing,
// or collapsed b renders badge-less and the reply is invisible (#3799).
const windowMessages = fixture().filter((m) => m.id !== "b2");
const openThreadExtras = [{ id: "b2", createdAt: 600, parentId: "b1" }];
const counts = computeThreadReplyUnreadCounts({
timelineMessages: unionScopeMessages(windowMessages, openThreadExtras),
subtreeReplyIds: ROOT_SUBTREE,
visibleReplyIds: ["a", "b"],
expandedReplyIds: new Set(),
getReadAt: uniformReadAt(550),
});
assert.equal(counts.get("b"), 1); // b2
assert.equal(counts.has("a"), false); // a1(400) read at 550
});

test("computeThreadReplyUnreadCounts without the union misses the window-external reply (documents #3799 pre-fix)", () => {
// Baseline negative control: feeding only the channel-window projection
// yields a zero badge for b even though b2 is unread — exactly the bug
// shape. The union above is the fix; this pins the before/after contrast.
const windowMessages = fixture().filter((m) => m.id !== "b2");
const counts = computeThreadReplyUnreadCounts({
timelineMessages: windowMessages,
subtreeReplyIds: ROOT_SUBTREE,
visibleReplyIds: ["a", "b"],
expandedReplyIds: new Set(),
getReadAt: uniformReadAt(550),
});
assert.equal(counts.has("b"), false);
});
23 changes: 23 additions & 0 deletions desktop/src/features/channels/lib/threadReplyUnreadCounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,29 @@ import type { TimelineMessage } from "@/features/messages/types";
* @param isForcedUnread Session-local OR-overlay: a reply forced unread this
* session counts regardless of its marker (per-message mark-unread).
*/
/**
* Union the channel-window timeline with extra messages (e.g. the open
* thread's fetched replies) by id. Needed because fresh depth-2+ replies
* materialize in `threadRepliesKey` before the channel-window projection
* re-materializes from the event store (block/buzz#3799 scope-b): without
* this union the descendant-aware unread subtree walk never sees them and a
* collapsed ancestor row renders badge-less.
*/
export function unionScopeMessages(
timelineMessages: TimelineMessage[],
extraMessages: readonly TimelineMessage[],
): TimelineMessage[] {
if (extraMessages.length === 0) {
return timelineMessages;
}
const seen = new Set(timelineMessages.map((message) => message.id));
const additions = extraMessages.filter((message) => !seen.has(message.id));
if (additions.length === 0) {
return timelineMessages;
}
return [...timelineMessages, ...additions];
}

export function computeThreadReplyUnreadCounts(params: {
timelineMessages: TimelineMessage[];
subtreeReplyIds: Iterable<string>;
Expand Down
19 changes: 19 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
} from "@/features/messages/lib/timelineLoadingState";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel";
import { useThreadPanelInitialExpansion } from "@/features/messages/useThreadPanelInitialExpansion";
import { useThreadReplies } from "@/features/messages/useThreadReplies";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import type { TimelineMessage } from "@/features/messages/types";
Expand Down Expand Up @@ -433,6 +434,23 @@ export function ChannelScreen({
messages: timelineMessages,
onSearchHit: handleFindSearchHit,
});
// block/buzz#3799: seed the panel's initial expansion from the ancestors of
// any depth-2+ reply so an agent reply anchored to a *non-latest* message
// renders on first paint instead of hiding inside a collapsed summary row.
useThreadPanelInitialExpansion({
activeChannel,
threadReplyEvents,
openThreadHeadId: effectiveOpenThreadHeadId,
setExpandedThreadReplyIds,
currentPubkey,
currentAvatarUrl: currentProfile?.avatarUrl ?? null,
profiles: messageProfiles,
members: channelMembers,
personaLookup,
respondToLookup,
relaySelfPubkey,
ownerProfiles: messageOwnerProfiles,
});
const threadPanelData = useIndependentThreadPanel({
activeChannel,
channelEvents: resolvedMessages,
Expand Down Expand Up @@ -471,6 +489,7 @@ export function ChannelScreen({
threadReplyTargetId,
expandedThreadReplyIds,
openThreadMessages: threadPanelData.visibleReplies,
activeThreadMessages: threadPanelData.messages,
getChannelReadAt,
getMessageReadAt,
markChannelUnread,
Expand Down
45 changes: 40 additions & 5 deletions desktop/src/features/channels/ui/useChannelUnreadState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
buildRepliesByRootId,
collectReplyDescendantIds,
} from "@/features/channels/lib/subtreeCreatedAt";
import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
import {
computeThreadReplyUnreadCounts,
unionScopeMessages,
} from "@/features/channels/lib/threadReplyUnreadCounts";
import { computeThreadBadgeCounts } from "@/features/channels/lib/threadBadgeCounts";
import {
useStableArrayShallow,
Expand Down Expand Up @@ -34,6 +37,7 @@ type UseChannelUnreadStateOptions = {
threadReplyTargetId: string | null;
expandedThreadReplyIds: ReadonlySet<string>;
openThreadMessages?: MainTimelineEntry[];
activeThreadMessages?: TimelineMessage[];
getChannelReadAt: (channelId: string) => number | null;
getMessageReadAt: (messageId: string) => number | null;
markChannelUnread: (channelId: string) => void;
Expand Down Expand Up @@ -62,6 +66,7 @@ export function useChannelUnreadState({
threadReplyTargetId,
expandedThreadReplyIds,
openThreadMessages,
activeThreadMessages,
getChannelReadAt,
getMessageReadAt,
markChannelUnread,
Expand Down Expand Up @@ -140,6 +145,22 @@ export function useChannelUnreadState({
() => buildDirectReplyIdsByParentId(timelineMessages),
[timelineMessages],
);
// block/buzz#3799 (scope-b): build one *widened* id-tree over the
// channel-window timeline ∪ the open thread's fetched message set, so the
// per-row subtree-unread walk reaches a fresh depth-2+ reply that lives
// only in `threadRepliesKey` while the channel-window projection lags.
// Kept separate from directReplyIdsByParentId because it is consumed only
// by threadReplyUnreadCounts; the other memoized readers stay window-scoped.
const widenedDirectReplyIdsByParentId = React.useMemo(() => {
if (!activeThreadMessages || activeThreadMessages.length === 0) {
return directReplyIdsByParentId;
}
const seen = new Set(timelineMessages.map((m) => m.id));
const additions = activeThreadMessages.filter((m) => !seen.has(m.id));
if (additions.length === 0) return directReplyIdsByParentId;
const unioned = [...timelineMessages, ...additions];
return buildDirectReplyIdsByParentId(unioned);
}, [activeThreadMessages, directReplyIdsByParentId, timelineMessages]);
const repliesByRootId = React.useMemo(
() => buildRepliesByRootId(timelineMessages),
[timelineMessages],
Expand All @@ -153,6 +174,11 @@ export function useChannelUnreadState({
collectReplyDescendantIds(messageId, directReplyIdsByParentId),
[directReplyIdsByParentId],
);
const getReplyDescendantIdsForMessageWidened = React.useCallback(
(messageId: string) =>
collectReplyDescendantIds(messageId, widenedDirectReplyIdsByParentId),
[widenedDirectReplyIdsByParentId],
);
const createdAtByMessageId = React.useMemo(
() => buildCreatedAtByMessageId(timelineMessages),
[timelineMessages],
Expand Down Expand Up @@ -307,13 +333,22 @@ export function useChannelUnreadState({
// unread descendant with no separate expanded-subtree gate. readStateVersion
// is an intentional recompute trigger so the counts re-read after any marker
// advances.
// block/buzz#3799 (scope-b): the scope must be the UNION of the channel
// window projection and the open thread's fetched message set — a fresh
// depth-2+ reply may live only in `threadRepliesKey` while the window
// projection lags, and without it the collapsed ancestor row's badge is 0.
const threadUnreadScope = React.useMemo(
() => unionScopeMessages(timelineMessages, activeThreadMessages ?? []),
[timelineMessages, activeThreadMessages],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
const threadReplyUnreadCounts = React.useMemo(
() =>
openThreadHeadId
? computeThreadReplyUnreadCounts({
timelineMessages,
subtreeReplyIds: getReplyDescendantIdsForMessage(openThreadHeadId),
timelineMessages: threadUnreadScope,
subtreeReplyIds:
getReplyDescendantIdsForMessageWidened(openThreadHeadId),
visibleReplyIds: threadMessages.map((entry) => entry.message.id),
expandedReplyIds: expandedThreadReplyIds,
getReadAt: getMessageReadAt,
Expand All @@ -324,10 +359,10 @@ export function useChannelUnreadState({
[
openThreadHeadId,
threadMessages,
timelineMessages,
threadUnreadScope,
getMessageReadAt,
expandedThreadReplyIds,
getReplyDescendantIdsForMessage,
getReplyDescendantIdsForMessageWidened,
currentPubkey,
isMsgForcedUnread,
readStateVersion,
Expand Down
Loading