mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
**Category:** improvement **User Impact:** Agent addressing now stays visible, editable, and consistent across messages, so users can keep talking to the same agents without rebuilding the audience for every send. **Problem:** Agent addressing was transient and easy to lose track of. Inline mention text, persistent audience state, restored drafts, and sent-message rendering could drift apart, making it unclear who would receive the next message. **Solution:** The composer now treats addressed agents as a persistent audience represented by ordinary editable mention chips and a synchronized compact avatar tray. Eligible agent mentions can become persistent immediately, addressing survives sends without flashing empty, and removing or explicitly unpinning an agent updates that state without disturbing unrelated draft text. `Cmd+Shift+M` / `Ctrl+Shift+M` provides a fast way to toggle the current recipient or select the best eligible agent. - Promotes eligible inline agent mentions into persistent addressing when the preference is enabled; human mentions remain unchanged. - Keeps inline chips, restored drafts, persistent recipient state, and the avatar tray synchronized. - Clears an agent's persistent address when its final chip is removed, while explicit unpinning preserves unrelated draft text. - Restores persistent mentions optimistically during send and restores the full draft on failure, avoiding an empty-composer flash. - Deduplicates inline mentions and tag-backed address prefixes in sent messages. - Toggles the current addressed agent with `Cmd+Shift+M` / `Ctrl+Shift+M`, or selects the best eligible agent when none is addressed. - Ranks shortcut selection by recent explicit stream mentions, then active/member/runnable/stable fallbacks; DM participant fan-out does not affect recency. - Adds focused avatar feedback for entry/exit, send bounce, and failure shake, with reduced-motion and narrow-layout support. - Applies the same behavior to main and thread composers while preserving draft restoration, channel switching, and send-race correctness. ### Screenshots #### Light theme The compact tray keeps persistent recipients visible alongside editable mention chips.  #### Dark theme Persistent addressing remains clear and legible in the dark composer treatment.  ### Reproduction steps 1. Open a channel with an eligible agent and enable persistent agent addressing. 2. Insert an agent mention and confirm it appears as a normal editable chip while the compact avatar tray reflects the same persistent recipient. 3. Send several messages and confirm the agent remains addressed, the mention restores without an empty-composer flash, and sent messages do not duplicate the addressed agent. 4. Remove the agent's final mention chip and confirm its persistent address clears. Add unrelated draft text, explicitly unpin the agent, and confirm that text remains intact. 5. Press `Cmd+Shift+M` on macOS or `Ctrl+Shift+M` on Windows/Linux. Confirm it toggles the current addressed agent or, when none is selected, chooses the most relevant eligible agent. 6. Verify recent explicit channel/thread mentions influence shortcut selection, while DM participant fan-out does not. 7. Force a failed send and confirm the addressed avatars shake and the full draft is restored; confirm successful sends use the subtler bounce feedback. 8. Repeat in the thread composer, after switching channels, in dark theme, with reduced motion enabled, and at a narrow composer width. <details> <summary>File changes</summary> **desktop/src/features/channels/ui/ChannelPane.tsx** Feeds recent explicit agent mentions into the composer for contextual shortcut selection. **desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts** and tests Show tag-backed addressed agents only when they are not already represented by inline mention text. **desktop/src/features/messages/lib/mentionCandidates.ts**, **mentionRanking.ts**, **mentionSuggestionMapping.ts**, **recentMentionPubkeys.ts**, and tests Model agent eligibility and activity, derive explicit mention recency, and rank the best shortcut recipient. **desktop/src/features/messages/lib/useActiveAgentPubkeys.ts**, **useDefaultAgentSuggestion.ts**, and **useMentions.ts** Expose active-agent and recent-recipient context to mention consumers. **desktop/src/features/messages/ui/ComposerAddressControls.tsx** and tests Render the compact persistent-recipient tray with enter, exit, send, failure, layout, and reduced-motion states. **desktop/src/features/messages/ui/MessageComposer.tsx** and **MessageComposer.types.ts** Connect editable mention text, persistent addressing, shortcut selection, draft restoration, and send feedback. **desktop/src/features/messages/ui/MessageRow.tsx** Avoids duplicating addressed agents already rendered as inline mentions. **desktop/src/features/messages/ui/MessageThreadPanel.tsx** Brings the same recency and persistent-addressing behavior to thread composers. **desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**, **useAutoPinMentionedAgents.ts**, **useAlwaysAddressShortcut.ts**, **useMentionSendFlow.ts**, and tests Synchronize mention chips with persistent audience state, promote eligible mentions, handle shortcut toggling/default selection, and preserve recipients through send success or failure. **desktop/src/features/messages/ui/useComposerPasteHandler.ts** and **useMentionSendFlow.types.ts** Keep the composer implementation focused by extracting paste and send-flow boundaries. **desktop/src/shared/lib/keyboard-shortcuts.ts** Documents `Cmd+Shift+M` / `Ctrl+Shift+M` as the agent-address shortcut. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Exercises persistence, selection, removal, sends, failures, restored drafts, themes, reduced motion, and narrow layouts. </details> --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
932 lines
34 KiB
TypeScript
932 lines
34 KiB
TypeScript
import * as React from "react";
|
|
import { ArrowDown } from "lucide-react";
|
|
|
|
import { HuddleTranscriptIntro } from "@/features/huddle/components/HuddleTranscriptIntro";
|
|
import {
|
|
buildThreadSummaryFromVisibleEntries,
|
|
getActiveContinuationDepths,
|
|
hasNestedThreadBranches,
|
|
type MainTimelineEntry,
|
|
} from "@/features/messages/lib/threadPanel";
|
|
import {
|
|
hasSameMessageAuthor,
|
|
isWithinGroupingWindow,
|
|
} from "@/features/messages/lib/messageGrouping";
|
|
import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types";
|
|
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
|
|
import type { TimelineMessage } from "@/features/messages/types";
|
|
import type { VideoReviewPresentation } from "@/features/messages/lib/videoReviewContext";
|
|
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 { VideoReviewNavigationProvider } from "@/shared/ui/VideoReviewNavigation";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
|
|
import { AuxiliaryPanelBody } 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 { ComposerActivityAccessory } from "./ComposerActivityAccessory";
|
|
import { ComposerDockBackdrop } from "./ComposerDockBackdrop";
|
|
import { MessageComposer } from "./MessageComposer";
|
|
import {
|
|
MessageThreadPanelHeader,
|
|
ThreadMessageSkeleton,
|
|
} from "./MessageThreadPanelSkeleton";
|
|
import type { ThreadDepthGuideAction } from "./MessageRow";
|
|
import { MessageThreadRow } from "./MessageThreadRow";
|
|
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
|
import { ThreadReplyRegion } from "./MessageThreadReplyState";
|
|
import { TypingIndicatorRow } from "./TypingIndicatorRow";
|
|
import { UnreadDivider } from "./UnreadDivider";
|
|
import { useComposerHeightPadding } from "./useComposerHeightPadding";
|
|
import { useStableSendToChannel } from "./useStableSendToChannel";
|
|
import { useAnchoredScroll } from "./useAnchoredScroll";
|
|
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
|
|
import { selectThreadRowHighlight } from "@/features/messages/lib/threadReplyHighlight";
|
|
|
|
type MessageThreadPanelProps = ThreadPanelLayoutProps & {
|
|
channel: Channel | null;
|
|
channelId: string | null;
|
|
channelName: string;
|
|
currentPubkey?: string;
|
|
disabled?: boolean;
|
|
firstUnreadReplyId?: string | null;
|
|
huddleMemberPubkeys?: readonly string[];
|
|
huddleMemberPubkeysPending?: boolean;
|
|
/** Present the huddle's parent-channel thread as a dedicated live chat. */
|
|
isHuddleTranscript?: boolean;
|
|
editTarget?: MessageComposerEditTarget | 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<void>;
|
|
onMarkUnread?: (message: TimelineMessage) => void;
|
|
onMarkRead?: (message: TimelineMessage) => void;
|
|
onExpandReplies: (message: TimelineMessage) => void;
|
|
onScrollTargetResolved: () => void;
|
|
onScrollTargetSettled?: (messageId: string) => void;
|
|
scrollTargetHighlights?: boolean;
|
|
searchMessageId?: string | null;
|
|
searchQuery?: string;
|
|
onSelectReplyTarget: (message: TimelineMessage) => void;
|
|
onSend: (
|
|
content: string,
|
|
mentionPubkeys: string[],
|
|
mediaTags?: string[][],
|
|
channelId?: string | null,
|
|
threadContext?: {
|
|
parentEventId: string | null;
|
|
threadHeadId: string | null;
|
|
} | null,
|
|
forceRest?: boolean,
|
|
) => Promise<void>;
|
|
onSendToChannel?: (
|
|
message: TimelineMessage,
|
|
threadRoot: TimelineMessage,
|
|
channelId: string,
|
|
) => Promise<void>;
|
|
onToggleReaction?: (
|
|
message: TimelineMessage,
|
|
emoji: string,
|
|
remove: boolean,
|
|
) => Promise<void>;
|
|
profiles?: UserProfileLookup;
|
|
recentMentionPubkeys?: readonly string[];
|
|
replyTargetMessage: TimelineMessage | null;
|
|
scrollTargetId: string | null;
|
|
threadHead: TimelineMessage | null;
|
|
threadReplies: MainTimelineEntry[];
|
|
threadRepliesPending?: boolean;
|
|
/** True when the thread-reply query terminally failed (all retries exhausted). */
|
|
threadRepliesError?: boolean;
|
|
/** Retries the failed thread-reply load; wired to the query's `refetch`. */
|
|
onRetryThreadReplies?: () => void;
|
|
threadUnreadCount?: number;
|
|
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
|
|
threadTypingPubkeys: string[];
|
|
videoReviewPresentation?: VideoReviewPresentation;
|
|
activityAccessoryContent?: React.ReactNode;
|
|
activityAccessoryVisible: boolean;
|
|
widthPx: number;
|
|
isFollowingThread?: boolean;
|
|
isMessageUnreadById?: (messageId: string) => boolean;
|
|
onFollowThread?: () => void;
|
|
onUnfollowThread?: () => void;
|
|
/**
|
|
* When set to `thread:<threadHead.id>`, 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;
|
|
|
|
export function MessageThreadPanel({
|
|
channel,
|
|
channelId,
|
|
channelName,
|
|
columnMaxWidthPx,
|
|
currentPubkey,
|
|
disabled = false,
|
|
firstUnreadReplyId,
|
|
huddleMemberPubkeys,
|
|
huddleMemberPubkeysPending = false,
|
|
isHuddleTranscript = false,
|
|
layout = "standalone",
|
|
editTarget,
|
|
enterMotion,
|
|
headerLeading,
|
|
headerTitle,
|
|
headerTitleAriaLabel,
|
|
isSending,
|
|
isFocusMode,
|
|
isSinglePanelView = false,
|
|
isFollowingThread,
|
|
isMessageUnreadById,
|
|
onCancelEdit,
|
|
onCancelReply,
|
|
onClose,
|
|
onHeaderTitleClick,
|
|
onResetWidth,
|
|
onResizeStart,
|
|
onDelete,
|
|
onEdit,
|
|
onEditLastOwnMessage,
|
|
onEditSave,
|
|
onFollowThread,
|
|
onMarkUnread,
|
|
onMarkRead,
|
|
onExpandReplies,
|
|
onScrollTargetResolved,
|
|
onScrollTargetSettled,
|
|
onSelectReplyTarget,
|
|
onSend,
|
|
onSendToChannel,
|
|
onToggleReaction,
|
|
onUnfollowThread,
|
|
profiles,
|
|
recentMentionPubkeys,
|
|
replyTargetMessage,
|
|
scrollTargetId,
|
|
scrollTargetHighlights = true,
|
|
searchMessageId,
|
|
searchQuery,
|
|
threadHead,
|
|
videoReviewPresentation,
|
|
threadReplies,
|
|
threadRepliesPending = false,
|
|
threadRepliesError = false,
|
|
onRetryThreadReplies,
|
|
threadUnreadCount,
|
|
threadReplyUnreadCounts,
|
|
threadTypingPubkeys,
|
|
activityAccessoryContent,
|
|
activityAccessoryVisible,
|
|
canResetWidth,
|
|
splitPaneClamp,
|
|
showBackButton,
|
|
testId = "message-thread-panel",
|
|
widthPx,
|
|
transparentChrome = false,
|
|
autoSendDraftKey = null,
|
|
onAutoSubmitComplete,
|
|
}: MessageThreadPanelProps) {
|
|
const threadBodyRef = React.useRef<HTMLDivElement>(null);
|
|
const threadContentRef = React.useRef<HTMLDivElement>(null);
|
|
const threadComposerWrapperRef = React.useRef<HTMLDivElement>(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,
|
|
!isHuddleTranscript && (isOverlay || isSinglePanelView || isFocusMode),
|
|
);
|
|
const hasConstrainedColumn = columnMaxWidthPx != null;
|
|
// Whether the composer dock trades its quiet-state spacer for the
|
|
// conditional activity accessory (agent working and/or someone typing).
|
|
const hasComposerBottomActivity =
|
|
activityAccessoryVisible || threadTypingPubkeys.length > 0;
|
|
|
|
// 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 =
|
|
!isHuddleTranscript &&
|
|
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 =
|
|
!isHuddleTranscript &&
|
|
!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,
|
|
isHuddleTranscript,
|
|
threadHead,
|
|
]);
|
|
|
|
const {
|
|
isAtBottom,
|
|
newMessageCount,
|
|
onScroll,
|
|
scrollToBottom,
|
|
settleAtBottomAfterLayout,
|
|
} = useAnchoredScroll({
|
|
channelId: threadHeadId,
|
|
contentRef: threadContentRef,
|
|
isLoading: threadRepliesPending || repliesRenderState === "pending",
|
|
messages: threadMessages,
|
|
highlightTargetMessage: scrollTargetHighlights,
|
|
onTargetReached: onScrollTargetResolved,
|
|
onTargetSettled: onScrollTargetSettled,
|
|
pinTargetCentered: !scrollTargetHighlights,
|
|
scrollContainerRef: threadBodyRef,
|
|
targetMessageId: scrollTargetId,
|
|
});
|
|
useComposerHeightPadding(
|
|
threadBodyRef,
|
|
threadComposerWrapperRef,
|
|
isSinglePanelView,
|
|
"padding",
|
|
settleAtBottomAfterLayout,
|
|
);
|
|
const stableSendToChannel = useStableSendToChannel(
|
|
channelId,
|
|
threadHead,
|
|
onSendToChannel,
|
|
);
|
|
if (!threadHead) {
|
|
return null;
|
|
}
|
|
const threadScrollRegion = (
|
|
<AuxiliaryPanelBody
|
|
className="overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
|
|
data-buzz-conversation-scroll
|
|
data-testid="message-thread-body"
|
|
mode={isHuddleTranscript ? "panel" : undefined}
|
|
onScroll={onScroll}
|
|
tabIndex={-1}
|
|
ref={threadBodyRef}
|
|
>
|
|
{/* The gallery is intentionally DOM-scoped: only media currently rendered
|
|
in this open thread participates. Collapsed or unloaded descendants
|
|
join only after the thread UI renders them. */}
|
|
<div
|
|
className={cn(hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS)}
|
|
data-image-gallery-scope="thread"
|
|
ref={threadContentRef}
|
|
style={
|
|
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
|
|
}
|
|
>
|
|
{isHuddleTranscript ? (
|
|
<div className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-2 pt-4")}>
|
|
<HuddleTranscriptIntro />
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
|
data-testid="message-thread-head"
|
|
>
|
|
<div className="rounded-2xl">
|
|
<MessageThreadRow
|
|
actionBarPlacement="inside"
|
|
channelId={channelId}
|
|
currentPubkey={currentPubkey}
|
|
huddleMemberPubkeys={huddleMemberPubkeys}
|
|
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
|
isFollowingThread={isFollowingThread}
|
|
isUnread={isMessageUnreadById?.(threadHead.id)}
|
|
message={threadHead}
|
|
onDelete={
|
|
onDelete &&
|
|
canManageMessageForCurrentUser(
|
|
threadHead,
|
|
currentPubkey,
|
|
profiles,
|
|
)
|
|
? onDelete
|
|
: undefined
|
|
}
|
|
onEdit={
|
|
onEdit &&
|
|
canManageMessageForCurrentUser(
|
|
threadHead,
|
|
currentPubkey,
|
|
profiles,
|
|
)
|
|
? onEdit
|
|
: undefined
|
|
}
|
|
onFollowThread={
|
|
onFollowThread ? (_msg) => onFollowThread() : undefined
|
|
}
|
|
onMarkUnread={onMarkUnread}
|
|
onMarkRead={onMarkRead}
|
|
onToggleReaction={onToggleReaction}
|
|
onUnfollowThread={
|
|
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
|
|
}
|
|
profiles={profiles}
|
|
searchQuery={
|
|
searchMessageId === threadHead.id ? searchQuery : undefined
|
|
}
|
|
showDepthGuides={shouldShowThreadBranchGuides}
|
|
videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
|
|
threadHead.id,
|
|
)}
|
|
videoReviewContext={videoReviewPresentation?.contextsByMessageId.get(
|
|
threadHead.id,
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showThreadHeadDivider ? (
|
|
<div
|
|
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-2")}
|
|
data-testid="message-thread-head-divider"
|
|
>
|
|
<Separator className="bg-border/60" />
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
|
|
data-testid="message-thread-replies"
|
|
>
|
|
<ThreadReplyRegion
|
|
isPending={threadRepliesPending}
|
|
isError={threadRepliesError}
|
|
deferredCount={deferredThreadReplies.length}
|
|
liveCount={threadReplies.length}
|
|
isHuddleTranscript={isHuddleTranscript}
|
|
onRetry={onRetryThreadReplies}
|
|
renderSkeleton={() => (
|
|
<div
|
|
className="space-y-2.5 pt-1"
|
|
data-testid="message-thread-replies-loading"
|
|
>
|
|
<ThreadMessageSkeleton />
|
|
<ThreadMessageSkeleton />
|
|
</div>
|
|
)}
|
|
renderList={() =>
|
|
visibleThreadHeadSummary ? (
|
|
<div
|
|
className="space-y-0"
|
|
data-render-pending={isRepliesPending ? "true" : undefined}
|
|
>
|
|
<MessageThreadSummaryRow
|
|
depth={threadHead.depth}
|
|
message={threadHead}
|
|
onOpenThread={expandThreadHeadReplies}
|
|
summary={visibleThreadHeadSummary}
|
|
summaryIndentOffsetRem={
|
|
THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM
|
|
}
|
|
unreadCount={threadUnreadCount}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="space-y-0"
|
|
data-render-pending={isRepliesPending ? "true" : undefined}
|
|
>
|
|
{threadReplyRenderItems.map((item) => {
|
|
const {
|
|
collapseDepthGuideActions,
|
|
connectsToVisibleChild,
|
|
continuationDepths,
|
|
entry,
|
|
index,
|
|
isContinuation,
|
|
} = item;
|
|
const showUnreadDivider =
|
|
index > 0 && entry.message.id === firstUnreadReplyId;
|
|
const highlight = selectThreadRowHighlight({
|
|
branch: highlightedBranch,
|
|
index,
|
|
messageId: entry.message.id,
|
|
messageDepth: entry.message.depth,
|
|
showGuides: shouldShowThreadBranchGuides,
|
|
});
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex flex-col gap-0",
|
|
entry.summary &&
|
|
"group/message rounded-2xl px-0 py-0.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
|
)}
|
|
key={entry.message.renderKey ?? entry.message.id}
|
|
>
|
|
{showUnreadDivider ? <UnreadDivider /> : null}
|
|
<MessageThreadRow
|
|
channelId={channelId}
|
|
currentPubkey={currentPubkey}
|
|
collapseDepthGuideActions={collapseDepthGuideActions}
|
|
collapseDescendantsLabel="Collapse replies"
|
|
connectDescendants={
|
|
shouldShowThreadBranchGuides &&
|
|
connectsToVisibleChild
|
|
}
|
|
depthGuideDepths={
|
|
shouldShowThreadBranchGuides
|
|
? continuationDepths
|
|
: undefined
|
|
}
|
|
highlightDescendantRail={
|
|
shouldShowThreadBranchGuides &&
|
|
highlight.isBranchOwner &&
|
|
connectsToVisibleChild
|
|
}
|
|
highlightReplyConnector={
|
|
shouldShowThreadBranchGuides &&
|
|
highlight.isDirectChild
|
|
}
|
|
highlightThreadLineDepths={highlight.lineDepths}
|
|
hoverBackground={!entry.summary}
|
|
huddleMemberPubkeys={huddleMemberPubkeys}
|
|
huddleMemberPubkeysPending={
|
|
huddleMemberPubkeysPending
|
|
}
|
|
isContinuation={isContinuation}
|
|
isUnread={isMessageUnreadById?.(entry.message.id)}
|
|
message={entry.message}
|
|
onCollapseDepthGuide={handleCollapseDepthGuide}
|
|
onCollapseDepthGuideHoverChange={
|
|
handleCollapseBranchHoverChange
|
|
}
|
|
onCollapseDescendants={
|
|
shouldShowThreadBranchGuides &&
|
|
connectsToVisibleChild &&
|
|
!entry.summary
|
|
? onExpandReplies
|
|
: undefined
|
|
}
|
|
onCollapseDescendantsHoverChange={
|
|
handleCollapseBranchHoverChange
|
|
}
|
|
onDelete={
|
|
onDelete &&
|
|
canManageMessageForCurrentUser(
|
|
entry.message,
|
|
currentPubkey,
|
|
profiles,
|
|
)
|
|
? onDelete
|
|
: undefined
|
|
}
|
|
onEdit={
|
|
onEdit &&
|
|
canManageMessageForCurrentUser(
|
|
entry.message,
|
|
currentPubkey,
|
|
profiles,
|
|
)
|
|
? onEdit
|
|
: undefined
|
|
}
|
|
onMarkUnread={onMarkUnread}
|
|
onMarkRead={onMarkRead}
|
|
onReply={onSelectReplyTarget}
|
|
onSendToChannel={stableSendToChannel}
|
|
onToggleReaction={onToggleReaction}
|
|
profiles={profiles}
|
|
searchQuery={
|
|
searchMessageId === entry.message.id
|
|
? searchQuery
|
|
: undefined
|
|
}
|
|
showDepthGuides={shouldShowThreadBranchGuides}
|
|
videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
|
|
entry.message.id,
|
|
)}
|
|
videoReviewContext={videoReviewPresentation?.contextsByMessageId.get(
|
|
entry.message.id,
|
|
)}
|
|
/>
|
|
{entry.summary ? (
|
|
<MessageThreadSummaryRow
|
|
collapseDepthGuideActions={
|
|
collapseDepthGuideActions
|
|
}
|
|
depth={entry.message.depth}
|
|
depthGuideDepths={
|
|
shouldShowThreadBranchGuides
|
|
? continuationDepths
|
|
: undefined
|
|
}
|
|
highlightThreadLineDepths={highlight.lineDepths}
|
|
message={entry.message}
|
|
onCollapseDepthGuide={handleCollapseDepthGuide}
|
|
onCollapseDepthGuideHoverChange={
|
|
handleCollapseBranchHoverChange
|
|
}
|
|
onOpenThread={onExpandReplies}
|
|
summary={entry.summary}
|
|
summaryIndentOffsetRem={
|
|
THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM
|
|
}
|
|
showDepthGuides={shouldShowThreadBranchGuides}
|
|
unreadCount={threadReplyUnreadCounts?.get(
|
|
entry.message.id,
|
|
)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</AuxiliaryPanelBody>
|
|
);
|
|
|
|
const threadFooter = (
|
|
<>
|
|
{!isAtBottom ? (
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-36 z-50 flex justify-center px-4">
|
|
<Button
|
|
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-2xs font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
|
|
data-testid="thread-scroll-to-latest"
|
|
onClick={() => scrollToBottom("smooth")}
|
|
size="sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<ArrowDown aria-hidden />
|
|
{newMessageCount > 0
|
|
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
|
|
: "Jump to latest"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className="pointer-events-none absolute inset-x-0 bottom-0 z-40 isolate before:absolute before:inset-x-0 before:bottom-0 before:-z-10 before:h-24 before:bg-gradient-to-b before:from-transparent before:to-background before:content-[''] after:absolute after:inset-x-0 after:bottom-0 after:-z-10 after:h-12 after:bg-background after:content-['']"
|
|
data-testid="thread-composer-overlay"
|
|
ref={threadComposerWrapperRef}
|
|
>
|
|
<div
|
|
className={cn(hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS)}
|
|
style={
|
|
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
|
|
}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"composer-dock composer-overlay-corner-masks relative pointer-events-auto",
|
|
hasComposerBottomActivity && "composer-dock--with-activity",
|
|
)}
|
|
>
|
|
<ComposerDockBackdrop gutterClassName="inset-x-5" />
|
|
<MessageComposer
|
|
audienceContext={{ type: "thread" }}
|
|
channelId={channelId}
|
|
channelName={channelName}
|
|
channelType={channel?.channelType ?? null}
|
|
containerClassName={cn(
|
|
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
|
|
"pb-0",
|
|
)}
|
|
layoutMode="dock"
|
|
disabled={disabled || isSending || !channelId}
|
|
draftKey={`thread:${threadHead.id}`}
|
|
autoSubmitDraftKey={autoSendDraftKey}
|
|
onAutoSubmitComplete={onAutoSubmitComplete}
|
|
editTarget={editTarget}
|
|
isSending={isSending}
|
|
onCancelEdit={onCancelEdit}
|
|
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
|
|
onCaptureSendContext={onCaptureSendContext}
|
|
onEditLastOwnMessage={onEditLastOwnMessage}
|
|
onEditSave={onEditSave}
|
|
onSend={onSend}
|
|
placeholder={
|
|
isHuddleTranscript
|
|
? "Message the huddle"
|
|
: `Reply in thread to ${threadHead.author}`
|
|
}
|
|
profiles={profiles}
|
|
recentMentionPubkeys={recentMentionPubkeys}
|
|
replyTarget={composerReplyTarget}
|
|
typingParentEventId={threadHead.id}
|
|
typingRootEventId={threadHead.rootId}
|
|
/>
|
|
{/* The activity accessory is anchored in the dock's reserved bottom
|
|
rail, so fading it cannot change the observed overlay height or
|
|
move the conversation. Its natural content height remains responsive. */}
|
|
<ComposerActivityAccessory
|
|
className={THREAD_PANEL_COMPOSER_GUTTER_CLASS}
|
|
visible={hasComposerBottomActivity}
|
|
>
|
|
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-visible pl-2">
|
|
{activityAccessoryVisible && activityAccessoryContent ? (
|
|
<div className="flex min-w-0 flex-1 overflow-visible">
|
|
{activityAccessoryContent}
|
|
</div>
|
|
) : null}
|
|
{threadTypingPubkeys.length > 0 ? (
|
|
<TypingIndicatorRow
|
|
channel={channel}
|
|
className="min-w-0 flex-1 py-0 pl-[calc(0.75rem+1px)] pr-0 sm:pl-[calc(1rem+1px)]"
|
|
currentPubkey={currentPubkey}
|
|
profiles={profiles}
|
|
typingPubkeys={threadTypingPubkeys}
|
|
variant="activity"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</ComposerActivityAccessory>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<VideoReviewNavigationProvider>
|
|
<AuxiliaryPanel
|
|
canResetWidth={canResetWidth}
|
|
className="relative"
|
|
enterMotion={enterMotion ?? !isFocusMode}
|
|
footer={threadFooter}
|
|
header={
|
|
isHuddleTranscript ? undefined : (
|
|
<MessageThreadPanelHeader
|
|
headerLeading={headerLeading}
|
|
headerTitle={headerTitle}
|
|
headerTitleAriaLabel={headerTitleAriaLabel}
|
|
isFocusMode={isFocusMode}
|
|
isSinglePanelView={isSinglePanelView}
|
|
onClose={onClose}
|
|
onHeaderTitleClick={onHeaderTitleClick}
|
|
showBackButton={showBackButton}
|
|
/>
|
|
)
|
|
}
|
|
isSinglePanelView={isSinglePanelView}
|
|
layout={layout}
|
|
onClose={onClose}
|
|
onResetWidth={onResetWidth}
|
|
onResizeStart={onResizeStart}
|
|
splitPaneClamp={splitPaneClamp}
|
|
testId={testId}
|
|
transparentChrome={transparentChrome}
|
|
widthPx={widthPx}
|
|
>
|
|
{threadScrollRegion}
|
|
</AuxiliaryPanel>
|
|
</VideoReviewNavigationProvider>
|
|
);
|
|
}
|