Align thread summary rows (#1421)

This commit is contained in:
klopez4212
2026-07-01 15:48:58 +01:00
committed by GitHub
parent 8c38847913
commit 4ae5a0d5e3
6 changed files with 136 additions and 9 deletions

View File

@@ -1,7 +1,7 @@
const THREAD_REPLY_MAX_VISIBLE_DEPTH = 6;
const THREAD_REPLY_AVATAR_SIZE_REM = 2.25; // Tailwind size-9
const THREAD_REPLY_ROW_MARGIN_INLINE_REM = 0.25; // Tailwind mx-1
export const THREAD_REPLY_ROW_MARGIN_INLINE_REM = 0.25; // Tailwind mx-1
const THREAD_REPLY_ROW_CONTENT_INSET_REM = 0.5; // Tailwind px-2
const THREAD_REPLY_ROW_CONTENT_GAP_REM = 0.625; // Tailwind gap-2.5
const THREAD_REPLY_ROW_PADDING_TOP_REM = 0.375; // Tailwind py-1.5

View File

@@ -43,6 +43,7 @@ export function MessageAuthorText({
hoverUnderline && "hover:underline",
className,
)}
data-testid="message-author"
>
{children}
</Component>

View File

@@ -95,7 +95,7 @@ type MessageThreadPanelProps = {
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2";
const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5";
const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = -0.125;
const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = 0;
type MessageThreadPanelSkeletonProps = {
isSinglePanelView?: boolean;
layout?: "standalone" | "split";

View File

@@ -13,10 +13,15 @@ import {
threadReplyLength,
THREAD_REPLY_BODY_OFFSET_REM,
THREAD_REPLY_LINE_WIDTH_REM,
THREAD_REPLY_ROW_MARGIN_INLINE_REM,
} from "@/features/messages/lib/threadTreeLayout";
import { cn } from "@/shared/lib/cn";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const THREAD_SUMMARY_CONTENT_OFFSET_REM =
THREAD_REPLY_BODY_OFFSET_REM - THREAD_REPLY_ROW_MARGIN_INLINE_REM;
const THREAD_SUMMARY_SURFACE_AVATAR_INSET_REM = 0.25;
function ParticipantAvatar({
participant,
index,
@@ -80,8 +85,15 @@ export function MessageThreadSummaryRow({
unreadCount?: number;
}) {
const indentRem = getThreadReplyIndentRem(depth);
const marginLeftRem =
indentRem + THREAD_REPLY_BODY_OFFSET_REM + summaryIndentOffsetRem;
const hoverLeftRem =
indentRem + THREAD_REPLY_ROW_MARGIN_INLINE_REM + summaryIndentOffsetRem;
const hoverLeft = threadReplyLength(hoverLeftRem);
const contentPaddingStart = threadReplyLength(
THREAD_SUMMARY_CONTENT_OFFSET_REM,
);
const surfaceInsetStart = `calc(${contentPaddingStart} - ${threadReplyLength(
THREAD_SUMMARY_SURFACE_AVATAR_INSET_REM,
)})`;
const replyLabel = summary.replyCount === 1 ? "reply" : "replies";
const summaryAriaLabel = summary.lastReplyAt
? `View thread with ${summary.replyCount} ${replyLabel}, last reply ${formatThreadSummaryLastReplyTime(summary.lastReplyAt)}`
@@ -198,14 +210,27 @@ export function MessageThreadSummaryRow({
<button
aria-label={summaryAriaLabel}
className="group relative isolate inline-flex h-8 w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-full text-left text-xs font-medium text-muted-foreground transition-[color,opacity] before:pointer-events-none before:absolute before:-bottom-0.5 before:-left-0.5 before:-right-2 before:-top-0.5 before:-z-10 before:rounded-full before:content-[''] before:transition-[background-color,box-shadow] hover:text-foreground hover:opacity-90 hover:before:bg-background/95 hover:before:ring-1 hover:before:ring-border/70 focus-visible:outline-hidden focus-visible:before:bg-background/95 focus-visible:before:ring-1 focus-visible:before:ring-ring"
className="group relative isolate inline-flex h-8 w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-full py-0 pr-3 text-left text-xs font-medium text-muted-foreground transition-[color,opacity] hover:text-foreground hover:opacity-90 focus-visible:outline-hidden"
data-thread-head-id={message.id}
data-testid="message-thread-summary"
onClick={() => onOpenThread(message)}
style={{ marginLeft: threadReplyLength(marginLeftRem) }}
style={{
marginLeft: hoverLeft,
maxWidth: `calc(100% - ${hoverLeft})`,
paddingLeft: contentPaddingStart,
}}
type="button"
>
<div className="ml-0.5 flex shrink-0 items-center">
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[-0.125rem] top-[-0.125rem] rounded-full opacity-0 ring-border/70 transition-[background-color,box-shadow,opacity] group-hover:bg-background/95 group-hover:opacity-100 group-hover:ring-1 group-focus-visible:bg-background/95 group-focus-visible:opacity-100 group-focus-visible:ring-1 group-focus-visible:ring-ring"
data-testid="message-thread-summary-surface"
style={{
left: surfaceInsetStart,
right: 0,
}}
/>
<div className="relative z-10 flex shrink-0 items-center">
{summary.participants.map((participant, index) => (
<ParticipantAvatar
index={index}
@@ -215,7 +240,7 @@ export function MessageThreadSummaryRow({
/>
))}
</div>
<div className="min-w-0">
<div className="relative z-10 min-w-0">
<div>
<span className="font-medium transition-colors group-hover:text-foreground">
{summary.replyCount} {replyLabel}

View File

@@ -8,6 +8,7 @@ import {
getTimelineItemKey,
type TimelineNonDayItem,
} from "@/features/messages/lib/timelineItems";
import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import {
@@ -374,7 +375,7 @@ function MessageRowItem({
return (
<div
className={cn(
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 pb-2.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
"group/message relative mx-1 mb-1.5 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isHighlighted &&
"-mx-4 px-4 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-6",
)}
@@ -417,6 +418,7 @@ function MessageRowItem({
onOpenThread={onReply}
showDepthGuides={false}
summary={summary}
summaryIndentOffsetRem={-THREAD_REPLY_ROW_MARGIN_INLINE_REM}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}

View File

@@ -30,6 +30,67 @@ async function expectThreadReplyUnobscured(row: Locator) {
.toBe(true);
}
async function measureThreadSummaryGeometry(summaryRow: Locator) {
return summaryRow.evaluate((summaryButton) => {
const summaryWrapper = summaryButton.parentElement;
const container = summaryWrapper?.parentElement;
const messageRow = container?.querySelector<HTMLElement>(
'[data-testid="message-row"]',
);
const messageMarkdown =
messageRow?.querySelector<HTMLElement>(".message-markdown");
const messageAuthor = messageRow?.querySelector<HTMLElement>(
'[data-testid="message-author"]',
);
const firstParticipant = summaryButton.querySelector<HTMLElement>(
'[data-testid="message-thread-summary-participant"]',
);
const summarySurface = summaryButton.querySelector<HTMLElement>(
'[data-testid="message-thread-summary-surface"]',
);
const firstAvatar = firstParticipant?.firstElementChild;
if (
!summaryWrapper ||
!container ||
!messageRow ||
!messageAuthor ||
!messageMarkdown ||
!summarySurface ||
!(firstAvatar instanceof HTMLElement)
) {
throw new Error("Expected measurable thread summary geometry.");
}
const containerRect = container.getBoundingClientRect();
const messageRowRect = messageRow.getBoundingClientRect();
const messageAuthorRect = messageAuthor.getBoundingClientRect();
const messageMarkdownRect = messageMarkdown.getBoundingClientRect();
const summaryButtonRect = summaryButton.getBoundingClientRect();
const summaryButtonStyle = getComputedStyle(summaryButton);
const summaryButtonPaddingLeft = Number.parseFloat(
summaryButtonStyle.paddingLeft,
);
const summaryWrapperRect = summaryWrapper.getBoundingClientRect();
const firstAvatarRect = firstAvatar.getBoundingClientRect();
const summarySurfaceRect = summarySurface.getBoundingClientRect();
return {
authorLeft: messageAuthorRect.left,
avatarLeft: firstAvatarRect.left,
bodyLeft: messageMarkdownRect.left,
bottomPadding: containerRect.bottom - summaryWrapperRect.bottom,
messageRowLeft: messageRowRect.left,
summaryButtonContentLeft:
summaryButtonRect.left + summaryButtonPaddingLeft,
summaryButtonLeft: summaryButtonRect.left,
summaryButtonPaddingLeft,
summarySurfaceLeft: summarySurfaceRect.left,
topPadding: messageRowRect.top - containerRect.top,
};
});
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -583,6 +644,44 @@ test("opens a single-level thread panel with inline expansion", async ({
}),
)
.toBe("28x28");
const summaryGeometry = await measureThreadSummaryGeometry(rootSummaryRow);
expect(
Math.abs(summaryGeometry.authorLeft - summaryGeometry.bodyLeft),
).toBeLessThanOrEqual(1);
expect(
Math.abs(summaryGeometry.avatarLeft - summaryGeometry.bodyLeft),
).toBeLessThanOrEqual(1);
expect(
Math.abs(
summaryGeometry.summaryButtonContentLeft - summaryGeometry.bodyLeft,
),
).toBeLessThanOrEqual(1);
expect(
Math.abs(
summaryGeometry.summaryButtonLeft - summaryGeometry.messageRowLeft,
),
).toBeLessThanOrEqual(1);
expect(summaryGeometry.summaryButtonLeft).toBeLessThan(
summaryGeometry.bodyLeft,
);
expect(
Math.abs(
summaryGeometry.bodyLeft -
summaryGeometry.summaryButtonLeft -
summaryGeometry.summaryButtonPaddingLeft,
),
).toBeLessThanOrEqual(1);
expect(summaryGeometry.summarySurfaceLeft).toBeLessThan(
summaryGeometry.avatarLeft,
);
expect(
Math.abs(
summaryGeometry.avatarLeft - summaryGeometry.summarySurfaceLeft - 4,
),
).toBeLessThanOrEqual(1);
expect(
Math.abs(summaryGeometry.topPadding - summaryGeometry.bottomPadding),
).toBeLessThanOrEqual(1);
await page.mouse.move(0, 0);
const rootSummaryWidthBeforeHover = await rootSummaryRow.evaluate((row) =>