mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
988 lines
36 KiB
TypeScript
988 lines
36 KiB
TypeScript
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";
|