Files
buzz/desktop/src/features/messages/ui/MessageRow.tsx
kenny lopez 0b5a0fc6ac Refine message actions across conversations
Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-13 15:42:17 +01:00

988 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as React from "react";
import { AlertTriangle } from "lucide-react";
import {
depthGuideActionsEqual,
numberArrayEqual,
reactionsEqual,
tagsEqual,
} from "@/features/messages/lib/messageRowEquality";
import {
assertCanSendMessageToChannel,
canSendMessageToChannel,
} from "@/features/messages/lib/canSendToChannel";
import type { TimelineMessage } from "@/features/messages/types";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider";
import {
getThreadReplyAvatarCenterRem,
getThreadReplyAvatarCenterYRem,
getThreadReplyDescendantRailStartYRem,
getThreadReplyConnectorLayout,
getThreadReplyIndentRem,
threadReplyLength,
THREAD_REPLY_LINE_WIDTH_REM,
} from "@/features/messages/lib/threadTreeLayout";
import {
KIND_HUDDLE_STARTED,
KIND_STREAM_MESSAGE_DIFF,
} from "@/shared/constants/kinds";
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
import { cn } from "@/shared/lib/cn";
import { useMessageStyle } from "@/shared/lib/messageStylePreference";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage";
import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { useOpenVideoReviewAt } from "@/shared/ui/VideoReviewNavigation";
import { parseVideoReviewTimecode } from "@/shared/ui/videoReviewTimecode";
import { VideoReviewTimecodeButton } from "@/shared/ui/VideoReviewTimecodeButton";
import { editMessage } from "@/shared/api/tauri";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { toast } from "sonner";
import { MessageAgentOwner } from "./MessageAgentOwner";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
import { MessageTimestamp } from "./MessageTimestamp";
import { MessageRowActions } from "./MessageRowActions";
import type {
MessageRowProps,
ThreadDepthGuideAction,
} from "./MessageRow.types";
import {
getMessageBubbleBottomPaddingClass,
getMessageBubbleLayout,
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
} from "./messageBubbleLayout";
import { SentFromThreadLine } from "./SentFromThreadLine";
import { WaveMessageAttachment } from "./WaveMessageAttachment";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const DiffMessage = React.lazy(() => import("./DiffMessage"));
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
const MESSAGE_BODY_SURFACE_CLASSES = cn(
"w-fit max-w-full bg-muted/50",
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
);
export const MessageRow = React.memo(
function MessageRow({
bodyFooter,
channelId = null,
currentPubkey,
collapseDepthGuideActions,
connectDescendants = false,
depthGuideDepths,
highlighted = false,
highlightDescendantRail = false,
highlightReplyConnector = false,
highlightThreadLineDepths,
hoverBackground = true,
huddleMemberPubkeys,
huddleMemberPubkeysPending = false,
hideAgentAccessBadge = false,
collapseDescendantsLabel,
isFollowingThread,
isContinuation = false,
isFollowedByContinuation = false,
isUnread,
layoutVariant = "default",
message,
onCollapseDepthGuide,
onCollapseDepthGuideHoverChange,
onCollapseDescendants,
onCollapseDescendantsHoverChange,
onDelete,
onEdit,
onFollowThread,
onMarkUnread,
onMarkRead,
onToggleReaction,
onReply,
onSendToChannel,
onEntranceComplete,
playEntrance = false,
onUnfollowThread,
profiles,
searchQuery,
showDepthGuides = true,
videoReviewCommentRootId,
videoReviewContext,
}: MessageRowProps) {
// Keep the transient send state with its timestamp rather than collapsing
// it into a grouped message row with no header.
const isDisplayedAsContinuation = isContinuation && !message.pending;
const showMessageBubbles = useMessageStyle() === "bubbles";
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
const linkPreviewsSuppressed = hasLinkPreviewSuppression(message.tags);
const removeLinkPreviewsForEveryone =
channelId && onEdit && !message.pending && !linkPreviewsSuppressed
? async () => {
const tags = message.tags ?? [];
try {
await editMessage(
channelId,
message.id,
message.body,
tags.filter((tag) => tag[0] === "imeta"),
tags.filter((tag) => tag[0] === "emoji"),
undefined,
true,
tags.filter((tag) => tag[0] === "mention"),
);
} catch (error) {
toast.error(
`Failed to remove previews: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
: undefined;
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
null,
);
const handleEntranceAnimationEnd = React.useCallback(
(event: React.AnimationEvent<HTMLElement>) => {
if (
playEntrance &&
event.animationName === "motion-enter-conversation"
) {
onEntranceComplete?.(message.id);
}
},
[message.id, onEntranceComplete, playEntrance],
);
const {
reactions,
canToggle: canToggleReactions,
pending: reactionPending,
errorMessage: reactionErrorMessage,
select: handleReactionSelect,
} = useReactionHandler(message, onToggleReaction);
const { openReminder, activeReminderEventIds } = useRemindLater();
const hasActiveReminder = activeReminderEventIds.has(message.id);
const handleRemindLater = React.useCallback(
(msg: TimelineMessage) => {
openReminder({
eventId: msg.id,
channelId: channelId ?? "",
preview: msg.body.slice(0, 100),
authorPubkey: msg.pubkey ?? "",
});
},
[channelId, openReminder],
);
const sendToChannelAllowed = canSendMessageToChannel(
message,
currentPubkey,
profiles,
);
const handleSendToChannel = React.useCallback(
async (target: TimelineMessage) => {
assertCanSendMessageToChannel(target, currentPubkey, profiles);
await onSendToChannel?.(target);
},
[currentPubkey, onSendToChannel, profiles],
);
const { mentionNames, mentionPubkeysByName } = React.useMemo(
() => resolveMentionProps(message.tags, profiles),
[profiles, message.tags],
);
// "Is this pubkey an agent" = the community-scoped baseline every surface
// shares (managed ∪ relay) plus the pubkey's own profile `isAgent` flag from this surface's lookup. Both are per-pubkey
// O(1) checks — no per-row rescan of `profiles` (that duplicated parent
// work in every mounted row and re-ran on each profile-lookup change).
const knownAgentPubkeys = useKnownAgentPubkeys();
const isKnownAgentPubkey = React.useCallback(
(pubkey: string) => {
const normalized = normalizePubkey(pubkey);
return (
knownAgentPubkeys.has(normalized) ||
profiles?.[normalized]?.isAgent === true
);
},
[knownAgentPubkeys, profiles],
);
const profilePopoverRole =
message.role === "bot" ||
(message.pubkey && isKnownAgentPubkey(message.pubkey))
? "bot"
: message.role;
const agentMentionPubkeysByName = React.useMemo(() => {
if (!mentionPubkeysByName) {
return undefined;
}
const values: Record<string, string> = {};
for (const [name, pubkey] of Object.entries(mentionPubkeysByName)) {
if (isKnownAgentPubkey(pubkey)) {
values[name] = pubkey;
}
}
return Object.keys(values).length > 0 ? values : undefined;
}, [isKnownAgentPubkey, mentionPubkeysByName]);
const imetaByUrl = React.useMemo(
() => (message.tags ? parseImetaTags(message.tags) : undefined),
[message.tags],
);
const snapshotSharedBy = React.useMemo(
() =>
resolveSnapshotSharedBy(
{ signerPubkey: message.signerPubkey },
profiles,
),
[message.signerPubkey, profiles],
);
const { customEmoji, emojiOnly } = useMessageEmoji(
message.body,
message.tags,
);
const waveMessage = React.useMemo(
() => parseWaveMessageContent(message.body),
[message.body],
);
const hasStandaloneCardSurface =
message.kind === KIND_HUDDLE_STARTED ||
message.kind === KIND_STREAM_MESSAGE_DIFF ||
waveMessage !== null;
const bodyOffsetClass = emojiOnly ? "mt-1" : "mt-conversation-body";
const { nonDmChannelNames: channelNames } = useChannelNavigation();
const openVideoReviewAt = useOpenVideoReviewAt();
const indentRem = getThreadReplyIndentRem(message.depth);
const descendantGuideOffsetRem = connectDescendants
? getThreadReplyAvatarCenterRem(message.depth)
: null;
const replyConnector = React.useMemo(() => {
return getThreadReplyConnectorLayout(message.depth);
}, [message.depth]);
const depthGuideItems = React.useMemo(() => {
const depths =
depthGuideDepths ??
Array.from(
{ length: Math.max(0, message.depth - 1) },
(_, index) => index + 1,
);
return depths.map((depth) => ({
depth,
offset: getThreadReplyAvatarCenterRem(depth),
}));
}, [depthGuideDepths, message.depth]);
const handleCollapseDescendants = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onCollapseDescendants?.(message);
},
[message, onCollapseDescendants],
);
const handleCollapseDescendantsHoverChange = React.useCallback(
(hovered: boolean) => {
onCollapseDescendantsHoverChange?.(message, hovered);
},
[message, onCollapseDescendantsHoverChange],
);
const handleCollapseDepthGuide = React.useCallback(
(
event: React.MouseEvent<HTMLButtonElement>,
targetMessage: TimelineMessage,
) => {
event.preventDefault();
event.stopPropagation();
onCollapseDepthGuide?.(targetMessage);
},
[onCollapseDepthGuide],
);
const handleCollapseDepthGuideHoverChange = React.useCallback(
(targetMessage: TimelineMessage, hovered: boolean) => {
onCollapseDepthGuideHoverChange?.(targetMessage, hovered);
},
[onCollapseDepthGuideHoverChange],
);
const collapseDepthGuideActionsByDepth = React.useMemo(() => {
if (!collapseDepthGuideActions?.length) {
return new Map<number, ThreadDepthGuideAction>();
}
return new Map(
collapseDepthGuideActions.map((action) => [action.depth, action]),
);
}, [collapseDepthGuideActions]);
const getTag = (name: string) =>
message.tags?.find((tag) => tag[0] === name)?.[1];
const renderBody = () => {
switch (message.kind) {
case KIND_STREAM_MESSAGE_DIFF:
return (
<React.Suspense
fallback={
<div className="p-3 text-sm text-muted-foreground">
Loading diff…
</div>
}
>
<DiffMessage
commitSha={getTag("commit")}
content={message.body}
description={getTag("description")}
filePath={getTag("file")}
onExpand={() => {
setExpandedDiffId(message.id);
}}
repoUrl={getTag("repo")}
truncated={getTag("truncated") === "true"}
/>
</React.Suspense>
);
case KIND_HUDDLE_STARTED:
return (
<HuddleAttachment
channelId={channelId}
className="mt-2"
message={message}
/>
);
default: {
if (waveMessage) {
return (
<WaveMessageAttachment
channelId={channelId}
fallbackText={waveMessage.fallbackText}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
/>
);
}
const reviewRootEventId = videoReviewCommentRootId;
const reviewTimecode = reviewRootEventId
? parseVideoReviewTimecode(message.body)
: null;
const markdown = (
<Markdown
channelNames={channelNames}
className={cn(
"max-w-full text-message",
showMessageBubbles && "message-bubble-markdown",
emojiOnly &&
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
)}
// Only pass the author pubkey for agent-authored messages so
// config-nudge cards can authenticate the sender. Uses the
// raw event signer (signerPubkey), not a relay-delegated display
// author, because the agent itself must have signed the card.
configNudgeAuthorPubkey={getConfigNudgeAuthorPubkey(
message,
isKnownAgentPubkey,
)}
content={reviewTimecode?.text ?? message.body}
messageId={message.id}
linkPreviewsSuppressed={linkPreviewsSuppressed}
linkPreviewTags={message.tags}
onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
agentMentionPubkeysByName={agentMentionPubkeysByName}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
searchQuery={searchQuery}
snapshotSharedBy={snapshotSharedBy}
videoReviewContext={videoReviewContext}
/>
);
if (!reviewRootEventId || !reviewTimecode || !openVideoReviewAt) {
return markdown;
}
return (
<div className="flex min-w-0 items-start gap-1.5">
<VideoReviewTimecodeButton
surface="message"
timecode={reviewTimecode.timecode}
onClick={(event) => {
event.stopPropagation();
openVideoReviewAt(reviewRootEventId, reviewTimecode.seconds);
}}
/>
<div className="min-w-0 flex-1">{markdown}</div>
</div>
);
}
}
};
const isThreadReplyLayout = layoutVariant === "thread-reply";
const guideBleedRem = isThreadReplyLayout ? 0.25 : 0;
const avatarButtonRadiusClass = "rounded-full";
const showRespondToIndicator =
message.respondTo === "anyone" || message.respondTo === "allowlist";
const avatarNode = (
<div className="relative shrink-0">
<UserAvatar
accent={message.accent}
avatarUrl={message.avatarUrl ?? null}
className="shrink-0"
displayName={message.author}
testId="message-avatar"
/>
{showRespondToIndicator &&
!hideAgentAccessBadge &&
!isThreadReplyLayout ? (
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-background",
)}
role="img"
aria-label={
message.respondTo === "anyone"
? "Anyone can send instructions to this agent"
: "Selected people can send instructions to this agent"
}
title={
message.respondTo === "anyone"
? "Anyone can send instructions to this agent"
: "Selected people can send instructions to this agent"
}
>
{message.respondTo === "anyone" ? (
<AlertTriangle
aria-hidden="true"
className="h-2.5 w-2.5 fill-background text-amber-500"
/>
) : (
<span className="h-2 w-2 rounded-full bg-blue-500" />
)}
</span>
) : null}
</div>
);
const continuationTimestampGutter = (
<div
aria-hidden
className={cn(
"relative flex w-9 shrink-0 justify-end items-start pt-0.5",
isThreadReplyLayout ? "self-start" : "self-stretch",
)}
>
<MessageTimestamp
className="opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100"
createdAt={message.createdAt}
hideDayPeriod
time={message.time}
/>
</div>
);
const avatarGutterNode = isDisplayedAsContinuation ? (
continuationTimestampGutter
) : message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={profilePopoverRole}
botIdenticonValue={message.author}
>
<button
className={cn(
"flex shrink-0 items-start focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
avatarButtonRadiusClass,
)}
type="button"
>
{avatarNode}
</button>
</UserProfilePopover>
) : (
<div className="flex shrink-0 items-start">{avatarNode}</div>
);
const authorNode = message.pubkey ? (
<MessageAuthorText hoverUnderline>{message.author}</MessageAuthorText>
) : (
<MessageAuthorText as="h3">{message.author}</MessageAuthorText>
);
const agentOwnerNode = message.isAgent ? (
<MessageAgentOwner
ownerLabel={message.ownerLabel}
ownerPubkey={message.ownerPubkey}
/>
) : null;
const anchorActionBarToBubble =
showMessageBubbles && !hasStandaloneCardSurface;
const actionBarNode = (
<MessageRowActions
anchorToBubble={anchorActionBarToBubble}
channelId={channelId}
isFollowingThread={isFollowingThread}
isUnread={isUnread}
message={message}
onDelete={onDelete}
onEdit={onEdit}
onFollowThread={onFollowThread}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
onReactionBadgeBurstRequest={
reactionPending ? undefined : setBadgeBurstEmoji
}
onReactionSelect={canToggleReactions ? handleReactionSelect : undefined}
onRemindLater={handleRemindLater}
onReply={onReply}
onSendToChannel={
onSendToChannel && sendToChannelAllowed
? handleSendToChannel
: undefined
}
onUnfollowThread={onUnfollowThread}
reactionErrorMessage={reactionErrorMessage}
reactions={reactions}
showMessageBubbles={showMessageBubbles}
/>
);
const statusMetadataNode =
message.pending || message.edited ? (
<>
{message.pending ? (
<p
className="font-normal text-muted-foreground/70"
data-testid="message-send-status"
>
Sending…
</p>
) : null}
{message.edited ? (
<Tooltip>
<TooltipTrigger asChild>
<p className="text-muted-foreground/70">(edited)</p>
</TooltipTrigger>
<TooltipContent>This message has been edited</TooltipContent>
</Tooltip>
) : null}
</>
) : null;
const inlineMetadataNode = (
<div className="flex shrink-0 items-baseline gap-2 text-xs">
<MessageTimestamp createdAt={message.createdAt} time={message.time} />
{statusMetadataNode}
</div>
);
const continuationMetadataNode =
isDisplayedAsContinuation && statusMetadataNode ? (
<div className="mt-0.5 flex items-baseline gap-2 text-xs">
{statusMetadataNode}
</div>
) : null;
const headerNode = isDisplayedAsContinuation ? null : (
<MessageHeaderRow>
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={profilePopoverRole}
botIdenticonValue={message.author}
>
<button
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{authorNode}
</button>
</UserProfilePopover>
) : (
authorNode
)}
{agentOwnerNode}
{inlineMetadataNode}
{message.personaDisplayName &&
message.personaDisplayName !== message.author ? (
<span className="text-xs text-muted-foreground">
{message.personaDisplayName}
</span>
) : null}
</MessageHeaderRow>
);
const bodyContainerClass = isDisplayedAsContinuation
? "mt-0"
: bodyOffsetClass;
const { radiusClass, rowSpacingClass: bubbleRowSpacingClass } =
getMessageBubbleLayout(
isDisplayedAsContinuation,
isFollowedByContinuation,
);
const coreMessageBodyNode = (
<>
<SentFromThreadLine channelId={channelId} tags={message.tags} />
{renderBody()}
{continuationMetadataNode}
</>
);
const expandedDiffNode =
expandedDiffId === message.id ? (
<React.Suspense
fallback={
<div className="p-3 text-sm text-muted-foreground">
Loading diff viewer…
</div>
}
>
<DiffMessageExpanded
content={message.body}
filePath={getTag("file")}
onClose={() => {
setExpandedDiffId(null);
}}
/>
</React.Suspense>
) : null;
const reactionsNode = (
<MessageReactions
messageId={message.id}
reactions={reactions}
canToggle={canToggleReactions}
pending={reactionPending}
placement={
showMessageBubbles && !hasStandaloneCardSurface
? "bubble-overlap"
: "inline"
}
burstEmojiOnRender={badgeBurstEmoji}
onBurstEmojiRendered={(emoji) => {
setBadgeBurstEmoji((current) => (current === emoji ? null : current));
}}
onSelect={(emoji) => {
void handleReactionSelect(emoji);
}}
/>
);
const reactionErrorNode = reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">{reactionErrorMessage}</p>
) : null;
const classicMessageBodyNode = (
<div className={bodyContainerClass}>
{coreMessageBodyNode}
{reactionsNode}
{reactionErrorNode}
{expandedDiffNode}
</div>
);
const bubbleMessageBodyNode = (
<div className={cn(bodyContainerClass, "w-fit max-w-full")}>
{hasStandaloneCardSurface ? (
<div data-testid="message-standalone-card-content">
{coreMessageBodyNode}
{expandedDiffNode}
{bodyFooter}
</div>
) : (
<div
className={cn(
MESSAGE_BODY_SURFACE_CLASSES,
"relative",
radiusClass,
getMessageBubbleBottomPaddingClass(reactions.length > 0),
)}
data-testid="message-body-surface"
>
{anchorActionBarToBubble ? actionBarNode : null}
{coreMessageBodyNode}
{expandedDiffNode}
{bodyFooter}
</div>
)}
{reactionsNode}
{reactionErrorNode}
</div>
);
return (
<div
className="relative"
style={
indentRem > 0
? { paddingLeft: threadReplyLength(indentRem) }
: undefined
}
>
{showDepthGuides && depthGuideItems.length > 0 ? (
<div
aria-hidden={
collapseDepthGuideActionsByDepth.size > 0 ? undefined : true
}
className={cn(
"absolute left-0",
collapseDepthGuideActionsByDepth.size === 0 &&
"pointer-events-none",
)}
style={{
bottom: threadReplyLength(-guideBleedRem),
top: threadReplyLength(-guideBleedRem),
}}
>
{depthGuideItems.map(({ depth, offset }) => {
const collapseAction =
collapseDepthGuideActionsByDepth.get(depth);
const isHighlighted =
Boolean(collapseAction?.active) ||
Boolean(highlightThreadLineDepths?.includes(depth));
if (collapseAction) {
return (
<React.Fragment key={`${message.id}-depth-guide-${offset}`}>
<div
aria-hidden
className={cn(
"pointer-events-none absolute bottom-0 top-0 border-l transition-[border-color]",
isHighlighted ? "border-primary" : "border-border/45",
)}
style={{
borderLeftWidth: threadReplyLength(
THREAD_REPLY_LINE_WIDTH_REM,
),
left: threadReplyLength(offset),
}}
/>
<button
aria-label={collapseAction.label}
className="absolute bottom-0 top-0 z-20 w-5 -translate-x-1/2 cursor-pointer rounded-full focus-visible:outline-hidden"
data-thread-head-id={collapseAction.message.id}
data-testid="thread-collapse-guide"
onBlur={() =>
handleCollapseDepthGuideHoverChange(
collapseAction.message,
false,
)
}
onClick={(event) =>
handleCollapseDepthGuide(event, collapseAction.message)
}
onFocus={() =>
handleCollapseDepthGuideHoverChange(
collapseAction.message,
true,
)
}
onMouseEnter={() =>
handleCollapseDepthGuideHoverChange(
collapseAction.message,
true,
)
}
onMouseLeave={() =>
handleCollapseDepthGuideHoverChange(
collapseAction.message,
false,
)
}
style={{ left: threadReplyLength(offset) }}
type="button"
/>
</React.Fragment>
);
}
return (
<div
aria-hidden
className={cn(
"pointer-events-none absolute bottom-0 top-0 border-l transition-[border-color]",
isHighlighted ? "border-primary" : "border-border/45",
)}
key={`${message.id}-depth-guide-${offset}`}
style={{
borderLeftWidth: threadReplyLength(
THREAD_REPLY_LINE_WIDTH_REM,
),
left: threadReplyLength(offset),
}}
/>
);
})}
</div>
) : null}
{showDepthGuides && descendantGuideOffsetRem !== null ? (
<>
<div
aria-hidden
className={cn(
"pointer-events-none absolute bottom-0 z-0 border-l transition-[border-color]",
highlightDescendantRail ? "border-primary" : "border-border/45",
)}
style={{
bottom: threadReplyLength(-guideBleedRem),
borderLeftWidth: threadReplyLength(THREAD_REPLY_LINE_WIDTH_REM),
left: threadReplyLength(descendantGuideOffsetRem),
top: threadReplyLength(getThreadReplyDescendantRailStartYRem()),
}}
/>
{onCollapseDescendants ? (
<button
aria-label={
collapseDescendantsLabel ?? "Collapse replies to this message"
}
className="absolute bottom-0 z-20 w-5 -translate-x-1/2 cursor-pointer rounded-full p-0 focus-visible:outline-hidden"
data-thread-head-id={message.id}
data-testid="thread-collapse-rail"
onBlur={() => handleCollapseDescendantsHoverChange(false)}
onClick={handleCollapseDescendants}
onFocus={() => handleCollapseDescendantsHoverChange(true)}
onMouseEnter={() => handleCollapseDescendantsHoverChange(true)}
onMouseLeave={() => handleCollapseDescendantsHoverChange(false)}
style={{
left: threadReplyLength(descendantGuideOffsetRem),
top: threadReplyLength(getThreadReplyAvatarCenterYRem()),
}}
type="button"
/>
) : null}
</>
) : null}
{showDepthGuides && replyConnector ? (
<div
aria-hidden
className={cn(
"pointer-events-none absolute left-0 top-0 rounded-bl-2xl border-b border-l transition-[border-color]",
highlightReplyConnector ? "border-primary" : "border-border/45",
)}
style={{
borderBottomWidth: threadReplyLength(THREAD_REPLY_LINE_WIDTH_REM),
borderLeftWidth: threadReplyLength(THREAD_REPLY_LINE_WIDTH_REM),
height: threadReplyLength(
replyConnector.heightRem + guideBleedRem,
),
left: threadReplyLength(replyConnector.parentOffsetRem),
top: threadReplyLength(-guideBleedRem),
width: threadReplyLength(replyConnector.widthRem),
}}
/>
) : null}
<article
className={cn(
"group/message relative z-10 rounded-2xl transition-colors",
playEntrance && "motion-enter-conversation",
showMessageBubbles ? bubbleRowSpacingClass : "py-conversation-row",
showMessageBubbles
? hoverBackground || isThreadReplyLayout
? "mx-1 px-2"
: "px-2"
: hoverBackground
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
: isThreadReplyLayout
? "mx-1 px-2"
: "px-2",
"flex gap-2.5",
isDisplayedAsContinuation ? "items-center" : "items-start",
hasActiveReminder ? "bg-blue-500/10" : "",
highlighted
? "-mx-4 rounded-none px-6 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-8"
: "",
)}
data-message-id={message.id}
data-testid="message-row"
onAnimationEnd={handleEntranceAnimationEnd}
>
{avatarGutterNode}
<div
className="flex min-w-0 flex-1 flex-col gap-0.5"
data-testid="message-content-column"
>
{headerNode}
{showMessageBubbles
? bubbleMessageBodyNode
: classicMessageBodyNode}
</div>
{anchorActionBarToBubble ? null : actionBarNode}
</article>
</div>
);
// Callbacks (onReply, onToggleReaction) intentionally excluded: inline arrows
// from parent create new refs every render — including them defeats memo.
},
(prev, next) =>
prev.message.id === next.message.id &&
prev.message.pubkey === next.message.pubkey &&
prev.message.body === next.message.body &&
prev.message.author === next.message.author &&
prev.message.isAgent === next.message.isAgent &&
prev.message.ownerPubkey === next.message.ownerPubkey &&
prev.message.ownerLabel === next.message.ownerLabel &&
prev.message.avatarUrl === next.message.avatarUrl &&
prev.message.accent === next.message.accent &&
prev.message.time === next.message.time &&
prev.message.depth === next.message.depth &&
prev.message.kind === next.message.kind &&
prev.message.pending === next.message.pending &&
prev.message.edited === next.message.edited &&
// Value comparisons, not identity: these arrays are rebuilt with fresh
// identities on every ingest/refetch even when unchanged — identity
// checks made every row re-render on every streamed event in an open
// thread (see messageRowEquality.ts).
reactionsEqual(prev.message.reactions, next.message.reactions) &&
tagsEqual(prev.message.tags, next.message.tags) &&
prev.message.role === next.message.role &&
prev.message.personaDisplayName === next.message.personaDisplayName &&
prev.bodyFooter === next.bodyFooter &&
prev.currentPubkey === next.currentPubkey &&
depthGuideActionsEqual(
prev.collapseDepthGuideActions,
next.collapseDepthGuideActions,
) &&
prev.collapseDescendantsLabel === next.collapseDescendantsLabel &&
prev.connectDescendants === next.connectDescendants &&
numberArrayEqual(prev.depthGuideDepths, next.depthGuideDepths) &&
prev.highlightDescendantRail === next.highlightDescendantRail &&
prev.highlighted === next.highlighted &&
prev.highlightReplyConnector === next.highlightReplyConnector &&
numberArrayEqual(
prev.highlightThreadLineDepths,
next.highlightThreadLineDepths,
) &&
prev.hoverBackground === next.hoverBackground &&
prev.huddleMemberPubkeys === next.huddleMemberPubkeys &&
prev.huddleMemberPubkeysPending === next.huddleMemberPubkeysPending &&
prev.hideAgentAccessBadge === next.hideAgentAccessBadge &&
prev.isContinuation === next.isContinuation &&
prev.isFollowedByContinuation === next.isFollowedByContinuation &&
prev.isFollowingThread === next.isFollowingThread &&
prev.isUnread === next.isUnread &&
prev.layoutVariant === next.layoutVariant &&
prev.onCollapseDepthGuide === next.onCollapseDepthGuide &&
prev.onCollapseDepthGuideHoverChange ===
next.onCollapseDepthGuideHoverChange &&
prev.onCollapseDescendants === next.onCollapseDescendants &&
prev.onCollapseDescendantsHoverChange ===
next.onCollapseDescendantsHoverChange &&
prev.onEntranceComplete === next.onEntranceComplete &&
prev.playEntrance === next.playEntrance &&
prev.onSendToChannel === next.onSendToChannel &&
prev.profiles === next.profiles &&
prev.searchQuery === next.searchQuery &&
prev.videoReviewCommentRootId === next.videoReviewCommentRootId &&
prev.videoReviewContext === next.videoReviewContext,
);
MessageRow.displayName = "MessageRow";