diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c5bdbc5666..8663b4390e5 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -89,6 +89,7 @@ export default defineConfig({ "**/home-collapsed-top-chrome.spec.ts", "**/top-chrome-zoom-clearance.spec.ts", "**/thread-unread.spec.ts", + "**/inline-thread-replies.spec.ts", "**/thread-load-failure.spec.ts", "**/project-conversation-load-failure.spec.ts", "**/huddle-thread-load-failure.spec.ts", diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index d53e8dbbdaa..8cd78fec029 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -112,6 +112,7 @@ export const ChannelPane = React.memo(function ChannelPane({ welcomeKickoffStage = null, welcomeKickoffSettingUp = false, messages, + inlineThreadController, threadSummaries, huddleThreadRepliesError = false, onRetryHuddleThreadReplies, @@ -666,6 +667,7 @@ export const ChannelPane = React.memo(function ChannelPane({ mainEntries={mainTimelineEntries} threadSummaries={threadSummaries} messages={visibleMessages} + inlineThreadController={inlineThreadController} firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} onDelete={onDelete} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 1fe5bf751b8..0ab71ada1d0 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -6,6 +6,7 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { TimelineMessage } from "@/features/messages/types"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; +import type { InlineThreadController } from "@/features/messages/useThreadReplies"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ProfilePanelTab, @@ -71,6 +72,7 @@ export type ChannelPaneProps = { /** The kickoff is still setting up the team — the banner copy reads as setup status. */ welcomeKickoffSettingUp?: boolean; messages: TimelineMessage[]; + inlineThreadController?: InlineThreadController; threadSummaries?: ReadonlyMap; /** * A Huddle transcript flattens summarized reply subtrees into the chat diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index f7a5b480122..89e1c05f0ed 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -34,6 +34,7 @@ import { useWelcomeKickoffStagePresence } from "@/features/onboarding/useWelcome import { useWelcomeAgentCreate } from "@/features/channels/useWelcomeAgentCreate"; import { useCommunities } from "@/features/communities/useCommunities"; import { + mergeMessages, useChannelMessagesQuery, useChannelSubscription, useChannelWindowQuery, @@ -53,7 +54,7 @@ import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHead import { resolveTimelineQueryLoadingState } from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; -import { useThreadReplies } from "@/features/messages/useThreadReplies"; +import { useInlineThreadReplies, useMarkInlineRepliesRead, useThreadReplies } from "@/features/messages/useThreadReplies"; import { useChannelTyping } from "@/features/messages/useChannelTyping"; import type { TimelineMessage } from "@/features/messages/types"; import { useUsersBatchQuery } from "@/features/profile/hooks"; @@ -152,12 +153,8 @@ export function ChannelScreen({ } = useThreadPanelWidth(channelContentWidthPx || undefined); const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); const [isAddBotOpen, setIsAddBotOpen] = React.useState(false); - const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState( - () => new Set(), - ); - const [threadScrollTargetId, setThreadScrollTargetId] = React.useState< - string | null - >(null); + const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState(() => new Set()); + const [threadScrollTargetId, setThreadScrollTargetId] = React.useState(null); const [threadReplyTargetId, setThreadReplyTargetId] = React.useState< string | null >(null); @@ -202,10 +199,8 @@ export function ChannelScreen({ }, [activeChannelId, openThreadHeadId]); const messagesQuery = useChannelMessagesQuery(activeChannel); const windowQuery = useChannelWindowQuery(activeChannel); - const threadRepliesQuery = useThreadReplies( - activeChannel, - effectiveOpenThreadHeadId, - ); + const threadRepliesQuery = useThreadReplies(activeChannel, effectiveOpenThreadHeadId); + const inlineThreadReplies = useInlineThreadReplies(activeChannel); useChannelSubscription(activeChannel); const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } = useFetchOlderMessages(activeChannel); @@ -265,6 +260,11 @@ export function ChannelScreen({ targetMessageEvents, windowStore: windowQuery.data, }); + const resolvedTimelineEvents = React.useMemo( + () => + inlineThreadReplies.events.reduce(mergeMessages, resolvedMessages), + [inlineThreadReplies.events, resolvedMessages], + ); useHuddleReadMarker({ activeChannelId, activeChannelIsMember: activeChannel?.isMember, @@ -283,7 +283,7 @@ export function ChannelScreen({ threadReplyEvents, ); const messageEventProfilePubkeys = useMessageEventProfilePubkeys( - resolvedMessages, + resolvedTimelineEvents, threadReplyEvents, relaySelfPubkey, ); @@ -403,7 +403,7 @@ export function ChannelScreen({ const timelineMessages = React.useMemo( () => formatTimelineMessages( - resolvedMessages, + resolvedTimelineEvents, activeChannel, currentPubkey, currentProfile?.avatarUrl ?? null, @@ -424,7 +424,7 @@ export function ChannelScreen({ personaLookup, relaySelfPubkey, respondToLookup, - resolvedMessages, + resolvedTimelineEvents, ], ); const threadPanelData = useIndependentThreadPanel({ @@ -453,7 +453,6 @@ export function ChannelScreen({ markRevealedRepliesRead, openThreadHeadMessage, threadFirstUnreadReplyId, - threadReplyTargetMessage, threadReplyUnreadCounts, threadUnreadCounts, unreadCount, @@ -473,6 +472,7 @@ export function ChannelScreen({ isThreadMuted, readStateVersion, }); + useMarkInlineRepliesRead(inlineThreadReplies, markRevealedRepliesRead); const editTargetMessage = React.useMemo( () => timelineMessages.find((message) => message.id === editTargetId) ?? @@ -671,7 +671,8 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, threadReplyTargetId, - threadReplyTargetMessage, + threadReplyTargetMessage: threadPanelData.replyTargetMessage, + threadMessagesPending: threadRepliesQuery.isPending, }); const hasAuxiliaryPanel = Boolean( effectiveOpenThreadHeadId || @@ -883,6 +884,7 @@ export function ChannelScreen({ isSinglePanelView={isSinglePanelView} isTimelineError={messagesQuery.isError} isTimelineLoading={isTimelineLoading} onRetryTimeline={() => void messagesQuery.refetch()} messages={timelineMessages} + inlineThreadController={inlineThreadReplies.controller} threadSummaries={threadSummaries} huddleThreadRepliesError={huddleThreadRepliesError} onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 465a0a6b612..8a2a577f0b5 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -161,13 +161,45 @@ export function useChannelPaneHandlers({ }, []); const handleEdit = React.useCallback( - (message: { id: string }) => { + (message: { id: string; rootId?: string | null }) => { + const threadHeadId = message.rootId ?? null; + if ( + threadHeadId && + openThreadHeadIdRef.current !== threadHeadId && + !requireThreadEditResolution() + ) + return; + if (threadHeadId && openThreadHeadIdRef.current !== threadHeadId) { + deferPanelState(() => { + setExpandedThreadReplyIds(new Set()); + onOptimisticOpenThreadHeadIdChange(threadHeadId); + setOpenThreadHeadId(threadHeadId); + // Navigation can commit before local target state. Set the child + // target after the containing thread panel has mounted. + deferPanelState(() => { + setEditTargetId(message.id); + setThreadReplyTargetId(threadHeadId); + setThreadScrollTargetId(message.id); + }); + }); + return; + } setEditTargetId((current) => current === message.id ? null : message.id, ); - setThreadReplyTargetId(openThreadHeadIdRef.current); + setThreadReplyTargetId(threadHeadId ?? openThreadHeadIdRef.current); + if (threadHeadId) setThreadScrollTargetId(message.id); }, - [setEditTargetId, setThreadReplyTargetId], + [ + deferPanelState, + onOptimisticOpenThreadHeadIdChange, + requireThreadEditResolution, + setEditTargetId, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setThreadReplyTargetId, + setThreadScrollTargetId, + ], ); const handleEditSave = React.useCallback( @@ -214,9 +246,17 @@ export function useChannelPaneHandlers({ ); const handleOpenThread = React.useCallback( - (message: { id: string }) => { + (message: { id: string; rootId?: string | null }) => { if (!requireThreadEditResolution()) return; - if (openThreadHeadIdRef.current === message.id) { + const threadHeadId = message.rootId ?? message.id; + const replyTargetId = message.rootId ? message.id : threadHeadId; + if (openThreadHeadIdRef.current === threadHeadId) { + if (message.rootId) { + setThreadReplyTargetId(replyTargetId); + setThreadScrollTargetId(replyTargetId); + setEditTargetId(null); + return; + } deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(null); setOpenThreadHeadId(null); @@ -229,11 +269,17 @@ export function useChannelPaneHandlers({ } deferPanelState(() => { - onOptimisticOpenThreadHeadIdChange(message.id); - setOpenThreadHeadId(message.id); - setThreadReplyTargetId(message.id); - setThreadScrollTargetId(null); setExpandedThreadReplyIds(new Set()); + onOptimisticOpenThreadHeadIdChange(threadHeadId); + setOpenThreadHeadId(threadHeadId); + if (message.rootId) { + // Navigation can commit before local target state. Set the child + // target after the containing thread panel has mounted. + deferPanelState(() => { + setThreadReplyTargetId(replyTargetId); + setThreadScrollTargetId(replyTargetId); + }); + } }); setEditTargetId(null); }, diff --git a/desktop/src/features/channels/useThreadTargetSync.ts b/desktop/src/features/channels/useThreadTargetSync.ts index 92cd0020641..6823f84be7f 100644 --- a/desktop/src/features/channels/useThreadTargetSync.ts +++ b/desktop/src/features/channels/useThreadTargetSync.ts @@ -23,6 +23,7 @@ export function useThreadTargetSync({ setThreadScrollTargetId, threadReplyTargetId, threadReplyTargetMessage, + threadMessagesPending, }: { clearOptimisticThreadOverride: () => void; editTargetId: string | null; @@ -37,6 +38,7 @@ export function useThreadTargetSync({ setThreadScrollTargetId: (id: string | null) => void; threadReplyTargetId: string | null; threadReplyTargetMessage: TimelineMessage | null; + threadMessagesPending: boolean; }) { React.useEffect(() => { if (openThreadHeadId && !openThreadHeadMessage) { @@ -55,6 +57,10 @@ export function useThreadTargetSync({ return; } + if (threadMessagesPending) { + return; + } + if (threadReplyTargetId && !threadReplyTargetMessage) { setThreadReplyTargetId(openThreadHeadMessage?.id ?? null); } @@ -74,6 +80,7 @@ export function useThreadTargetSync({ setThreadReplyTargetId, setThreadScrollTargetId, threadReplyTargetId, + threadMessagesPending, threadReplyTargetMessage, ]); } diff --git a/desktop/src/features/messages/combineThreadRepliesResults.test.mjs b/desktop/src/features/messages/combineThreadRepliesResults.test.mjs index 47beb1c7a83..9e73faff5ea 100644 --- a/desktop/src/features/messages/combineThreadRepliesResults.test.mjs +++ b/desktop/src/features/messages/combineThreadRepliesResults.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { combineThreadRepliesResults } from "./useThreadReplies.ts"; +import { + combineThreadRepliesForRoots, + collectNewlyRevealedInlineReplyIds, + combineThreadRepliesResults, +} from "./useThreadReplies.ts"; const CHANNEL_A = "a".repeat(64); const CHANNEL_B = "b".repeat(64); @@ -18,9 +22,20 @@ function event(id, createdAt) { }; } +function replyEvent(id, rootId, createdAt) { + return { + ...event(id, createdAt), + tags: [ + ["e", rootId, "", "root"], + ["e", rootId, "", "reply"], + ], + }; +} + function ok(data) { return { data, + isFetching: false, isPending: false, isError: false, error: null, @@ -33,6 +48,7 @@ function ok(data) { function failed(refetch) { return { data: undefined, + isFetching: false, isPending: false, isError: true, error: new Error("subtree load failed"), @@ -43,6 +59,7 @@ function failed(refetch) { function pending() { return { data: undefined, + isFetching: true, isPending: true, isError: false, error: null, @@ -102,3 +119,114 @@ test("all-success aggregate reports no error", () => { assert.equal(combined.isError, false); assert.equal(combined.error, null); }); + +test("tracks pending, refreshing, and failed state for the owning root only", () => { + const readyRoot = "ready"; + const pendingRoot = "pending"; + const refreshingRoot = "refreshing"; + const failedRoot = "failed"; + const refreshing = { ...ok([]), isFetching: true }; + const combined = combineThreadRepliesForRoots( + [readyRoot, pendingRoot, refreshingRoot, failedRoot], + [ok([]), pending(), refreshing, failed(() => {})], + ); + + assert.deepEqual([...combined.pendingRootIds], [pendingRoot]); + assert.deepEqual( + [...combined.fetchingRootIds], + [pendingRoot, refreshingRoot], + ); + assert.deepEqual([...combined.errorRootIds], [failedRoot]); +}); + +test("marks only the reply snapshot revealed by each expansion", () => { + const rootId = "root"; + const firstReply = replyEvent("first", rootId, 100); + const futureReply = replyEvent("future", rootId, 200); + const empty = new Set(); + + const refreshingReveal = collectNewlyRevealedInlineReplyIds({ + rootIds: new Set([rootId]), + pendingRootIds: empty, + fetchingRootIds: new Set([rootId]), + errorRootIds: empty, + events: [firstReply], + revealedRootIds: empty, + }); + assert.deepEqual(refreshingReveal.messageIds, []); + + const initialReveal = collectNewlyRevealedInlineReplyIds({ + rootIds: new Set([rootId]), + pendingRootIds: empty, + fetchingRootIds: empty, + errorRootIds: empty, + events: [firstReply], + revealedRootIds: refreshingReveal.revealedRootIds, + }); + assert.deepEqual(initialReveal.messageIds, [rootId, firstReply.id]); + + const liveUpdate = collectNewlyRevealedInlineReplyIds({ + rootIds: new Set([rootId]), + pendingRootIds: empty, + fetchingRootIds: empty, + errorRootIds: empty, + events: [firstReply, futureReply], + revealedRootIds: initialReveal.revealedRootIds, + }); + assert.deepEqual(liveUpdate.messageIds, []); + + const collapsed = collectNewlyRevealedInlineReplyIds({ + rootIds: empty, + pendingRootIds: empty, + fetchingRootIds: empty, + errorRootIds: empty, + events: [firstReply, futureReply], + revealedRootIds: liveUpdate.revealedRootIds, + }); + const reopened = collectNewlyRevealedInlineReplyIds({ + rootIds: new Set([rootId]), + pendingRootIds: empty, + fetchingRootIds: empty, + errorRootIds: empty, + events: [firstReply, futureReply], + revealedRootIds: collapsed.revealedRootIds, + }); + assert.deepEqual(reopened.messageIds, [ + rootId, + firstReply.id, + futureReply.id, + ]); +}); + +test("marks cached replies on refresh failure without freezing the snapshot", () => { + const rootId = "root"; + const cachedReply = replyEvent("cached", rootId, 100); + const refreshedReply = replyEvent("refreshed", rootId, 200); + const empty = new Set(); + + const failedRefresh = collectNewlyRevealedInlineReplyIds({ + rootIds: new Set([rootId]), + pendingRootIds: empty, + fetchingRootIds: empty, + errorRootIds: new Set([rootId]), + events: [cachedReply], + revealedRootIds: empty, + }); + assert.deepEqual(failedRefresh.messageIds, [rootId, cachedReply.id]); + assert.deepEqual([...failedRefresh.revealedRootIds], []); + + const retrySucceeded = collectNewlyRevealedInlineReplyIds({ + rootIds: new Set([rootId]), + pendingRootIds: empty, + fetchingRootIds: empty, + errorRootIds: empty, + events: [cachedReply, refreshedReply], + revealedRootIds: failedRefresh.revealedRootIds, + }); + assert.deepEqual(retrySucceeded.messageIds, [ + rootId, + cachedReply.id, + refreshedReply.id, + ]); + assert.deepEqual([...retrySucceeded.revealedRootIds], [rootId]); +}); diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.test.mjs b/desktop/src/features/messages/ui/MessageThreadSummaryRow.test.mjs new file mode 100644 index 00000000000..02050151adc --- /dev/null +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + Node: dom.window.Node, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const message = { + id: "thread-root", + createdAt: 1, + pubkey: "author", + author: "Author", + avatarUrl: null, + role: undefined, + personaDisplayName: undefined, + time: "12:00 PM", + body: "Thread root", + parentId: null, + rootId: null, + depth: 0, + accent: false, + pending: undefined, + edited: false, + kind: 9, + tags: [], + reactions: undefined, +}; + +const summary = { + threadHeadId: message.id, + replyCount: 2, + lastReplyAt: null, + participants: [], +}; + +test("inline toggle is independent from the existing thread opener", async () => { + const React = await import("react"); + const { fireEvent, render } = await import("@testing-library/react"); + const { MessageThreadSummaryRow } = await import( + "./MessageThreadSummaryRow.tsx" + ); + let openCount = 0; + let toggleCount = 0; + const renderSummary = (inlineExpanded) => + React.createElement(MessageThreadSummaryRow, { + inlineExpanded, + message, + onOpenThread: () => { + openCount += 1; + }, + onToggleInline: () => { + toggleCount += 1; + }, + showDepthGuides: false, + summary, + }); + + const view = render(renderSummary(false)); + const inlineToggle = view.getByRole("button", { + name: "Show 2 replies in channel", + }); + assert.equal(inlineToggle.getAttribute("aria-pressed"), "false"); + fireEvent.click(inlineToggle); + assert.equal(toggleCount, 1); + assert.equal(openCount, 0); + + view.rerender(renderSummary(true)); + const hideToggle = view.getByRole("button", { + name: "Hide 2 replies from channel", + }); + assert.equal(hideToggle.getAttribute("aria-pressed"), "true"); + assert.equal(hideToggle.textContent, "Hide replies"); + + fireEvent.click( + view.getByRole("button", { name: "View thread with 2 replies" }), + ); + assert.equal(openCount, 1); + assert.equal(toggleCount, 1); +}); diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx index 083faf04db1..4b4998950e9 100644 --- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx @@ -62,7 +62,9 @@ export function MessageThreadSummaryRow({ message, onCollapseDepthGuide, onCollapseDepthGuideHoverChange, + inlineExpanded = false, onOpenThread, + onToggleInline, showDepthGuides = true, summary, summaryIndentOffsetRem = 0, @@ -72,12 +74,14 @@ export function MessageThreadSummaryRow({ depth?: number; depthGuideDepths?: ReadonlyArray; highlightThreadLineDepths?: ReadonlyArray; + inlineExpanded?: boolean; message: TimelineMessage; onCollapseDepthGuide?: (message: TimelineMessage) => void; onCollapseDepthGuideHoverChange?: ( message: TimelineMessage, hovered: boolean, ) => void; + onToggleInline?: (message: TimelineMessage) => void; onOpenThread: (message: TimelineMessage) => void; showDepthGuides?: boolean; summary: TimelineThreadSummary; @@ -208,73 +212,94 @@ export function MessageThreadSummaryRow({ ) : null} - + + {onToggleInline ? ( + + ) : null} + ); } diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 88ec8080aab..785b4983a26 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -10,6 +10,7 @@ import { preloadTimelineImages } from "@/features/messages/lib/timelineImagePrel import type { TimelineMessage } from "@/features/messages/types"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; +import type { InlineThreadController } from "@/features/messages/useThreadReplies"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -46,6 +47,7 @@ type MessageTimelineProps = { huddleMemberPubkeysPending?: boolean; messages: TimelineMessage[]; mainEntries?: MainTimelineEntry[]; + inlineThreadController?: InlineThreadController; /** Relay thread summaries (root id → summary) for the deferred-pass entry * fallback, so badge rows survive while a scrollback page commits. */ threadSummaries?: ReadonlyMap; @@ -97,6 +99,7 @@ type MessageTimelineProps = { onMarkRead?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onOpenThread?: (message: TimelineMessage) => void; + isSendingVideoReviewComment?: boolean; onSendVideoReviewComment?: ( message: TimelineMessage, @@ -165,6 +168,7 @@ const MessageTimelineBase = React.forwardRef< directMessageIntro = null, messages, mainEntries, + inlineThreadController, threadSummaries, isError = false, isLoading = false, @@ -198,6 +202,7 @@ const MessageTimelineBase = React.forwardRef< onMarkRead, onReply, onOpenThread, + channelName, channelType, isSendingVideoReviewComment = false, @@ -667,6 +672,8 @@ const MessageTimelineBase = React.forwardRef< onEntranceMessageComplete={onEntranceMessageComplete} messageFooters={messageFooters} mainEntries={renderedMessages === messages ? mainEntries : undefined} + inlineThreadController={inlineThreadController} + inlineThreadMessages={messages} leadingContent={virtualizedLeadingContent} historyExhausted={renderedHistoryExhausted} hideDayDividers={hideDayDividers} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index d7ef78ea04b..225397ac41d 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -17,11 +17,16 @@ import { type VirtualizedTimelineItem, virtualizedItemKey, } from "@/features/messages/lib/virtualizedTimelineItems"; -import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; -import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { + buildMainTimelineEntries, + buildThreadPanelDataFromIndex, + buildThreadPanelIndex, + type MainTimelineEntry, +} from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext"; import type { TimelineMessage } from "@/features/messages/types"; +import type { InlineThreadController } from "@/features/messages/useThreadReplies"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -56,6 +61,8 @@ type TimelineMessageListProps = { followThreadById?: (rootId: string) => void; highlightedMessageId?: string | null; isFollowingThreadById?: (rootId: string) => boolean; + inlineThreadController?: InlineThreadController; + inlineThreadMessages?: TimelineMessage[]; isMessageUnreadById?: (messageId: string) => boolean; entranceMessageId?: string | null; onEntranceMessageComplete?: (messageId: string) => void; @@ -122,6 +129,8 @@ type TimelineMessageListProps = { onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; }; +const EMPTY_THREAD_ROOT_IDS: ReadonlySet = new Set(); + export const TimelineMessageList = React.memo(function TimelineMessageList({ channelId, channelName, @@ -133,6 +142,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ huddleMemberPubkeys, huddleMemberPubkeysPending = false, isFollowingThreadById, + inlineThreadController, + inlineThreadMessages, isMessageUnreadById, entranceMessageId = null, onEntranceMessageComplete, @@ -168,12 +179,38 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onVirtualizerRangeChanged, onVirtualizerScrollerChange, }: TimelineMessageListProps) { + const inlineThreadRootIds = + inlineThreadController?.rootIds ?? EMPTY_THREAD_ROOT_IDS; + const replyMessages = inlineThreadMessages ?? messages; + const videoContextMessages = + inlineThreadRootIds.size > 0 ? replyMessages : messages; const entries = React.useMemo( () => mainEntries ?? buildMainTimelineEntries(messages, undefined, threadSummaries, profiles), [mainEntries, messages, profiles, threadSummaries], ); + const inlineRepliesByThreadId = React.useMemo(() => { + if (inlineThreadRootIds.size === 0) { + return new Map(); + } + + const index = buildThreadPanelIndex(replyMessages); + const expandedReplyIds = new Set( + replyMessages + .filter((message) => message.parentId !== null) + .map((message) => message.id), + ); + const repliesByThreadId = new Map(); + for (const rootId of inlineThreadRootIds) { + repliesByThreadId.set( + rootId, + buildThreadPanelDataFromIndex(index, rootId, null, expandedReplyIds) + .visibleReplies, + ); + } + return repliesByThreadId; + }, [inlineThreadRootIds, replyMessages]); // Contexts are memoized per message id so MessageRow/Markdown memo // comparisons hold across unrelated timeline re-renders (typing // indicators, presence updates) — a fresh context object per render would @@ -184,7 +221,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ channelName, channelType, isSendingVideoReviewComment, - messages, + messages: videoContextMessages, onSendVideoReviewComment, onToggleReaction, profiles, @@ -194,7 +231,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ channelName, channelType, isSendingVideoReviewComment, - messages, + videoContextMessages, onSendVideoReviewComment, onToggleReaction, profiles, @@ -247,11 +284,20 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ currentPubkey={currentPubkey} entry={item.entry} followThreadById={followThreadById} + footerByMessageId={messageFooters} footer={messageFooters?.[item.entry.message.id] ?? null} highlightedMessageId={highlightedMessageId} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} hideAgentAccessBadges={hideAgentAccessBadges} + inlineExpanded={inlineThreadRootIds.has(item.entry.message.id)} + inlineReplies={inlineRepliesByThreadId.get(item.entry.message.id)} + inlineRepliesError={inlineThreadController?.errorRootIds.has( + item.entry.message.id, + )} + inlineRepliesPending={inlineThreadController?.pendingRootIds.has( + item.entry.message.id, + )} isContinuation={ alwaysShowMessageIdentity ? false : item.isContinuation } @@ -262,6 +308,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ } isFollowingThreadById={isFollowingThreadById} isUnread={isMessageUnreadById?.(item.entry.message.id)} + isMessageUnreadById={isMessageUnreadById} playEntrance={item.entry.message.id === entranceMessageId} onEntranceComplete={onEntranceMessageComplete} onDelete={onDelete} @@ -270,7 +317,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onMarkUnread={onMarkUnread} onReply={onReply} onOpenThread={onOpenThread} + onRetryInlineReplies={inlineThreadController?.onRetry} onToggleReaction={onToggleReaction} + onToggleInlineThread={inlineThreadController?.onToggle} profiles={profiles} searchActiveMessageId={searchActiveMessageId} searchMatchingMessageIds={searchMatchingMessageIds} @@ -280,6 +329,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ videoReviewContext={videoReviewContextById.get( item.entry.message.id, )} + videoReviewContextById={videoReviewContextById} /> ); } @@ -293,6 +343,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ huddleMemberPubkeys, huddleMemberPubkeysPending, hideAgentAccessBadges, + inlineRepliesByThreadId, + inlineThreadController, + inlineThreadRootIds, isFollowingThreadById, isMessageUnreadById, entranceMessageId, diff --git a/desktop/src/features/messages/ui/TimelineMessageRow.tsx b/desktop/src/features/messages/ui/TimelineMessageRow.tsx index 283760fb021..c37d34fdc67 100644 --- a/desktop/src/features/messages/ui/TimelineMessageRow.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageRow.tsx @@ -1,13 +1,18 @@ import * as React from "react"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { + hasSameMessageAuthor, + isWithinGroupingWindow, +} from "@/features/messages/lib/messageGrouping"; import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; -import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext"; +import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; import { MessageRow } from "./MessageRow"; +import { MessageThreadRow } from "./MessageThreadRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; @@ -65,6 +70,7 @@ type MessageRowItemProps = { entry: MainTimelineEntry; followThreadById?: (rootId: string) => void; footer: React.ReactNode; + footerByMessageId?: Record; highlightedMessageId?: string | null; huddleMemberPubkeys?: readonly string[]; huddleMemberPubkeysPending?: boolean; @@ -72,7 +78,12 @@ type MessageRowItemProps = { isContinuation?: boolean; isFollowedByContinuation?: boolean; isFollowingThreadById?: (rootId: string) => boolean; + inlineExpanded?: boolean; + inlineReplies?: MainTimelineEntry[]; + inlineRepliesError?: boolean; + inlineRepliesPending?: boolean; isUnread?: boolean; + isMessageUnreadById?: (messageId: string) => boolean; playEntrance?: boolean; onEntranceComplete?: (messageId: string) => void; onDelete?: (message: TimelineMessage) => void; @@ -80,7 +91,9 @@ type MessageRowItemProps = { onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + onRetryInlineReplies?: () => void; onOpenThread?: (message: TimelineMessage) => void; + onToggleInlineThread?: (message: TimelineMessage) => void; onToggleReaction?: ToggleReaction; profiles?: UserProfileLookup; searchActiveMessageId?: string | null; @@ -88,7 +101,8 @@ type MessageRowItemProps = { searchQuery?: string; threadUnreadCounts?: ReadonlyMap; unfollowThreadById?: (rootId: string) => void; - videoReviewContext: ReturnType; + videoReviewContext: VideoReviewContext | undefined; + videoReviewContextById?: ReadonlyMap; }; export function MessageRowItem({ @@ -97,6 +111,7 @@ export function MessageRowItem({ entry, followThreadById, footer, + footerByMessageId, highlightedMessageId, huddleMemberPubkeys, huddleMemberPubkeysPending, @@ -104,7 +119,12 @@ export function MessageRowItem({ isContinuation = false, isFollowedByContinuation = false, isFollowingThreadById, + inlineExpanded = false, + inlineReplies = [], + inlineRepliesError = false, + inlineRepliesPending = false, isUnread, + isMessageUnreadById, playEntrance = false, onEntranceComplete, onDelete, @@ -112,7 +132,9 @@ export function MessageRowItem({ onMarkUnread, onMarkRead, onReply, + onRetryInlineReplies, onOpenThread, + onToggleInlineThread, onToggleReaction, profiles, searchActiveMessageId, @@ -121,6 +143,7 @@ export function MessageRowItem({ threadUnreadCounts, unfollowThreadById, videoReviewContext, + videoReviewContextById, }: MessageRowItemProps) { const { message, summary } = entry; const canManage = canManageMessageForCurrentUser( @@ -133,59 +156,157 @@ export function MessageRowItem({ if (summary && onOpenThread) { const isHighlighted = message.id === highlightedMessageId; + let previousGroupMessage: TimelineMessage | null = message; + const inlineReplyRows = inlineReplies.map((inlineEntry, index) => { + const inlineMessage = inlineEntry.message; + const nextMessage = inlineReplies[index + 1]?.message; + const inlineCanManage = canManageMessageForCurrentUser( + inlineMessage, + currentPubkey, + profiles, + ); + const isSearchMatch = + searchMatchingMessageIds?.has(inlineMessage.id) ?? false; + const isSearchActive = inlineMessage.id === searchActiveMessageId; + const isContinuationReply = + inlineEntry.summary === null && + hasSameMessageAuthor(previousGroupMessage, inlineMessage) && + isWithinGroupingWindow( + previousGroupMessage?.createdAt, + inlineMessage.createdAt, + ); + previousGroupMessage = + inlineEntry.summary === null ? inlineMessage : null; + + return ( +
+ inlineMessage.depth + } + currentPubkey={currentPubkey} + highlighted={ + inlineMessage.id === highlightedMessageId || isSearchActive + } + hoverBackground + huddleMemberPubkeys={huddleMemberPubkeys} + huddleMemberPubkeysPending={huddleMemberPubkeysPending} + hideAgentAccessBadge={hideAgentAccessBadges} + isContinuation={isContinuationReply} + isUnread={isMessageUnreadById?.(inlineMessage.id)} + message={inlineMessage} + onDelete={inlineCanManage && onDelete ? onDelete : undefined} + onEdit={inlineCanManage && onEdit ? onEdit : undefined} + onMarkRead={onMarkRead} + onMarkUnread={onMarkUnread} + onReply={onReply} + onToggleReaction={onToggleReaction} + profiles={profiles} + searchQuery={isSearchMatch ? searchQuery : undefined} + showDepthGuides + videoReviewContext={videoReviewContextById?.get(inlineMessage.id)} + /> + {footerByMessageId?.[inlineMessage.id] ?? null} +
+ ); + }); + return ( -
- followThreadById(message.id) : undefined - } - onMarkRead={onMarkRead} - onMarkUnread={onMarkUnread} - onToggleReaction={onToggleReaction} - onReply={onReply} - onUnfollowThread={ - unfollowThreadById - ? () => unfollowThreadById(message.id) - : undefined - } - profiles={profiles} - showDepthGuides={false} - videoReviewContext={videoReviewContext} - /> - - {footer} +
+
+ followThreadById(message.id) : undefined + } + onMarkRead={onMarkRead} + onMarkUnread={onMarkUnread} + onToggleReaction={onToggleReaction} + onReply={onReply} + onUnfollowThread={ + unfollowThreadById + ? () => unfollowThreadById(message.id) + : undefined + } + profiles={profiles} + showDepthGuides={false} + videoReviewContext={videoReviewContext} + /> + + {footer} +
+ {inlineExpanded ? ( +
+ {inlineReplyRows} + {inlineRepliesError || inlineReplyRows.length === 0 ? ( +
+ {inlineRepliesError ? ( + <> + Replies could not be loaded. + {onRetryInlineReplies ? ( + + ) : null} + + ) : inlineRepliesPending ? ( + "Loading replies…" + ) : ( + "No replies available." + )} +
+ ) : null} +
+ ) : null}
); } diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 4d602348f95..35a5b5c7089 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -1,3 +1,5 @@ +import * as React from "react"; + import { type QueryClient, useQueries, @@ -9,11 +11,23 @@ import { threadRepliesKey, sortMessages, } from "@/features/messages/lib/messageQueryKeys"; +import { getThreadReference } from "@/features/messages/lib/threading"; +import type { TimelineMessage } from "@/features/messages/types"; import { getThreadReplies } from "@/shared/api/tauri"; import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types"; const THREAD_PAGE_LIMIT = 200; const MAX_THREAD_PAGES = 500; +const EMPTY_THREAD_ROOT_IDS: ReadonlySet = new Set(); + +export type InlineThreadController = { + errorRootIds: ReadonlySet; + fetchingRootIds: ReadonlySet; + pendingRootIds: ReadonlySet; + rootIds: ReadonlySet; + onRetry: () => void; + onToggle: (message: TimelineMessage) => void; +}; async function loadThreadReplies( queryClient: QueryClient, @@ -67,6 +81,15 @@ export function useThreadReplies( }); } +type ThreadReplyQueryResult = { + data?: RelayEvent[]; + isPending: boolean; + isFetching: boolean; + isError: boolean; + error: unknown; + refetch: () => unknown; +}; + /** * Aggregate a set of per-root thread-reply query results into one view for a * multi-root consumer. Pure over the results array so the load-bearing @@ -79,13 +102,7 @@ export function useThreadReplies( * needlessly re-fetched. */ export function combineThreadRepliesResults( - results: readonly { - data?: RelayEvent[]; - isPending: boolean; - isError: boolean; - error: unknown; - refetch: () => unknown; - }[], + results: readonly ThreadReplyQueryResult[], ) { return { events: sortMessages(results.flatMap((result) => result.data ?? [])), @@ -100,18 +117,36 @@ export function combineThreadRepliesResults( }; } -/** - * Load every summarized reply subtree for a channel-style Huddle transcript. - * Ordinary channels keep replies in their thread panels; Huddles flatten those - * replies into the chat timeline so companion and in-app presentations show the - * same conversation without opening a transient thread surface. - */ +export function combineThreadRepliesForRoots( + rootIds: readonly string[], + results: readonly ThreadReplyQueryResult[], +) { + return { + ...combineThreadRepliesResults(results), + errorRootIds: new Set( + rootIds.filter((_rootId, index) => results[index]?.isError), + ), + fetchingRootIds: new Set( + rootIds.filter((_rootId, index) => results[index]?.isFetching), + ), + pendingRootIds: new Set( + rootIds.filter((_rootId, index) => results[index]?.isPending), + ), + }; +} + +/** Load multiple reply subtrees into independently cached per-root queries. */ export function useThreadRepliesForRoots( activeChannel: Channel | null, rootIds: readonly string[], ) { const queryClient = useQueryClient(); const channelId = activeChannel?.id ?? "none"; + const combine = React.useCallback( + (results: readonly ThreadReplyQueryResult[]) => + combineThreadRepliesForRoots(rootIds, results), + [rootIds], + ); return useQueries({ queries: rootIds.map((rootId) => ({ queryKey: threadRepliesKey(channelId, rootId), @@ -120,6 +155,135 @@ export function useThreadRepliesForRoots( staleTime: 0, gcTime: 60 * 60 * 1_000, })), - combine: combineThreadRepliesResults, + combine, }); } + +/** Controls on-demand reply subtrees rendered inside the main conversation. */ +export function useInlineThreadReplies(activeChannel: Channel | null): { + controller: InlineThreadController; + events: RelayEvent[]; +} { + const activeChannelId = activeChannel?.id ?? null; + const [state, setState] = React.useState<{ + channelId: string | null; + rootIds: Set; + }>(() => ({ channelId: null, rootIds: new Set() })); + const rootIds = + state.channelId === activeChannelId ? state.rootIds : EMPTY_THREAD_ROOT_IDS; + const rootIdList = React.useMemo(() => [...rootIds], [rootIds]); + const replies = useThreadRepliesForRoots(activeChannel, rootIdList); + const onToggle = React.useCallback( + (message: TimelineMessage) => { + if (!activeChannelId) return; + setState((current) => { + const nextRootIds = new Set( + current.channelId === activeChannelId ? current.rootIds : [], + ); + if (nextRootIds.has(message.id)) { + nextRootIds.delete(message.id); + } else { + nextRootIds.add(message.id); + } + return { channelId: activeChannelId, rootIds: nextRootIds }; + }); + }, + [activeChannelId], + ); + const onRetry = React.useCallback(() => replies.refetch(), [replies.refetch]); + const controller = React.useMemo( + () => ({ + errorRootIds: replies.errorRootIds, + fetchingRootIds: replies.fetchingRootIds, + onRetry, + onToggle, + pendingRootIds: replies.pendingRootIds, + rootIds, + }), + [ + onRetry, + onToggle, + replies.errorRootIds, + replies.fetchingRootIds, + replies.pendingRootIds, + rootIds, + ], + ); + + return React.useMemo( + () => ({ controller, events: replies.events }), + [controller, replies.events], + ); +} + +export function collectNewlyRevealedInlineReplyIds({ + rootIds, + pendingRootIds, + fetchingRootIds, + errorRootIds, + events, + revealedRootIds, +}: { + rootIds: ReadonlySet; + pendingRootIds: ReadonlySet; + fetchingRootIds: ReadonlySet; + errorRootIds: ReadonlySet; + events: readonly RelayEvent[]; + revealedRootIds: ReadonlySet; +}) { + const activeRevealedRootIds = new Set( + [...revealedRootIds].filter((rootId) => rootIds.has(rootId)), + ); + const replyIdsByRoot = new Map(); + for (const event of events) { + const rootId = getThreadReference(event.tags).rootId; + if (!rootId || !rootIds.has(rootId)) continue; + const ids = replyIdsByRoot.get(rootId); + if (ids) ids.push(event.id); + else replyIdsByRoot.set(rootId, [event.id]); + } + + const messageIds: string[] = []; + for (const rootId of rootIds) { + if ( + activeRevealedRootIds.has(rootId) || + pendingRootIds.has(rootId) || + fetchingRootIds.has(rootId) + ) + continue; + messageIds.push(rootId, ...(replyIdsByRoot.get(rootId) ?? [])); + if (!errorRootIds.has(rootId)) activeRevealedRootIds.add(rootId); + } + return { messageIds, revealedRootIds: activeRevealedRootIds }; +} + +export function useMarkInlineRepliesRead( + inlineReplies: { + controller: InlineThreadController; + events: RelayEvent[]; + }, + markRevealedRepliesRead: (messageId: string) => void, +) { + const revealedRootIdsRef = React.useRef>(new Set()); + React.useEffect(() => { + const revealed = collectNewlyRevealedInlineReplyIds({ + rootIds: inlineReplies.controller.rootIds, + fetchingRootIds: inlineReplies.controller.fetchingRootIds, + pendingRootIds: inlineReplies.controller.pendingRootIds, + errorRootIds: inlineReplies.controller.errorRootIds, + events: inlineReplies.events, + revealedRootIds: revealedRootIdsRef.current, + }); + revealedRootIdsRef.current = revealed.revealedRootIds; + for (const messageId of revealed.messageIds) { + markRevealedRepliesRead(messageId); + } + }, [ + inlineReplies.controller.fetchingRootIds, + inlineReplies.controller.errorRootIds, + inlineReplies.controller.pendingRootIds, + inlineReplies.controller.rootIds, + inlineReplies.events, + markRevealedRepliesRead, + ]); +} diff --git a/desktop/tests/e2e/inline-thread-replies.spec.ts b/desktop/tests/e2e/inline-thread-replies.spec.ts new file mode 100644 index 00000000000..55b272feb84 --- /dev/null +++ b/desktop/tests/e2e/inline-thread-replies.spec.ts @@ -0,0 +1,197 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; +const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); + +async function waitForMockLiveSubscription(page: Page, channelName: string) { + await expect + .poll(() => + page.evaluate( + ({ name }) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: name, + }) ?? false, + { name: channelName }, + ), + ) + .toBe(true); +} +async function seedThread(page: Page, channelName: string, label: string) { + return page.evaluate( + ({ channel, surface, alicePubkey, bobPubkey, currentPubkey }) => { + const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: channel, + content: `${surface} planning thread`, + createdAt: 1_708_000_000, + pubkey: alicePubkey, + }); + if (!root) throw new Error("Failed to seed inline thread root"); + + for (let index = 0; index < 30; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: channel, + content: `${surface} later message ${index}`, + createdAt: 1_708_000_100 + index, + pubkey: bobPubkey, + }); + } + + const reply = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: channel, + content: `${surface} direct reply`, + createdAt: 1_708_000_200, + parentEventId: root.id, + pubkey: bobPubkey, + }); + if (!reply) throw new Error("Failed to seed inline thread reply"); + + const nestedReply = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: channel, + content: `${surface} nested reply`, + createdAt: 1_708_000_201, + parentEventId: reply.id, + pubkey: currentPubkey, + }); + if (!nestedReply) throw new Error("Failed to seed nested inline reply"); + + return { + nestedReplyContent: nestedReply.content, + nestedReplyId: nestedReply.id, + replyContent: reply.content, + rootContent: root.content, + rootId: root.id, + }; + }, + { + alicePubkey: TEST_IDENTITIES.alice.pubkey, + bobPubkey: TEST_IDENTITIES.bob.pubkey, + currentPubkey: MOCK_IDENTITY_PUBKEY, + channel: channelName, + surface: label, + }, + ); +} + +for (const surface of [ + { + channelName: "general", + label: "Channel", + screenshot: "test-results/inline-thread-replies/channel.png", + expectUnreadReply: false, + }, + { + channelName: "alice-tyler", + label: "DM", + screenshot: "test-results/inline-thread-replies/dm.png", + expectUnreadReply: true, + }, +]) { + test(`${surface.label} thread replies expand in the main conversation`, async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId(`channel-${surface.channelName}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText( + surface.channelName, + ); + await waitForMockLiveSubscription(page, surface.channelName); + + const thread = await seedThread(page, surface.channelName, surface.label); + const summary = page.locator( + `[data-testid="message-thread-summary"][data-thread-head-id="${thread.rootId}"]`, + ); + const toggle = page.locator( + `[data-testid="message-thread-inline-toggle"][data-thread-head-id="${thread.rootId}"]`, + ); + const timeline = page.getByTestId("message-timeline"); + await summary.scrollIntoViewIfNeeded(); + await expect + .poll(() => + timeline.evaluate( + (element) => + element.scrollTop + element.clientHeight < element.scrollHeight - 8, + ), + ) + .toBe(true); + await expect(summary).toBeVisible(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + await expect( + page.getByText(thread.replyContent, { exact: true }), + ).toHaveCount(0); + if (surface.expectUnreadReply) { + await expect(page.getByTestId("thread-unread-badge")).toBeVisible(); + } + + await toggle.focus(); + await toggle.press("Enter"); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + await expect(toggle).toHaveText("Hide replies"); + const inlineReplies = page.getByTestId("message-thread-inline-replies"); + await expect(inlineReplies).toBeVisible(); + await expect( + inlineReplies.getByText(thread.replyContent, { exact: true }), + ).toBeVisible(); + await expect( + inlineReplies.getByText(thread.nestedReplyContent, { exact: true }), + ).toBeVisible(); + if (surface.expectUnreadReply) { + await expect(page.getByTestId("thread-unread-badge")).toHaveCount(0); + } + await expect(page.getByTestId("message-thread-panel")).toHaveCount(0); + + await waitForAnimations(page); + await inlineReplies.locator("..").screenshot({ path: surface.screenshot }); + + const directReplyRow = inlineReplies + .getByTestId("message-row") + .filter({ hasText: thread.replyContent }); + await directReplyRow.hover(); + const replyButton = directReplyRow.getByRole("button", { name: "Reply" }); + await replyButton.focus(); + await replyButton.press("Enter"); + let threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + thread.rootContent, + ); + await expect(threadPanel.getByTestId("reply-target")).toContainText( + thread.replyContent, + ); + await page.getByTestId("auxiliary-panel-close").click(); + await expect(threadPanel).toHaveCount(0); + + const nestedReplyRow = inlineReplies + .getByTestId("message-row") + .filter({ hasText: thread.nestedReplyContent }); + await nestedReplyRow.hover(); + const moreActionsButton = nestedReplyRow.getByLabel("More actions"); + await moreActionsButton.focus(); + await moreActionsButton.press("Enter"); + const editMenuItem = page.getByTestId( + `edit-message-${thread.nestedReplyId}`, + ); + await expect(editMenuItem).toBeVisible(); + await editMenuItem.click(); + threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + thread.rootContent, + ); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await threadPanel.getByRole("button", { name: "Cancel edit" }).click(); + await page.getByTestId("auxiliary-panel-close").click(); + await expect(threadPanel).toHaveCount(0); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + await expect(inlineReplies).toHaveCount(0); + await summary.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await page.getByTestId("auxiliary-panel-close").click(); + await expect(page.getByTestId("message-thread-panel")).toHaveCount(0); + }); +}