diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index a901d5010d..32f065acbd 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -75,7 +75,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 dbce0b6f5e..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 { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +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"; @@ -462,16 +462,13 @@ export const ChannelPane = React.memo(function ChannelPane({ return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); - const mainTimelineEntries = React.useMemo( - () => - buildMainTimelineEntries( - visibleMessages, - new Set(), - threadSummaries, - profiles, - ), - [profiles, threadSummaries, visibleMessages], - ); + const { entries: mainTimelineEntries, flattenReplies } = + useChannelMainTimelineEntries( + activeChannel, + visibleMessages, + threadSummaries, + profiles, + ); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, @@ -674,6 +671,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..c94f9dc306 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,15 @@ 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, + { + retainNonBroadcastThreadReplies: + !shouldFlattenChannelTimeline(channel), + }, + ); + return withFlattenedTimelineReplies(channel, next, reconciled); }, staleTime: 5 * 60 * 1_000, gcTime: 60 * 60 * 1_000, @@ -256,6 +295,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 +328,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..ece66cbc87 --- /dev/null +++ b/desktop/src/features/messages/lib/channelMainTimeline.ts @@ -0,0 +1,51 @@ +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"; +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 | undefined, + profiles?: UserProfileLookup, +): { entries: MainTimelineEntry[]; flattenReplies: boolean } { + const flattenReplies = shouldFlattenChannelTimeline(channel); + return { + flattenReplies, + entries: buildMainTimelineEntries( + messages, + new Set(), + threadSummaries ?? new Map(), + profiles, + { + flattenReplies, + }, + ), + }; +} + +/** 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/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/flattenChannelTimeline.test.mjs b/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs new file mode 100644 index 0000000000..a06c08f394 --- /dev/null +++ b/desktop/src/features/messages/lib/flattenChannelTimeline.test.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + fetchFlattenTimelineReplies, + 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"]); +}); + +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 new file mode 100644 index 0000000000..6e092ccb34 --- /dev/null +++ b/desktop/src/features/messages/lib/flattenChannelTimeline.ts @@ -0,0 +1,92 @@ +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, +} 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; + +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 + * 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[], + deps: FlattenTimelineFetchDeps = defaultFetchDeps, +): Promise { + const replies: RelayEvent[] = []; + for (const rootId of rootIds) { + let cursor: ThreadCursor | null = null; + for (let page = 0; page < MAX_THREAD_PAGES; page += 1) { + const response = await deps.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; + } + } + + 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. */ +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/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 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 }; }