import * as React from "react"; import { ArrowDown } from "lucide-react"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { buildThreadSummaryFromVisibleEntries, hasNestedThreadBranches, type MainTimelineEntry, } from "@/features/messages/lib/threadPanel"; import { hasSameMessageAuthor, isWithinGroupingWindow, } from "@/features/messages/lib/messageGrouping"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { cn } from "@/shared/lib/cn"; import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel"; import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel"; import { AuxiliaryPanelHeader, AuxiliaryPanelHeaderGroup, AuxiliaryPanelTitle, } from "@/shared/layout/AuxiliaryPanel"; import { THREAD_PANEL_COLUMN_CLASS, THREAD_PANEL_COMPOSER_GUTTER_CLASS, THREAD_PANEL_MESSAGE_GUTTER_CLASS, } from "@/features/messages/lib/messageThreadPanelLayout"; import { Button } from "@/shared/ui/button"; import { Separator } from "@/shared/ui/separator"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageComposer } from "./MessageComposer"; import { ThreadMessageSkeleton } from "./MessageThreadPanelSkeleton"; import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { UnreadDivider } from "./UnreadDivider"; import { useComposerHeightPadding } from "./useComposerHeightPadding"; import { useAnchoredScroll } from "./useAnchoredScroll"; import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot"; type MessageThreadPanelProps = ThreadPanelLayoutProps & { channel: Channel | null; channelId: string | null; channelName: string; currentPubkey?: string; disabled?: boolean; firstUnreadReplyId?: string | null; huddleMemberPubkeys?: readonly string[]; huddleMemberPubkeysPending?: boolean; editTarget?: { author: string; body: string; id: string; imetaMedia?: ImetaMedia[]; } | null; isSending: boolean; onCancelEdit?: () => void; onCancelReply: () => void; onClose: () => void; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onEditLastOwnMessage?: () => boolean; onEditSave?: ( content: string, mediaTags?: string[][], mentionPubkeys?: string[], ) => Promise; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; onExpandReplies: (message: TimelineMessage) => void; onScrollTargetResolved: () => void; scrollTargetHighlights?: boolean; onSelectReplyTarget: (message: TimelineMessage) => void; onSend: ( content: string, mentionPubkeys: string[], mediaTags?: string[][], channelId?: string | null, threadContext?: { parentEventId: string | null; threadHeadId: string | null; } | null, ) => Promise; onToggleReaction?: ( message: TimelineMessage, emoji: string, remove: boolean, ) => Promise; profiles?: UserProfileLookup; replyTargetMessage: TimelineMessage | null; scrollTargetId: string | null; threadHead: TimelineMessage | null; threadReplies: MainTimelineEntry[]; threadRepliesPending?: boolean; threadUnreadCount?: number; threadReplyUnreadCounts?: ReadonlyMap; threadTypingPubkeys: string[]; threadHeadVideoReviewContext?: VideoReviewContext; toolbarExtraActions?: React.ReactNode; widthPx: number; isFollowingThread?: boolean; isMessageUnreadById?: (messageId: string) => boolean; onFollowThread?: () => void; onUnfollowThread?: () => void; /** * When set to `thread:`, the thread composer auto-submits * once on mount (Send-from-drafts flow). Must be cleared by * `onAutoSubmitComplete` before `submitMessage` fires so the param cannot * re-trigger on back-navigation. */ autoSendDraftKey?: string | null; /** Called when the thread-composer auto-submit fires so the parent can clear the trigger. */ onAutoSubmitComplete?: () => void; }; const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = []; const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = 0; function hasLaterVisibleSibling( entries: readonly MainTimelineEntry[], entryIndex: number, ): boolean { const depth = entries[entryIndex]?.message.depth; if (depth == null) { return false; } for (let index = entryIndex + 1; index < entries.length; index += 1) { const nextDepth = entries[index].message.depth; if (nextDepth <= depth) { return nextDepth === depth; } } return false; } function getActiveContinuationDepths({ ancestors, entries, index, message, }: { ancestors: readonly { index: number; message: TimelineMessage }[]; entries: readonly MainTimelineEntry[]; index: number; message: TimelineMessage; }): number[] { const depths: number[] = []; for (const ancestor of ancestors) { if (ancestor.message.depth === 0) { continue; } const childDepth = ancestor.message.depth + 1; const pathChild = message.depth === childDepth ? { index, message } : ancestors.find((candidate) => candidate.message.depth === childDepth); if (pathChild && hasLaterVisibleSibling(entries, pathChild.index)) { depths.push(ancestor.message.depth); } } return depths; } export function MessageThreadPanel({ channel, channelId, channelName, columnMaxWidthPx, currentPubkey, disabled = false, firstUnreadReplyId, huddleMemberPubkeys, huddleMemberPubkeysPending = false, layout = "standalone", editTarget, headerLeading, isSending, isFocusMode, isSinglePanelView = false, isFollowingThread, isMessageUnreadById, onCancelEdit, onCancelReply, onClose, onDelete, onEdit, onEditLastOwnMessage, onEditSave, onFollowThread, onMarkUnread, onMarkRead, onExpandReplies, onScrollTargetResolved, onSelectReplyTarget, onSend, onToggleReaction, onUnfollowThread, profiles, replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, threadHead, threadHeadVideoReviewContext, threadReplies, threadRepliesPending = false, threadUnreadCount, threadReplyUnreadCounts, threadTypingPubkeys, toolbarExtraActions, widthPx, transparentChrome = false, autoSendDraftKey = null, onAutoSubmitComplete, }: MessageThreadPanelProps) { const threadBodyRef = React.useRef(null); const threadContentRef = React.useRef(null); const threadComposerWrapperRef = React.useRef(null); const [hoveredCollapseBranchId, setHoveredCollapseBranchId] = React.useState< string | null >(null); const [collapsedThreadHeadId, setCollapsedThreadHeadId] = React.useState< string | null >(null); const isOverlay = useIsThreadPanelOverlay(); const threadHeadId = threadHead?.id ?? null; useEscapeKey(onClose, isOverlay || isSinglePanelView || isFocusMode); const hasConstrainedColumn = columnMaxWidthPx != null; useComposerHeightPadding( threadBodyRef, threadComposerWrapperRef, isSinglePanelView, ); // Live ref so onCaptureSendContext can read reply state at submit time // (before any async mention-flow awaits change navigation state). const replyTargetMessageRef = React.useRef(replyTargetMessage); replyTargetMessageRef.current = replyTargetMessage; const onCaptureSendContext = React.useCallback( () => ({ parentEventId: replyTargetMessageRef.current?.id ?? threadHeadId, threadHeadId, }), [threadHeadId], ); const collapseThreadHeadReplies = React.useCallback(() => { if (!threadHeadId) { return; } setHoveredCollapseBranchId(null); setCollapsedThreadHeadId(threadHeadId); }, [threadHeadId]); const expandThreadHeadReplies = React.useCallback(() => { setHoveredCollapseBranchId(null); setCollapsedThreadHeadId(null); }, []); const handleCollapseBranchHoverChange = React.useCallback( (message: TimelineMessage, hovered: boolean) => { setHoveredCollapseBranchId((current) => { if (hovered) { return message.id; } return current === message.id ? null : current; }); }, [], ); const handleCollapseDepthGuide = React.useCallback( (message: TimelineMessage) => { if (message.id === threadHeadId) { collapseThreadHeadReplies(); return; } onExpandReplies(message); }, [collapseThreadHeadReplies, onExpandReplies, threadHeadId], ); const composerReplyTarget = replyTargetMessage && threadHead && replyTargetMessage.id !== threadHead.id ? { author: replyTargetMessage.author, body: replyTargetMessage.body, id: replyTargetMessage.id, } : null; const deferredThreadReplies = React.useDeferredValue( threadReplies, EMPTY_THREAD_REPLIES, ); const isRepliesPending = deferredThreadReplies !== threadReplies; const scrollTargetIsVisibleReply = React.useMemo( () => scrollTargetId !== null && scrollTargetId !== threadHeadId && deferredThreadReplies.some( (entry) => entry.message.id === scrollTargetId, ), [deferredThreadReplies, scrollTargetId, threadHeadId], ); const isThreadHeadRepliesCollapsed = collapsedThreadHeadId === threadHeadId && !scrollTargetIsVisibleReply; React.useLayoutEffect(() => { if (scrollTargetIsVisibleReply && collapsedThreadHeadId === threadHeadId) { setCollapsedThreadHeadId(null); } }, [collapsedThreadHeadId, scrollTargetIsVisibleReply, threadHeadId]); // Which of the three states the reply region paints this frame. Delegated to // a pure helper so the "don't flash empty over an incoming list" rule is // covered in the lib test suite (see selectDeferredListRenderState). const repliesRenderState = selectDeferredListRenderState( deferredThreadReplies.length, threadReplies.length, ); const threadHeadSummary = React.useMemo(() => { if (!threadHeadId) { return null; } return buildThreadSummaryFromVisibleEntries( threadHeadId, deferredThreadReplies, ); }, [deferredThreadReplies, threadHeadId]); const visibleThreadHeadSummary = isThreadHeadRepliesCollapsed ? threadHeadSummary : null; // Focus mode gives the thread a subject/body structure: the head is what the // thread is about, the replies are the conversation about it. Only draw the // rule when there is actually conversation under it — the "no replies yet" // card and the streaming-in `pending` state would both leave a rule hanging // over an empty region or a placeholder. const showThreadHeadDivider = isFocusMode && (threadRepliesPending || repliesRenderState === "list"); const threadMessages = React.useMemo( () => deferredThreadReplies.map((entry) => entry.message), [deferredThreadReplies], ); const shouldShowThreadBranchGuides = React.useMemo( () => hasNestedThreadBranches(deferredThreadReplies), [deferredThreadReplies], ); const highlightedBranch = React.useMemo(() => { if (!hoveredCollapseBranchId) { return null; } if (hoveredCollapseBranchId === threadHeadId) { return { depth: 0, endIndex: deferredThreadReplies.length - 1, id: hoveredCollapseBranchId, startIndex: -1, }; } const startIndex = deferredThreadReplies.findIndex( (entry) => entry.message.id === hoveredCollapseBranchId, ); if (startIndex < 0) { return null; } const depth = deferredThreadReplies[startIndex].message.depth; let endIndex = startIndex; while ( endIndex + 1 < deferredThreadReplies.length && deferredThreadReplies[endIndex + 1].message.depth > depth ) { endIndex += 1; } return { depth, endIndex, id: hoveredCollapseBranchId, startIndex, }; }, [deferredThreadReplies, hoveredCollapseBranchId, threadHeadId]); const threadReplyRenderItems = React.useMemo(() => { if (!threadHead) { return []; } const ancestorStack: { index: number; message: TimelineMessage }[] = [ { index: -1, message: threadHead }, ]; let previousGroupMessage: TimelineMessage | null = threadHead; return deferredThreadReplies.map((entry, index) => { while ( ancestorStack.length > 0 && ancestorStack[ancestorStack.length - 1].message.depth >= entry.message.depth ) { ancestorStack.pop(); } const ancestors = [...ancestorStack]; const continuationDepths = getActiveContinuationDepths({ ancestors, entries: deferredThreadReplies, index, message: entry.message, }); const collapseDepthGuideAncestors = ancestors.filter((ancestor) => continuationDepths.includes(ancestor.message.depth), ); const collapseDepthGuideActions: ThreadDepthGuideAction[] | undefined = collapseDepthGuideAncestors.length > 0 ? collapseDepthGuideAncestors.map((ancestor) => ({ active: hoveredCollapseBranchId === ancestor.message.id && entry.message.depth === ancestor.message.depth + 1, depth: ancestor.message.depth, label: ancestor.message.id === threadHead.id ? "Collapse thread" : "Collapse replies", message: ancestor.message, })) : undefined; const nextEntry = deferredThreadReplies[index + 1]; const connectsToVisibleChild = nextEntry != null && nextEntry.message.depth > entry.message.depth; const startsUnreadSection = index > 0 && entry.message.id === firstUnreadReplyId; const isContinuation = !startsUnreadSection && entry.summary === null && hasSameMessageAuthor(previousGroupMessage, entry.message) && isWithinGroupingWindow( previousGroupMessage?.createdAt, entry.message.createdAt, ); if (connectsToVisibleChild && !entry.summary) { ancestorStack.push({ index, message: entry.message }); } previousGroupMessage = entry.summary !== null ? null : entry.message; return { collapseDepthGuideActions, connectsToVisibleChild, continuationDepths, entry, index, isContinuation, }; }); }, [ deferredThreadReplies, firstUnreadReplyId, hoveredCollapseBranchId, threadHead, ]); const { isAtBottom, newMessageCount, onScroll, scrollToBottom } = useAnchoredScroll({ channelId: threadHeadId, contentRef: threadContentRef, isLoading: threadRepliesPending || repliesRenderState === "pending", messages: threadMessages, highlightTargetMessage: scrollTargetHighlights, onTargetReached: onScrollTargetResolved, scrollContainerRef: threadBodyRef, targetMessageId: scrollTargetId, }); const knownAgentPubkeys = useKnownAgentPubkeys(); const initialAgentPubkeys = React.useMemo(() => { if ( !threadHead || !currentPubkey || normalizePubkey(threadHead.signerPubkey ?? threadHead.pubkey ?? "") !== normalizePubkey(currentPubkey) ) { return []; } const { mentionPubkeysByName } = resolveMentionProps( threadHead.tags, profiles, ); if (!mentionPubkeysByName) return []; return orderMentionPubkeysByText( threadHead.body, mentionPubkeysByName, (pubkey) => knownAgentPubkeys.has(pubkey) || profiles?.[pubkey]?.isAgent === true, ); }, [currentPubkey, knownAgentPubkeys, profiles, threadHead]); if (!threadHead) { return null; } const threadScrollRegion = (
onFollowThread() : undefined } onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} onToggleReaction={onToggleReaction} onUnfollowThread={ onUnfollowThread ? (_msg) => onUnfollowThread() : undefined } profiles={profiles} showDepthGuides={shouldShowThreadBranchGuides} videoReviewContext={threadHeadVideoReviewContext} />
{showThreadHeadDivider ? (
) : null}
{threadRepliesPending ? (
) : repliesRenderState === "list" ? ( visibleThreadHeadSummary ? (
) : (
{threadReplyRenderItems.map((item) => { const { collapseDepthGuideActions, connectsToVisibleChild, continuationDepths, entry, index, isContinuation, } = item; const showUnreadDivider = index > 0 && entry.message.id === firstUnreadReplyId; const isHighlightedBranchOwner = highlightedBranch?.id === entry.message.id; const isInsideHighlightedBranch = highlightedBranch != null && index > highlightedBranch.startIndex && index <= highlightedBranch.endIndex; const isDirectChildOfHighlightedBranch = isInsideHighlightedBranch && highlightedBranch != null && index > highlightedBranch.startIndex && index <= highlightedBranch.endIndex && entry.message.depth === highlightedBranch.depth + 1; const highlightedLineDepths = shouldShowThreadBranchGuides && isInsideHighlightedBranch && highlightedBranch ? [highlightedBranch.depth] : undefined; return (
{showUnreadDivider ? : null} {entry.summary ? ( ) : null}
); })}
) ) : repliesRenderState === "empty" ? ( // Only show the empty state when the thread is GENUINELY empty. // Keying off `deferredThreadReplies` would flash "No replies" for a // frame while a non-empty list streams in on the deferred commit.

No replies in this branch yet

Reply in the thread to continue this branch.

) : // "pending": deferred list is empty but the live list has content — // rows are streaming in on the deferred commit. Paint nothing rather // than flashing the empty state. null}
); const threadFooter = ( <> {!isAtBottom ? (
) : null}
{toolbarExtraActions ? (
{toolbarExtraActions}
) : null} {threadTypingPubkeys.length > 0 ? ( ) : null}
); const threadHeaderContent = ( <> Thread ); return ( {threadHeaderContent} } isSinglePanelView={isSinglePanelView} layout={layout} onClose={onClose} testId="message-thread-panel" transparentChrome={transparentChrome} widthPx={widthPx} > {threadScrollRegion} ); }