From ae835ca03367d8acbed03b7882037fb43acede23 Mon Sep 17 00:00:00 2001 From: StephGlansberg Date: Thu, 23 Jul 2026 12:19:07 -0700 Subject: [PATCH 1/5] fix(desktop): flatten private/DM timelines without stripping reply tags Show reply-tagged events inline in private rooms and DMs so Nexus chat reads as a normal feed, while keeping NIP-10 anchors for ACP receipts. Co-authored-by: Cursor Signed-off-by: StephGlansberg Co-authored-by: Cursor --- desktop/scripts/check-file-sizes.mjs | 6 +- .../src/features/channels/ui/ChannelPane.tsx | 12 +- desktop/src/features/messages/hooks.ts | 39 ++++++- .../messages/lib/channelMainTimeline.ts | 31 ++++++ .../lib/flattenChannelTimeline.test.mjs | 105 ++++++++++++++++++ .../messages/lib/flattenChannelTimeline.ts | 62 +++++++++++ .../messages/lib/pageOlderMessages.ts | 57 +++++++++- .../messages/lib/threadPanel.test.mjs | 43 +++++++ .../src/features/messages/lib/threadPanel.ts | 49 +++++--- .../src/features/messages/lib/threading.ts | 18 ++- .../features/messages/ui/MessageTimeline.tsx | 7 ++ .../messages/ui/TimelineMessageList.tsx | 12 +- .../messages/useFetchOlderMessages.ts | 3 +- 13 files changed, 413 insertions(+), 31 deletions(-) create mode 100644 desktop/src/features/messages/lib/channelMainTimeline.ts create mode 100644 desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs create mode 100644 desktop/src/features/messages/lib/flattenChannelTimeline.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index c57e0e982e..df1649eb9f 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -50,6 +50,10 @@ const rules = [ // Do not add to this list; split the file instead. Remove each entry as its // file is broken up. Tracked as a follow-up. const overrides = new Map([ + // Private/DM timeline flatten for AEON Aspect offices: extracted helpers keep + // growth tiny, but biome's import/export blank leaves this 1 line over the + // ceiling. Queued to split with the rest of ChannelPane. + ["src/features/channels/ui/ChannelPane.tsx", 1005], // Native Builderlab auth/community commands add a small registration surface // to the existing Tauri composition root. The implementation lives in // builderlab.rs; this narrowly ratchets the command wiring while lib.rs is @@ -75,7 +79,7 @@ const overrides = new Map([ // is test-only content; the override covers the test growth accumulated // across the local-archive + agent-metric-archive PR series. store_tests.rs // (~731 lines) is under 1000 so needs no override. - ["src-tauri/src/archive/mod_tests.rs", 1208], + ["src-tauri/src/archive/mod_tests.rs", 1304], // unified-agent-model 1A.1: profile reconcile split to agents_profile.rs, // ratcheting 1443 -> 1295. Queued to split further in the A2 fold. // global-agent-config: resolve_deploy_model_provider + visibility exports diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 121e211f47..75734dcd0b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -58,7 +58,7 @@ import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types" import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel"; import { Button } from "@/shared/ui/button"; -import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import { buildChannelMainTimelineEntries } from "@/features/messages/lib/channelMainTimeline"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; @@ -66,6 +66,7 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; + export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -462,15 +463,15 @@ export const ChannelPane = React.memo(function ChannelPane({ return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); - const mainTimelineEntries = React.useMemo( + const { entries: mainTimelineEntries, flattenReplies } = React.useMemo( () => - buildMainTimelineEntries( + buildChannelMainTimelineEntries( + activeChannel, visibleMessages, - new Set(), threadSummaries, profiles, ), - [profiles, threadSummaries, visibleMessages], + [activeChannel, profiles, threadSummaries, visibleMessages], ); useRenderScopedReactionHydration({ activeChannel, @@ -674,6 +675,7 @@ export const ChannelPane = React.memo(function ChannelPane({ entranceMessageId={entranceMessageId} onEntranceMessageComplete={onEntranceMessageComplete} mainEntries={mainTimelineEntries} + flattenReplies={flattenReplies} threadSummaries={threadSummaries} messages={visibleMessages} firstUnreadMessageId={firstUnreadMessageId} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..a66dca0be1 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -13,12 +13,18 @@ import { isBroadcastReply, normalizeMentionPubkeys, resolveReplyRootId, + shouldFlattenChannelTimeline, } from "@/features/messages/lib/threading"; import { projectChannelWindowMessages, refreshChannelWindowMessages, } from "@/features/messages/lib/projectChannelWindow"; import { reconcileChannelWindowMessages } from "@/features/messages/lib/channelWindowReconciliation"; +import { + fetchFlattenTimelineReplies, + flattenTimelineRootIds, + mergeFlattenTimelineReplies, +} from "@/features/messages/lib/flattenChannelTimeline"; import { mergeMessages, mergeTimelineCacheMessages, @@ -226,6 +232,31 @@ export function useChannelWindowQuery(channel: Channel | null) { }); } +async function withFlattenedTimelineReplies( + channel: Channel, + window: ChannelWindowStore, + messages: RelayEvent[], +): Promise { + if (!shouldFlattenChannelTimeline(channel)) { + return messages; + } + const rootIds = flattenTimelineRootIds(window); + if (rootIds.length === 0) { + return messages; + } + try { + const replies = await fetchFlattenTimelineReplies(channel.id, rootIds); + return mergeFlattenTimelineReplies(messages, replies); + } catch (error) { + console.error( + "Failed to hydrate flattened timeline replies for channel", + channel.id, + error, + ); + return messages; + } +} + export function useChannelMessagesQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); @@ -245,7 +276,8 @@ export function useChannelMessagesQuery(channel: Channel | null) { emptyChannelWindowStore(); const next = replaceNewestChannelWindow(current, page); queryClient.setQueryData(windowKey, next); - return reconcileChannelWindowMessages(next, previousMessages); + const reconciled = reconcileChannelWindowMessages(next, previousMessages); + return withFlattenedTimelineReplies(channel, next, reconciled); }, staleTime: 5 * 60 * 1_000, gcTime: 60 * 60 * 1_000, @@ -256,6 +288,7 @@ export function useChannelSubscription(channel: Channel | null) { const queryClient = useQueryClient(); const channelId = channel?.id ?? null; const channelType = channel?.channelType ?? null; + const flattenTimeline = shouldFlattenChannelTimeline(channel); const refreshNewestWindow = useEffectEvent(async () => { if (!channelId) return; await refreshChannelWindowMessages(queryClient, channelId); @@ -288,7 +321,9 @@ export function useChannelSubscription(channel: Channel | null) { (current = []) => mergeMessages(current, event), ); } - if (!isBroadcastReply(event.tags)) return; + // Private/DM flatten: keep NIP-10 tags, but still admit the event into + // the live window overlay so it renders as a normal timeline row. + if (!isBroadcastReply(event.tags) && !flattenTimeline) return; } if (!isTimelineRow && !CHANNEL_AUX_KINDS.has(event.kind)) return; if (!isTimelineRow) { diff --git a/desktop/src/features/messages/lib/channelMainTimeline.ts b/desktop/src/features/messages/lib/channelMainTimeline.ts new file mode 100644 index 0000000000..9483c99018 --- /dev/null +++ b/desktop/src/features/messages/lib/channelMainTimeline.ts @@ -0,0 +1,31 @@ +import type { Channel } from "@/shared/api/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; +import { + buildMainTimelineEntries, + type MainTimelineEntry, +} from "@/features/messages/lib/threadPanel"; +import { shouldFlattenChannelTimeline } from "@/features/messages/lib/threading"; +import type { TimelineMessage } from "@/features/messages/types"; + +/** Main-timeline entries with private/DM reply flatten applied when appropriate. */ +export function buildChannelMainTimelineEntries( + channel: Pick | null | undefined, + messages: TimelineMessage[], + threadSummaries: ReadonlyMap, + profiles?: UserProfileLookup, +): { entries: MainTimelineEntry[]; flattenReplies: boolean } { + const flattenReplies = shouldFlattenChannelTimeline(channel); + return { + flattenReplies, + entries: buildMainTimelineEntries( + messages, + new Set(), + threadSummaries, + profiles, + { + flattenReplies, + }, + ), + }; +} diff --git a/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs b/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs new file mode 100644 index 0000000000..4f7f37051e --- /dev/null +++ b/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + mergeFlattenTimelineReplies, + flattenTimelineRootIds, +} from "./flattenChannelTimeline.ts"; +import { shouldFlattenChannelTimeline } from "./threading.ts"; + +test("shouldFlattenChannelTimeline is true for private rooms and DMs only", () => { + assert.equal( + shouldFlattenChannelTimeline({ + channelType: "stream", + visibility: "private", + }), + true, + ); + assert.equal( + shouldFlattenChannelTimeline({ channelType: "dm", visibility: "private" }), + true, + ); + assert.equal( + shouldFlattenChannelTimeline({ channelType: "stream", visibility: "open" }), + false, + ); + assert.equal(shouldFlattenChannelTimeline(null), false); +}); + +test("mergeFlattenTimelineReplies preserves reply tags and dedupes by id", () => { + const root = { + id: "root", + pubkey: "a", + created_at: 1, + kind: 9, + tags: [["h", "channel"]], + content: "root", + sig: "", + }; + const reply = { + id: "reply", + pubkey: "b", + created_at: 2, + kind: 9, + tags: [ + ["h", "channel"], + ["e", "root", "", "reply"], + ], + content: "reply", + sig: "", + }; + + const merged = mergeFlattenTimelineReplies([root], [reply, reply]); + assert.deepEqual( + merged.map((event) => event.id), + ["root", "reply"], + ); + assert.deepEqual(merged[1].tags, reply.tags); +}); + +test("flattenTimelineRootIds reads page and live summary roots", () => { + const store = { + pages: [ + { + startCursor: null, + rows: [ + { + event: { + id: "root-a", + pubkey: "a", + created_at: 1, + kind: 9, + tags: [], + content: "", + sig: "", + }, + thread: { + replyCount: 1, + descendantCount: 1, + lastReplyAt: 2, + participantPubkeys: ["b"], + }, + }, + ], + aux: [], + hasMore: false, + nextCursor: null, + }, + ], + liveOverlay: [], + liveAux: [], + liveSummaries: { + "root-b": { + summary: { + replyCount: 2, + descendantCount: 2, + lastReplyAt: 3, + participantPubkeys: ["c"], + }, + createdAt: 3, + }, + }, + }; + + assert.deepEqual(flattenTimelineRootIds(store).sort(), ["root-a", "root-b"]); +}); diff --git a/desktop/src/features/messages/lib/flattenChannelTimeline.ts b/desktop/src/features/messages/lib/flattenChannelTimeline.ts new file mode 100644 index 0000000000..45d5bfa0dd --- /dev/null +++ b/desktop/src/features/messages/lib/flattenChannelTimeline.ts @@ -0,0 +1,62 @@ +import type { RelayEvent, ThreadCursor } from "@/shared/api/types"; +import { getThreadReplies } from "@/shared/api/tauri"; +import { + channelWindowThreadSummaries, + type ChannelWindowStore, +} from "@/features/messages/lib/channelWindowStore"; +import { isTimelineContentEvent } from "@/features/messages/lib/formatTimelineMessages"; +import { + dedupeMessagesById, + sortMessages, +} from "@/features/messages/lib/messageQueryKeys"; + +const THREAD_PAGE_LIMIT = 200; +const MAX_THREAD_PAGES = 50; +const MAX_FLATTEN_ROOTS = 40; + +/** + * Fetch reply bodies for roots that the channel window only surfaces as + * kind:39005 summaries. Used so private/DM timelines can render those replies + * inline without requiring `["broadcast","1"]` on the wire. + */ +export async function fetchFlattenTimelineReplies( + channelId: string, + rootIds: readonly string[], +): Promise { + const replies: RelayEvent[] = []; + const cappedRoots = rootIds.slice(0, MAX_FLATTEN_ROOTS); + for (const rootId of cappedRoots) { + let cursor: ThreadCursor | null = null; + for (let page = 0; page < MAX_THREAD_PAGES; page += 1) { + const response = await getThreadReplies(rootId, channelId, { + limit: THREAD_PAGE_LIMIT, + cursor, + }); + for (const event of response.events) { + if (isTimelineContentEvent(event)) { + replies.push(event); + } + } + if (!response.nextCursor) break; + cursor = response.nextCursor; + } + } + return replies; +} + +/** Roots that have a relay thread summary and therefore hidden reply bodies. */ +export function flattenTimelineRootIds(store: ChannelWindowStore): string[] { + return [...channelWindowThreadSummaries(store).keys()]; +} + +/** + * Merge hydrated reply events into the reconciled message list. Reply tags are + * preserved; callers decide whether to render them via flattenReplies. + */ +export function mergeFlattenTimelineReplies( + messages: RelayEvent[], + replies: RelayEvent[], +): RelayEvent[] { + if (replies.length === 0) return messages; + return sortMessages(dedupeMessagesById([...messages, ...replies])); +} diff --git a/desktop/src/features/messages/lib/pageOlderMessages.ts b/desktop/src/features/messages/lib/pageOlderMessages.ts index af51a19348..3318465390 100644 --- a/desktop/src/features/messages/lib/pageOlderMessages.ts +++ b/desktop/src/features/messages/lib/pageOlderMessages.ts @@ -6,8 +6,18 @@ import { } from "@/features/messages/lib/channelWindowStore"; import { projectChannelWindowMessages } from "@/features/messages/lib/projectChannelWindow"; import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse"; -import { channelWindowKey } from "@/features/messages/lib/messageQueryKeys"; +import { + channelMessagesKey, + channelWindowKey, +} from "@/features/messages/lib/messageQueryKeys"; +import { + fetchFlattenTimelineReplies, + flattenTimelineRootIds, + mergeFlattenTimelineReplies, +} from "@/features/messages/lib/flattenChannelTimeline"; +import { shouldFlattenChannelTimeline } from "@/features/messages/lib/threading"; import { getChannelWindowEvents } from "@/shared/api/channelWindow"; +import type { Channel, RelayEvent } from "@/shared/api/types"; const CHANNEL_WINDOW_PAGE_SIZE = 50; export type PageOlderResult = { hasOlderMessages: boolean }; @@ -18,20 +28,50 @@ export function pageOlderMessagesUntilRowFloor( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, + channel?: Channel | null, ): Promise { const running = inFlightPasses.get(channelId); if (running) return running; - const pass = runPage(queryClient, channelId, shouldContinue).finally(() => { - inFlightPasses.delete(channelId); - }); + const pass = runPage(queryClient, channelId, shouldContinue, channel).finally( + () => { + inFlightPasses.delete(channelId); + }, + ); inFlightPasses.set(channelId, pass); return pass; } +async function hydrateFlattenedOlderReplies( + queryClient: QueryClient, + channel: Channel, + store: ChannelWindowStore, + knownRootIds: ReadonlySet, +) { + const rootIds = flattenTimelineRootIds(store).filter( + (rootId) => !knownRootIds.has(rootId), + ); + if (rootIds.length === 0) return; + try { + const replies = await fetchFlattenTimelineReplies(channel.id, rootIds); + if (replies.length === 0) return; + queryClient.setQueryData( + channelMessagesKey(channel.id), + (messages = []) => mergeFlattenTimelineReplies(messages, replies), + ); + } catch (error) { + console.error( + "Failed to hydrate flattened older timeline replies for channel", + channel.id, + error, + ); + } +} + async function runPage( queryClient: QueryClient, channelId: string, shouldContinue: () => boolean, + channel?: Channel | null, ): Promise { const store = queryClient.getQueryData( channelWindowKey(channelId), @@ -41,6 +81,7 @@ async function runPage( return { hasOlderMessages: false }; } + const knownRootIds = new Set(flattenTimelineRootIds(store)); const requestCursor = tail.nextCursor; const events = await getChannelWindowEvents( channelId, @@ -56,5 +97,13 @@ async function runPage( const next = appendOlderChannelWindow(retained, page); queryClient.setQueryData(channelWindowKey(channelId), next); projectChannelWindowMessages(queryClient, channelId); + if (shouldFlattenChannelTimeline(channel ?? null) && channel) { + await hydrateFlattenedOlderReplies( + queryClient, + channel, + next, + knownRootIds, + ); + } return { hasOlderMessages: page.hasMore }; } diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 8981d36380..04434d7387 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -67,6 +67,49 @@ test("buildMainTimelineEntries includes broadcast replies", () => { ); }); +test("buildMainTimelineEntries flattenReplies renders reply-tagged events inline without summaries", () => { + const root = message({ id: "root", createdAt: 1 }); + const reply = message({ + id: "reply", + createdAt: 2, + parentId: "root", + rootId: "root", + depth: 1, + tags: [["e", "root", "", "reply"]], + }); + const summaries = new Map([ + [ + "root", + { + replyCount: 1, + descendantCount: 1, + lastReplyAt: 2, + participantPubkeys: ["author"], + }, + ], + ]); + + const entries = buildMainTimelineEntries( + [root, reply], + new Set(), + summaries, + undefined, + { flattenReplies: true }, + ); + + assert.deepEqual( + entries.map((entry) => [ + entry.message.id, + entry.message.depth, + entry.summary, + ]), + [ + ["root", 0, null], + ["reply", 0, null], + ], + ); +}); + test("buildMainTimelineEntries keeps huddle thread replies out of the parent timeline summary", () => { const huddleRoot = message({ id: "huddle-root", diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index ebc1b35ae7..5e1f286a39 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -428,12 +428,23 @@ function mergeThreadSummaries( }; } +export type BuildMainTimelineEntriesOptions = { + /** + * When true, reply-tagged events render as ordinary timeline rows and + * thread-summary badges are suppressed. Presentation only — does not alter + * event tags. See `shouldFlattenChannelTimeline`. + */ + flattenReplies?: boolean; +}; + export function buildMainTimelineEntries( messages: TimelineMessage[], unreadReplyIds: ReadonlySet = new Set(), relaySummaries: ReadonlyMap = new Map(), profiles?: UserProfileLookup, + options?: BuildMainTimelineEntriesOptions, ): MainTimelineEntry[] { + const flattenReplies = options?.flattenReplies === true; const { descendantStatsByMessageId } = buildThreadPanelIndex( messages, unreadReplyIds, @@ -442,24 +453,32 @@ export function buildMainTimelineEntries( return messages .filter( (message) => - message.parentId == null || isBroadcastReply(message.tags ?? []), + flattenReplies || + message.parentId == null || + isBroadcastReply(message.tags ?? []), ) .map((message) => { - const relaySummary = relaySummaries.get(message.id); + // Flattened private/DM replies keep NIP-10 tags but render as depth-0 + // timeline posts (no thread indent / "1 reply" badge). + const displayMessage = + flattenReplies && message.parentId != null + ? { ...message, depth: 0 } + : message; + if (flattenReplies || displayMessage.kind === KIND_HUDDLE_STARTED) { + return { message: displayMessage, summary: null }; + } + const relaySummary = relaySummaries.get(displayMessage.id); return { - message, - summary: - message.kind === KIND_HUDDLE_STARTED - ? null - : mergeThreadSummaries( - buildSummaryForDirectReplies( - message.id, - descendantStatsByMessageId, - ), - relaySummary - ? buildRelayThreadSummary(message.id, relaySummary, profiles) - : null, - ), + message: displayMessage, + summary: mergeThreadSummaries( + buildSummaryForDirectReplies( + displayMessage.id, + descendantStatsByMessageId, + ), + relaySummary + ? buildRelayThreadSummary(displayMessage.id, relaySummary, profiles) + : null, + ), }; }); } diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts index 95694f4989..96fb06af06 100644 --- a/desktop/src/features/messages/lib/threading.ts +++ b/desktop/src/features/messages/lib/threading.ts @@ -1,4 +1,4 @@ -import type { RelayEvent } from "@/shared/api/types"; +import type { Channel, RelayEvent } from "@/shared/api/types"; export type ThreadReference = { parentId: string | null; @@ -22,6 +22,22 @@ export function isThreadReply(tags: string[][]): boolean { return ref.parentId !== null && !isBroadcastReply(tags); } +/** + * Private rooms and DMs render reply-tagged events inline on the channel + * timeline (no "N replies" summary collapse). Wire tags are unchanged — + * NIP-10 reply/root markers stay on the event for ACP turn receipts. + * + * Covers AEON Aspect private offices (e.g. Nexus `#aspect-nexus` / + * `7e8c4840-d401-4701-afc9-7ae7174cfc4e`) where agent replies should read as + * ordinary chat posts. + */ +export function shouldFlattenChannelTimeline( + channel: Pick | null | undefined, +): boolean { + if (!channel) return false; + return channel.visibility === "private" || channel.channelType === "dm"; +} + export function getThreadReference(tags: string[][]): ThreadReference { const eventTags = getEventTags(tags); diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index b66c97619e..68f5015ee8 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -43,6 +43,11 @@ type MessageTimelineProps = { huddleMemberPubkeysPending?: boolean; messages: TimelineMessage[]; mainEntries?: MainTimelineEntry[]; + /** + * When true, deferred entry rebuilds keep reply-tagged events inline (private + * rooms / DMs). Ignored when `mainEntries` is provided. + */ + flattenReplies?: boolean; /** Relay thread summaries (root id → summary) for the deferred-pass entry * fallback, so badge rows survive while a scrollback page commits. */ threadSummaries?: ReadonlyMap; @@ -151,6 +156,7 @@ const MessageTimelineBase = React.forwardRef< directMessageIntro = null, messages, mainEntries, + flattenReplies = false, threadSummaries, isLoading = false, entranceMessageId = null, @@ -619,6 +625,7 @@ const MessageTimelineBase = React.forwardRef< onEntranceMessageComplete={onEntranceMessageComplete} messageFooters={messageFooters} mainEntries={renderedMessages === messages ? mainEntries : undefined} + flattenReplies={flattenReplies} leadingContent={virtualizedLeadingContent} historyExhausted={renderedHistoryExhausted} threadSummaries={threadSummaries} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 5d3cf8fd5d..555f3d1d2f 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -69,6 +69,11 @@ type TimelineMessageListProps = { /** Hoisted main-timeline entries (computed once in ChannelPane). Falls back * to deriving them here when omitted (e.g. the deferred-render pass). */ mainEntries?: MainTimelineEntry[]; + /** + * Private/DM presentation: render reply-tagged events inline when rebuilding + * entries without `mainEntries`. Wire tags stay untouched. + */ + flattenReplies?: boolean; /** Relay thread summaries keyed by thread root id. Keeps badge rows alive on * the deferred-render fallback — replies usually are not local timeline * rows, so without the relay map every summary row unmounts mid-scrollback. */ @@ -137,6 +142,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onEntranceMessageComplete, messageFooters, mainEntries, + flattenReplies = false, threadSummaries, messages, onDelete, @@ -166,8 +172,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ const entries = React.useMemo( () => mainEntries ?? - buildMainTimelineEntries(messages, undefined, threadSummaries, profiles), - [mainEntries, messages, profiles, threadSummaries], + buildMainTimelineEntries(messages, undefined, threadSummaries, profiles, { + flattenReplies, + }), + [flattenReplies, mainEntries, messages, profiles, threadSummaries], ); const reviewCommentsByRootId = React.useMemo( () => diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 2c602b88dc..0a4888a06b 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -68,6 +68,7 @@ export function useFetchOlderMessages(channel: Channel | null) { queryClient, channelId, () => channelId === channel?.id, + channel, ); } catch (error) { console.error("Failed to fetch older messages", channelId, error); @@ -75,7 +76,7 @@ export function useFetchOlderMessages(channel: Channel | null) { isFetchingOlderRef.current = false; setIsFetchingOlder(false); } - }, [channel?.id, channelId, queryClient]); + }, [channel, channelId, queryClient]); return { fetchOlder, isFetchingOlder, hasOlderMessages, historyExhausted }; } From a5d1579e4c46b960409ee824ad40dbeb6ba7016b Mon Sep 17 00:00:00 2001 From: StephGlansberg Date: Thu, 23 Jul 2026 12:26:15 -0700 Subject: [PATCH 2/5] fix(desktop): accept optional threadSummaries in channel timeline helper Signed-off-by: StephGlansberg Co-authored-by: Cursor --- desktop/src/features/messages/lib/channelMainTimeline.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/lib/channelMainTimeline.ts b/desktop/src/features/messages/lib/channelMainTimeline.ts index 9483c99018..b0ac2c2ff4 100644 --- a/desktop/src/features/messages/lib/channelMainTimeline.ts +++ b/desktop/src/features/messages/lib/channelMainTimeline.ts @@ -12,7 +12,7 @@ import type { TimelineMessage } from "@/features/messages/types"; export function buildChannelMainTimelineEntries( channel: Pick | null | undefined, messages: TimelineMessage[], - threadSummaries: ReadonlyMap, + threadSummaries: ReadonlyMap | undefined, profiles?: UserProfileLookup, ): { entries: MainTimelineEntry[]; flattenReplies: boolean } { const flattenReplies = shouldFlattenChannelTimeline(channel); @@ -21,7 +21,7 @@ export function buildChannelMainTimelineEntries( entries: buildMainTimelineEntries( messages, new Set(), - threadSummaries, + threadSummaries ?? new Map(), profiles, { flattenReplies, From 2166b96a33039c68cea4cc866ab5258c32ac7498 Mon Sep 17 00:00:00 2001 From: StephGlansberg Date: Thu, 23 Jul 2026 13:23:38 -0700 Subject: [PATCH 3/5] fix(desktop): hydrate complete flattened reply state Signed-off-by: StephGlansberg --- .../lib/flattenChannelTimeline.test.mjs | 58 +++++++++++++++++++ .../messages/lib/flattenChannelTimeline.ts | 40 +++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs b/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs index 4f7f37051e..a06c08f394 100644 --- a/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs +++ b/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + fetchFlattenTimelineReplies, mergeFlattenTimelineReplies, flattenTimelineRootIds, } from "./flattenChannelTimeline.ts"; @@ -103,3 +104,60 @@ test("flattenTimelineRootIds reads page and live summary roots", () => { assert.deepEqual(flattenTimelineRootIds(store).sort(), ["root-a", "root-b"]); }); + +test("fetchFlattenTimelineReplies hydrates every loaded root and structural aux", async () => { + const rootIds = Array.from({ length: 41 }, (_, index) => `root-${index}`); + const fetchedRoots = []; + let structuralMessageIds = []; + const events = await fetchFlattenTimelineReplies("channel", rootIds, { + async getThreadReplies(rootId, channelId, options) { + assert.equal(channelId, "channel"); + assert.equal(options.limit, 200); + assert.equal(options.cursor, null); + fetchedRoots.push(rootId); + return { + events: [ + { + id: `reply-${rootId}`, + pubkey: "author", + created_at: fetchedRoots.length, + kind: 9, + tags: [ + ["h", "channel"], + ["e", rootId, "", "reply"], + ], + content: rootId, + sig: "", + }, + ], + nextCursor: null, + }; + }, + async fetchStructuralAuxForMessages(channelId, messageIds) { + assert.equal(channelId, "channel"); + structuralMessageIds = messageIds; + return [ + { + id: "edit", + pubkey: "author", + created_at: 100, + kind: 40003, + tags: [ + ["h", "channel"], + ["e", "reply-root-40"], + ], + content: "edited", + sig: "", + }, + ]; + }, + }); + + assert.deepEqual(fetchedRoots, rootIds); + assert.equal(structuralMessageIds.includes("root-40"), true); + assert.equal(structuralMessageIds.includes("reply-root-40"), true); + assert.equal( + events.some((event) => event.id === "edit"), + true, + ); +}); diff --git a/desktop/src/features/messages/lib/flattenChannelTimeline.ts b/desktop/src/features/messages/lib/flattenChannelTimeline.ts index 45d5bfa0dd..6e092ccb34 100644 --- a/desktop/src/features/messages/lib/flattenChannelTimeline.ts +++ b/desktop/src/features/messages/lib/flattenChannelTimeline.ts @@ -1,5 +1,9 @@ import type { RelayEvent, ThreadCursor } from "@/shared/api/types"; import { getThreadReplies } from "@/shared/api/tauri"; +import { + collectMessageIdsForAuxBackfill, + fetchStructuralAuxForMessages, +} from "@/features/messages/lib/auxBackfill"; import { channelWindowThreadSummaries, type ChannelWindowStore, @@ -12,7 +16,16 @@ import { const THREAD_PAGE_LIMIT = 200; const MAX_THREAD_PAGES = 50; -const MAX_FLATTEN_ROOTS = 40; + +type FlattenTimelineFetchDeps = { + getThreadReplies: typeof getThreadReplies; + fetchStructuralAuxForMessages: typeof fetchStructuralAuxForMessages; +}; + +const defaultFetchDeps: FlattenTimelineFetchDeps = { + getThreadReplies, + fetchStructuralAuxForMessages, +}; /** * Fetch reply bodies for roots that the channel window only surfaces as @@ -22,13 +35,13 @@ const MAX_FLATTEN_ROOTS = 40; export async function fetchFlattenTimelineReplies( channelId: string, rootIds: readonly string[], + deps: FlattenTimelineFetchDeps = defaultFetchDeps, ): Promise { const replies: RelayEvent[] = []; - const cappedRoots = rootIds.slice(0, MAX_FLATTEN_ROOTS); - for (const rootId of cappedRoots) { + for (const rootId of rootIds) { let cursor: ThreadCursor | null = null; for (let page = 0; page < MAX_THREAD_PAGES; page += 1) { - const response = await getThreadReplies(rootId, channelId, { + const response = await deps.getThreadReplies(rootId, channelId, { limit: THREAD_PAGE_LIMIT, cursor, }); @@ -41,7 +54,24 @@ export async function fetchFlattenTimelineReplies( cursor = response.nextCursor; } } - return replies; + + const messageIds = [ + ...new Set([...rootIds, ...collectMessageIdsForAuxBackfill(replies)]), + ]; + let structuralAux: RelayEvent[] = []; + try { + structuralAux = await deps.fetchStructuralAuxForMessages( + channelId, + messageIds, + ); + } catch (error) { + console.error( + "Failed to backfill flattened timeline structural aux for channel", + channelId, + error, + ); + } + return sortMessages([...replies, ...structuralAux]); } /** Roots that have a relay thread summary and therefore hidden reply bodies. */ From 63b13f6bc1b4a11095fcb17f4b4c6a6a97e8e04f Mon Sep 17 00:00:00 2001 From: StephGlansberg Date: Fri, 24 Jul 2026 13:12:00 -0700 Subject: [PATCH 4/5] fix(desktop): keep flattened replies within active window Signed-off-by: StephGlansberg --- desktop/scripts/check-file-sizes.mjs | 4 --- .../src/features/channels/ui/ChannelPane.tsx | 20 +++++------- .../messages/lib/channelMainTimeline.ts | 20 ++++++++++++ .../messages/lib/threadPanel.test.mjs | 31 +++++++++++++++++++ .../src/features/messages/lib/threadPanel.ts | 17 +++++++--- 5 files changed, 71 insertions(+), 21 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 26db86da78..32f065acbd 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -50,10 +50,6 @@ const rules = [ // Do not add to this list; split the file instead. Remove each entry as its // file is broken up. Tracked as a follow-up. const overrides = new Map([ - // Private/DM timeline flatten for AEON Aspect offices: extracted helpers keep - // growth tiny, but biome's import/export blank leaves this 1 line over the - // ceiling. Queued to split with the rest of ChannelPane. - ["src/features/channels/ui/ChannelPane.tsx", 1005], // Native Builderlab auth/community commands add a small registration surface // to the existing Tauri composition root. The implementation lives in // builderlab.rs; this narrowly ratchets the command wiring while lib.rs is diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index d75a77ee44..c1a656d7f6 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -58,7 +58,7 @@ import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types" import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel"; import { Button } from "@/shared/ui/button"; -import { buildChannelMainTimelineEntries } from "@/features/messages/lib/channelMainTimeline"; +import { useChannelMainTimelineEntries } from "@/features/messages/lib/channelMainTimeline"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; @@ -66,7 +66,6 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; - export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -463,16 +462,13 @@ export const ChannelPane = React.memo(function ChannelPane({ return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); - const { entries: mainTimelineEntries, flattenReplies } = React.useMemo( - () => - buildChannelMainTimelineEntries( - activeChannel, - visibleMessages, - threadSummaries, - profiles, - ), - [activeChannel, profiles, threadSummaries, visibleMessages], - ); + const { entries: mainTimelineEntries, flattenReplies } = + useChannelMainTimelineEntries( + activeChannel, + visibleMessages, + threadSummaries, + profiles, + ); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, diff --git a/desktop/src/features/messages/lib/channelMainTimeline.ts b/desktop/src/features/messages/lib/channelMainTimeline.ts index b0ac2c2ff4..ece66cbc87 100644 --- a/desktop/src/features/messages/lib/channelMainTimeline.ts +++ b/desktop/src/features/messages/lib/channelMainTimeline.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import type { Channel } from "@/shared/api/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; @@ -29,3 +30,22 @@ export function buildChannelMainTimelineEntries( ), }; } + +/** Memoized private/DM timeline projection for channel panes. */ +export function useChannelMainTimelineEntries( + channel: Pick | null | undefined, + messages: TimelineMessage[], + threadSummaries: ReadonlyMap | undefined, + profiles?: UserProfileLookup, +): { entries: MainTimelineEntry[]; flattenReplies: boolean } { + return useMemo( + () => + buildChannelMainTimelineEntries( + channel, + messages, + threadSummaries, + profiles, + ), + [channel, messages, profiles, threadSummaries], + ); +} diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 04434d7387..603dfe2282 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -110,6 +110,37 @@ test("buildMainTimelineEntries flattenReplies renders reply-tagged events inline ); }); +test("buildMainTimelineEntries flattenReplies excludes retained replies whose root left the active window", () => { + const activeRoot = message({ id: "active-root", createdAt: 3 }); + const activeReply = message({ + id: "active-reply", + createdAt: 4, + parentId: "active-root", + rootId: "active-root", + depth: 1, + }); + const staleReply = message({ + id: "stale-reply", + createdAt: 2, + parentId: "stale-root", + rootId: "stale-root", + depth: 1, + }); + + const entries = buildMainTimelineEntries( + [staleReply, activeRoot, activeReply], + new Set(), + new Map(), + undefined, + { flattenReplies: true }, + ); + + assert.deepEqual( + entries.map((entry) => entry.message.id), + ["active-root", "active-reply"], + ); +}); + test("buildMainTimelineEntries keeps huddle thread replies out of the parent timeline summary", () => { const huddleRoot = message({ id: "huddle-root", diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 5e1f286a39..c3c9e4cfcc 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -445,17 +445,24 @@ export function buildMainTimelineEntries( options?: BuildMainTimelineEntriesOptions, ): MainTimelineEntry[] { const flattenReplies = options?.flattenReplies === true; + const activeRootIds = flattenReplies + ? new Set( + messages + .filter((message) => message.parentId == null) + .map((message) => message.id), + ) + : null; const { descendantStatsByMessageId } = buildThreadPanelIndex( messages, unreadReplyIds, ); return messages - .filter( - (message) => - flattenReplies || - message.parentId == null || - isBroadcastReply(message.tags ?? []), + .filter((message) => + flattenReplies + ? message.parentId == null || + activeRootIds?.has(message.rootId ?? message.parentId) + : message.parentId == null || isBroadcastReply(message.tags ?? []), ) .map((message) => { // Flattened private/DM replies keep NIP-10 tags but render as depth-0 From 4bb50159cb0504a8f0b6bfc81f0e78ef43783ecc Mon Sep 17 00:00:00 2001 From: StephGlansberg Date: Fri, 24 Jul 2026 13:20:40 -0700 Subject: [PATCH 5/5] fix(desktop): reconcile flattened reply cache on refetch Signed-off-by: StephGlansberg --- desktop/src/features/messages/hooks.ts | 9 ++++- .../lib/channelWindowReconciliation.ts | 23 +++++++++--- .../lib/projectChannelWindow.test.mjs | 35 +++++++++++++++++++ .../messages/lib/threadPanel.test.mjs | 31 ---------------- .../src/features/messages/lib/threadPanel.ts | 17 +++------ 5 files changed, 67 insertions(+), 48 deletions(-) diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index a66dca0be1..c94f9dc306 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -276,7 +276,14 @@ export function useChannelMessagesQuery(channel: Channel | null) { emptyChannelWindowStore(); const next = replaceNewestChannelWindow(current, page); queryClient.setQueryData(windowKey, next); - const reconciled = reconcileChannelWindowMessages(next, previousMessages); + const reconciled = reconcileChannelWindowMessages( + next, + previousMessages, + { + retainNonBroadcastThreadReplies: + !shouldFlattenChannelTimeline(channel), + }, + ); return withFlattenedTimelineReplies(channel, next, reconciled); }, staleTime: 5 * 60 * 1_000, diff --git a/desktop/src/features/messages/lib/channelWindowReconciliation.ts b/desktop/src/features/messages/lib/channelWindowReconciliation.ts index cc2c0f034c..09512b7245 100644 --- a/desktop/src/features/messages/lib/channelWindowReconciliation.ts +++ b/desktop/src/features/messages/lib/channelWindowReconciliation.ts @@ -10,15 +10,28 @@ import { getThreadReference, isBroadcastReply } from "./threading"; const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); -function retainRefetchReconciliationEvents(events: RelayEvent[]) { +function retainRefetchReconciliationEvents( + events: RelayEvent[], + retainNonBroadcastThreadReplies: boolean, +) { return events.filter((event) => { if (!CHANNEL_TIMELINE_KINDS.has(event.kind)) return false; if (event.pending) return true; + if (!retainNonBroadcastThreadReplies) return false; const thread = getThreadReference(event.tags); return thread.parentId !== null && !isBroadcastReply(event.tags); }); } +export type ReconcileChannelWindowMessagesOptions = { + /** + * Preserve cache-only non-broadcast replies that the authoritative channel + * window omits. Flattened private/DM refetches disable this before + * rehydrating replies for the roots in the replacement window. + */ + retainNonBroadcastThreadReplies?: boolean; +}; + /** * Project the timeline from the authoritative window while retaining local * pending sends and non-broadcast thread replies the window does not contain. @@ -26,12 +39,14 @@ function retainRefetchReconciliationEvents(events: RelayEvent[]) { export function reconcileChannelWindowMessages( window: ChannelWindowStore, messages: RelayEvent[], + options?: ReconcileChannelWindowMessagesOptions, ) { const windowEvents = flattenChannelWindowEvents(window); const authoritativeIds = new Set(windowEvents.map((event) => event.id)); - const retained = retainRefetchReconciliationEvents(messages).filter( - (event) => !authoritativeIds.has(event.id), - ); + const retained = retainRefetchReconciliationEvents( + messages, + options?.retainNonBroadcastThreadReplies !== false, + ).filter((event) => !authoritativeIds.has(event.id)); // Reconcile acknowledgements against cache-only rows without changing the // authoritative window's order. The render key moves from an optimistic row diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 5c76330972..91f0608374 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -154,6 +154,41 @@ test("test_projection_retains_pending_send_and_non_broadcast_thread_reply", asyn ); }); +test("test_flattened_refetch_drops_cache_only_replies but keeps live authoritative replies", () => { + const harness = createHarness(); + const staleReply = { + ...event("stale-reply", 105), + tags: [ + ["h", "channel"], + ["e", "stale-root", "", "root"], + ["e", "stale-parent", "", "reply"], + ], + }; + const liveReply = { + ...event("live-reply", 110), + tags: [ + ["h", "channel"], + ["e", "older-root", "", "root"], + ["e", "older-root", "", "reply"], + ], + }; + const window = mergeLiveChannelWindowEvent( + harness.client.getQueryData(harness.windowKey), + liveReply, + ); + + const projected = reconcileChannelWindowMessages( + window, + [staleReply, liveReply], + { retainNonBroadcastThreadReplies: false }, + ); + + assert.deepEqual( + projected.map((item) => item.content), + ["initial", "live-reply"], + ); +}); + test("test_projection_replaces_pending_send_with_authoritative_event", () => { const harness = createHarness(); const pending = { ...event("accepted", 110), pending: true }; diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 603dfe2282..04434d7387 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -110,37 +110,6 @@ test("buildMainTimelineEntries flattenReplies renders reply-tagged events inline ); }); -test("buildMainTimelineEntries flattenReplies excludes retained replies whose root left the active window", () => { - const activeRoot = message({ id: "active-root", createdAt: 3 }); - const activeReply = message({ - id: "active-reply", - createdAt: 4, - parentId: "active-root", - rootId: "active-root", - depth: 1, - }); - const staleReply = message({ - id: "stale-reply", - createdAt: 2, - parentId: "stale-root", - rootId: "stale-root", - depth: 1, - }); - - const entries = buildMainTimelineEntries( - [staleReply, activeRoot, activeReply], - new Set(), - new Map(), - undefined, - { flattenReplies: true }, - ); - - assert.deepEqual( - entries.map((entry) => entry.message.id), - ["active-root", "active-reply"], - ); -}); - test("buildMainTimelineEntries keeps huddle thread replies out of the parent timeline summary", () => { const huddleRoot = message({ id: "huddle-root", diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index c3c9e4cfcc..5e1f286a39 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -445,24 +445,17 @@ export function buildMainTimelineEntries( options?: BuildMainTimelineEntriesOptions, ): MainTimelineEntry[] { const flattenReplies = options?.flattenReplies === true; - const activeRootIds = flattenReplies - ? new Set( - messages - .filter((message) => message.parentId == null) - .map((message) => message.id), - ) - : null; const { descendantStatsByMessageId } = buildThreadPanelIndex( messages, unreadReplyIds, ); return messages - .filter((message) => - flattenReplies - ? message.parentId == null || - activeRootIds?.has(message.rootId ?? message.parentId) - : message.parentId == null || isBroadcastReply(message.tags ?? []), + .filter( + (message) => + flattenReplies || + message.parentId == null || + isBroadcastReply(message.tags ?? []), ) .map((message) => { // Flattened private/DM replies keep NIP-10 tags but render as depth-0