Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions desktop/src/features/messages/lib/rowHeightEstimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const PREVIEW_CARD = 70;
const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5
const MIN_ESTIMATE = 60; // never reserve less than the old flat floor
const CONTINUATION_MIN_ESTIMATE = 28;
// CollapsibleMessageBody clamps long bodies; over-reserving the full dump
// makes the virtualizer leave huge empty gaps until paint.
const COLLAPSED_BODY_MAX_PX = 240;
const EXPAND_TOGGLE_ROW = 22;

function mediaHeightFromDim(dim: string | undefined): number {
const dimensions = dimensionsFromDim(dim);
Expand Down Expand Up @@ -117,24 +121,24 @@ export function estimateRowHeight(
const proseForLineCount = stripMediaOnlyLines(prose);

let height = isContinuation ? CONTINUATION_ROW_CHROME : ROW_CHROME;
height +=
let bodyHeight =
wrappedLineCount(proseForLineCount.trim() === "" ? "" : proseForLineCount) *
TEXT_LINE_HEIGHT;
height += codeLines * CODE_LINE_HEIGHT;
bodyHeight += codeLines * CODE_LINE_HEIGHT;

const imetaUrls = new Set<string>();
if (message.tags && message.tags.length > 0) {
const imeta = parseImetaTags(message.tags);
for (const entry of imeta.values()) {
if (!entry.url) continue;
imetaUrls.add(entry.url);
height += mediaReserveHeight(entry.dim);
bodyHeight += mediaReserveHeight(entry.dim);
}
}
for (const url of mediaUrlsInBody(body)) {
if (imetaUrls.has(url)) continue; // already counted via its imeta dim
// dim-less inline media reserves the fixed markdown image box plus its mt-1.
height += mediaReserveHeight(undefined);
bodyHeight += mediaReserveHeight(undefined);
}

// A bare non-media URL on its own line usually renders a link-preview card.
Expand All @@ -144,7 +148,13 @@ export function estimateRowHeight(
(line) =>
/^\s*https?:\/\/\S+\s*$/.test(line) && !MEDIA_URL_RE.test(line.trim()),
);
if (hasPreviewUrlLine) height += PREVIEW_CARD;
if (hasPreviewUrlLine) bodyHeight += PREVIEW_CARD;

if (bodyHeight > COLLAPSED_BODY_MAX_PX) {
height += COLLAPSED_BODY_MAX_PX + EXPAND_TOGGLE_ROW;
} else {
height += bodyHeight;
}

if (message.reactions && message.reactions.length > 0) height += REACTION_ROW;

Expand Down
91 changes: 91 additions & 0 deletions desktop/src/features/messages/ui/CollapsibleMessageBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import * as React from "react";

import { cn } from "@/shared/lib/cn";
import {
COLLAPSED_MESSAGE_MAX_HEIGHT_PX,
messageBodyNeedsClamp,
shouldForceExpandMessageBody,
} from "./collapsibleMessageBody";

type CollapsibleMessageBodyProps = {
children: React.ReactNode;
/** Route-target highlight — keep the body fully visible. */
highlighted?: boolean;
/** Active timeline search — expand so matches aren't behind the fold. */
searchQuery?: string;
className?: string;
};

/**
* Clamps tall message bodies (agent dumps, long pastes) behind Show more /
* Show less. Expansion is local to the mounted row and is not persisted.
*/
export function CollapsibleMessageBody({
children,
highlighted = false,
searchQuery,
className,
}: CollapsibleMessageBodyProps) {
const contentRef = React.useRef<HTMLDivElement | null>(null);
const [needsClamp, setNeedsClamp] = React.useState(false);
const [expanded, setExpanded] = React.useState(false);

const forceExpand = shouldForceExpandMessageBody({
highlighted,
searchQuery,
});
const isExpanded = forceExpand || expanded;

React.useLayoutEffect(() => {
const el = contentRef.current;
if (!el) return;

const measure = () => {
// scrollHeight is the full content height even under max-height.
setNeedsClamp(messageBodyNeedsClamp(el.scrollHeight));
};

measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);

const showToggle = needsClamp && !forceExpand;

return (
<div className={cn(className)}>
<div className="relative">
<div
ref={contentRef}
className={cn(!isExpanded && needsClamp && "overflow-hidden")}
style={
!isExpanded && needsClamp
? { maxHeight: COLLAPSED_MESSAGE_MAX_HEIGHT_PX }
: undefined
}
data-testid="collapsible-message-body"
data-collapsed={!isExpanded && needsClamp ? "true" : "false"}
>
{children}
</div>
{!isExpanded && needsClamp ? (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-linear-to-t from-background to-transparent"
/>
) : null}
</div>
{showToggle ? (
<button
type="button"
className="mt-1 text-xs font-medium text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="message-body-expand-toggle"
onClick={() => setExpanded((value) => !value)}
>
{isExpanded ? "Show less" : "Show more"}
</button>
) : null}
</div>
);
}
54 changes: 30 additions & 24 deletions desktop/src/features/messages/ui/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedB
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { CollapsibleMessageBody } from "./CollapsibleMessageBody";
import { MessageActionBar } from "./MessageActionBar";
import { MessageAgentOwner } from "./MessageAgentOwner";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
Expand Down Expand Up @@ -356,31 +357,36 @@ export const MessageRow = React.memo(
}

return (
<Markdown
channelNames={channelNames}
className={cn(
"max-w-full text-sm",
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={message.body}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
agentMentionPubkeysByName={agentMentionPubkeysByName}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
<CollapsibleMessageBody
highlighted={highlighted}
searchQuery={searchQuery}
snapshotSharedBy={snapshotSharedBy}
videoReviewContext={videoReviewContext}
/>
>
<Markdown
channelNames={channelNames}
className={cn(
"max-w-full text-sm",
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={message.body}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
agentMentionPubkeysByName={agentMentionPubkeysByName}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
searchQuery={searchQuery}
snapshotSharedBy={snapshotSharedBy}
videoReviewContext={videoReviewContext}
/>
</CollapsibleMessageBody>
);
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
COLLAPSED_MESSAGE_MAX_HEIGHT_PX,
messageBodyNeedsClamp,
shouldForceExpandMessageBody,
} from "./collapsibleMessageBody.ts";

describe("collapsibleMessageBody", () => {
it("clamps only when content exceeds the max by more than 1px", () => {
assert.equal(messageBodyNeedsClamp(COLLAPSED_MESSAGE_MAX_HEIGHT_PX), false);
assert.equal(
messageBodyNeedsClamp(COLLAPSED_MESSAGE_MAX_HEIGHT_PX + 1),
false,
);
assert.equal(
messageBodyNeedsClamp(COLLAPSED_MESSAGE_MAX_HEIGHT_PX + 2),
true,
);
});

it("force-expands for route highlights and non-empty search", () => {
assert.equal(shouldForceExpandMessageBody({ highlighted: true }), true);
assert.equal(
shouldForceExpandMessageBody({ searchQuery: " checkout " }),
true,
);
assert.equal(shouldForceExpandMessageBody({ searchQuery: " " }), false);
assert.equal(shouldForceExpandMessageBody({}), false);
});
});
21 changes: 21 additions & 0 deletions desktop/src/features/messages/ui/collapsibleMessageBody.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** ~12 lines of text-sm at typical line-height — matches Slack-ish clamp. */
export const COLLAPSED_MESSAGE_MAX_HEIGHT_PX = 240;

/** Expand when the row is a search/route target so the match isn't hidden. */
export function shouldForceExpandMessageBody({
highlighted,
searchQuery,
}: {
highlighted?: boolean;
searchQuery?: string;
}): boolean {
if (highlighted) return true;
return Boolean(searchQuery?.trim());
}

export function messageBodyNeedsClamp(
scrollHeight: number,
maxHeight = COLLAPSED_MESSAGE_MAX_HEIGHT_PX,
): boolean {
return scrollHeight > maxHeight + 1;
}