Refine thread reply alignment (#620)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
thomaspblock
2026-05-19 19:11:49 -04:00
committed by GitHub
co-authored by Cursor
parent 2fed74bdcd
commit b881d5319c
5 changed files with 125 additions and 50 deletions
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildMainTimelineEntries } from "./threadPanel.ts";
import {
buildMainTimelineEntries,
buildThreadPanelData,
} from "./threadPanel.ts";
function message(overrides) {
return {
@@ -56,3 +59,44 @@ test("buildMainTimelineEntries includes broadcast replies", () => {
["root", "broadcast-reply"],
);
});
test("buildThreadPanelData keeps direct comments unindented", () => {
const root = message({ id: "root", createdAt: 1 });
const directComment = message({
id: "direct-comment",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
tags: [["e", "root", "", "reply"]],
});
const nestedReply = message({
id: "nested-reply",
createdAt: 3,
parentId: "direct-comment",
rootId: "root",
depth: 2,
tags: [
["e", "root", "", "root"],
["e", "direct-comment", "", "reply"],
],
});
const panelData = buildThreadPanelData(
[root, directComment, nestedReply],
"root",
"root",
new Set(["direct-comment"]),
);
assert.deepEqual(
panelData.visibleReplies.map((entry) => ({
id: entry.message.id,
depth: entry.message.depth,
})),
[
{ id: "direct-comment", depth: 0 },
{ id: "nested-reply", depth: 1 },
],
);
});
@@ -215,7 +215,7 @@ function buildVisibleThreadReplies(params: {
appendExpandedReplies({
entries,
parentId: openThreadHeadId,
depth: 1,
depth: 0,
directChildrenByParentId,
descendantStatsByMessageId,
expandedReplyIds,
+56 -37
View File
@@ -22,6 +22,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const DiffMessage = React.lazy(() => import("./DiffMessage"));
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
const MESSAGE_TEXT_OFFSET_PX = 54;
const NESTED_REPLY_OFFSET_PX = 28;
export const MessageRow = React.memo(
function MessageRow({
activeReplyTargetId = null,
@@ -84,11 +87,21 @@ export const MessageRow = React.memo(
);
const visibleDepth = Math.min(message.depth, 6);
const indentPx = visibleDepth * 28;
const indentPx =
visibleDepth > 0
? MESSAGE_TEXT_OFFSET_PX + (visibleDepth - 1) * NESTED_REPLY_OFFSET_PX
: 0;
const depthGuideOffsets = React.useMemo(() => {
return Array.from(
{ length: visibleDepth },
(_, index) => 14 + index * 28,
if (visibleDepth === 0) {
return [];
}
return Array.from({ length: visibleDepth }, (_, index) =>
index === 0
? MESSAGE_TEXT_OFFSET_PX / 2
: MESSAGE_TEXT_OFFSET_PX +
NESTED_REPLY_OFFSET_PX / 2 +
(index - 1) * NESTED_REPLY_OFFSET_PX,
);
}, [visibleDepth]);
const getTag = (name: string) =>
@@ -136,12 +149,8 @@ export const MessageRow = React.memo(
const isThreadReplyLayout = layoutVariant === "thread-reply";
const guideBleedPx = isThreadReplyLayout ? 4 : 0;
const avatarSizeClass = isThreadReplyLayout
? "!h-5 !w-5 !rounded-md"
: "!h-9 !w-9";
const avatarButtonRadiusClass = isThreadReplyLayout
? "rounded-md"
: "rounded-xl";
const avatarSizeClass = "!h-9 !w-9";
const avatarButtonRadiusClass = "rounded-xl";
const respondToDotColor =
message.respondTo === "anyone"
@@ -291,7 +300,7 @@ export const MessageRow = React.memo(
<article
className={cn(
"group/message relative rounded-2xl px-2 py-1 transition-colors",
isThreadReplyLayout ? "space-y-1" : "flex items-start gap-2.5",
"flex items-start gap-2.5",
highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
)}
data-message-id={message.id}
@@ -299,33 +308,43 @@ export const MessageRow = React.memo(
>
{isThreadReplyLayout ? (
<>
<div className="flex min-w-0 items-baseline gap-1.5">
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={message.role}
botIdenticonValue={message.author}
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={message.role}
botIdenticonValue={message.author}
>
<button
className={cn(
"flex shrink-0 items-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
avatarButtonRadiusClass,
)}
type="button"
>
<button
className={cn(
"flex shrink-0 items-start gap-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
avatarButtonRadiusClass,
)}
type="button"
{avatarNode}
</button>
</UserProfilePopover>
) : (
<div className="flex shrink-0 items-start">{avatarNode}</div>
)}
<div className="-mt-1 min-w-0 flex-1 space-y-0">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={message.role}
botIdenticonValue={message.author}
>
{avatarNode}
{authorNode}
</button>
</UserProfilePopover>
) : (
<>
<div className="flex shrink-0 items-start">
{avatarNode}
</div>
{authorNode}
</>
)}
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<button
className="truncate rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{authorNode}
</button>
</UserProfilePopover>
) : (
authorNode
)}
{inlineMetadataNode}
{message.personaDisplayName &&
message.personaDisplayName !== message.author ? (
@@ -334,8 +353,8 @@ export const MessageRow = React.memo(
</span>
) : null}
</div>
<div className="-mt-0.5">{messageBodyNode}</div>
</div>
<div className="min-w-0 space-y-0.5">{messageBodyNode}</div>
</>
) : (
<>
@@ -228,10 +228,13 @@ export function MessageThreadPanel({
data-testid="message-thread-replies"
>
{threadReplies.length > 0 ? (
<div className="space-y-2">
<div className="space-y-2.5">
{threadReplies.map((entry) => {
return (
<div key={entry.message.id}>
<div
className="flex flex-col gap-1"
key={entry.message.id}
>
<MessageRow
activeReplyTargetId={replyTargetId}
channelId={channelId}
@@ -257,7 +260,6 @@ export function MessageThreadPanel({
{entry.summary ? (
<MessageThreadSummaryRow
depth={entry.message.depth}
layoutVariant="thread-reply"
message={entry.message}
onOpenThread={onExpandReplies}
summary={entry.summary}
@@ -5,6 +5,9 @@ import type {
import type { TimelineMessage } from "@/features/messages/types";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const MESSAGE_TEXT_OFFSET_PX = 54;
const NESTED_REPLY_OFFSET_PX = 28;
function formatRelativeTime(unixSeconds: number): string {
const now = Date.now() / 1_000;
const diff = now - unixSeconds;
@@ -45,24 +48,31 @@ function ParticipantAvatar({
export function MessageThreadSummaryRow({
depth = 0,
layoutVariant = "default",
message,
onOpenThread,
summary,
}: {
depth?: number;
layoutVariant?: "default" | "thread-reply";
message: TimelineMessage;
onOpenThread: (message: TimelineMessage) => void;
summary: TimelineThreadSummary;
}) {
const visibleDepth = Math.min(Math.max(depth, 0), 6);
const messageTextOffsetPx = layoutVariant === "thread-reply" ? 8 : 50;
const marginLeftPx = visibleDepth * 28 + messageTextOffsetPx;
const depthGuideOffsets = Array.from(
{ length: visibleDepth },
(_, index) => 14 + index * 28,
);
const indentPx =
visibleDepth > 0
? MESSAGE_TEXT_OFFSET_PX + (visibleDepth - 1) * NESTED_REPLY_OFFSET_PX
: 0;
const marginLeftPx = indentPx + MESSAGE_TEXT_OFFSET_PX;
const depthGuideOffsets =
visibleDepth === 0
? []
: Array.from({ length: visibleDepth }, (_, index) =>
index === 0
? MESSAGE_TEXT_OFFSET_PX / 2
: MESSAGE_TEXT_OFFSET_PX +
NESTED_REPLY_OFFSET_PX / 2 +
(index - 1) * NESTED_REPLY_OFFSET_PX,
);
return (
<div className="relative pb-1 pt-1">