Skip to content
Merged
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
2 changes: 1 addition & 1 deletion desktop/src/features/messages/lib/threadTreeLayout.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/messages/ui/MessageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function MessageAuthorText({
hoverUnderline && "hover:underline",
className,
)}
data-testid="message-author"
>
{children}
</Component>
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
37 changes: 31 additions & 6 deletions desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}`
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/features/messages/ui/TimelineMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getTimelineItemKey,
type TimelineItem,
} 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 {
Expand Down Expand Up @@ -353,7 +354,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",
)}
Expand Down Expand Up @@ -396,6 +397,7 @@ function MessageRowItem({
onOpenThread={onReply}
showDepthGuides={false}
summary={summary}
summaryIndentOffsetRem={-THREAD_REPLY_ROW_MARGIN_INLINE_REM}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}
Expand Down
99 changes: 99 additions & 0 deletions desktop/tests/e2e/messaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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) =>
Expand Down
Loading