mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +00:00
Align thread summary rows (#1421)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -43,6 +43,7 @@ export function MessageAuthorText({
|
||||
hoverUnderline && "hover:underline",
|
||||
className,
|
||||
)}
|
||||
data-testid="message-author"
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user