Skip to content
Merged
4 changes: 2 additions & 2 deletions desktop/src/features/messages/lib/rowHeightEstimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ const MEDIA_MAX_HEIGHT = 256; // max-h-64
const TEXT_LINE_HEIGHT = 22;
const CODE_LINE_HEIGHT = 19;
const CHARS_PER_LINE = 64; // rough wrap width at the timeline column
const ROW_CHROME = 34; // author/time header + row padding
const REACTION_ROW = 28;
const ROW_CHROME = 26; // author/time header + denser row padding
const REACTION_ROW = 24;
const PREVIEW_CARD = 96;
const MIN_ESTIMATE = 60; // never reserve less than the old flat floor

Expand Down
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
35 changes: 29 additions & 6 deletions desktop/src/features/messages/ui/MessageReactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";

const REACTION_PILL_BASE_CLASSES =
"inline-flex h-8 items-center rounded-full border text-xs font-medium leading-none transition-colors";
const REACTION_GLYPH_CLASSES = "h-3.5 w-3.5 translate-y-px text-sm";
"inline-flex h-7 items-center rounded-full border text-xs font-medium leading-none transition-colors";
Comment thread
klopez4212 marked this conversation as resolved.
const REACTION_CUSTOM_GLYPH_CLASSES = "h-3.5 w-3.5 -translate-y-[0.5px]";
const REACTION_NATIVE_GLYPH_CLASSES = "h-3 w-3 text-xs";
const REACTION_COUNT_CLASSES = "text-muted-foreground";
const REACTION_NATIVE_COUNT_CLASSES =
"text-muted-foreground translate-y-[0.5px]";
const REACTION_PILL_HOVER_CLASSES =
"hover:bg-primary/10 hover:text-foreground focus-visible:bg-primary/10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring";
const BADGE_BURST_STABLE_FRAMES = 2;
Expand Down Expand Up @@ -436,9 +440,20 @@ function ReactionPill({
ref={setPillRef}
type="button"
>
<EmojiGlyph reaction={reaction} className={REACTION_GLYPH_CLASSES} />
<EmojiGlyph
reaction={reaction}
className={
reaction.emojiUrl
? REACTION_CUSTOM_GLYPH_CLASSES
: REACTION_NATIVE_GLYPH_CLASSES
}
/>
<AnimatedCount
className="text-muted-foreground"
className={
reaction.emojiUrl
? REACTION_COUNT_CLASSES
: REACTION_NATIVE_COUNT_CLASSES
}
value={reaction.count}
/>
</button>
Expand Down Expand Up @@ -468,10 +483,18 @@ function ReactionPill({
>
<EmojiGlyph
reaction={reaction}
className={REACTION_GLYPH_CLASSES}
className={
reaction.emojiUrl
? REACTION_CUSTOM_GLYPH_CLASSES
: REACTION_NATIVE_GLYPH_CLASSES
}
/>
<AnimatedCount
className="text-muted-foreground"
className={
reaction.emojiUrl
? REACTION_COUNT_CLASSES
: REACTION_NATIVE_COUNT_CLASSES
}
value={reaction.count}
/>
</button>
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/features/messages/ui/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,9 +380,9 @@ export const MessageRow = React.memo(
const actionBarNode = (
<div
className={cn(
"absolute right-2 top-1 z-10",
"pointer-events-none absolute right-2 top-1 z-10",
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
actionBarPlacement === "floating"
? "sm:top-0 sm:-translate-y-1/2"
? "sm:translate-y-0"
: "sm:top-1 sm:translate-y-0",
Comment thread
klopez4212 marked this conversation as resolved.
)}
>
Expand Down Expand Up @@ -644,7 +644,7 @@ export const MessageRow = React.memo(
<article
className={cn(
"group/message relative z-10 rounded-2xl transition-colors",
isThreadReplyLayout ? "py-1.5" : "py-2",
isThreadReplyLayout ? "py-1.5" : "py-1",
hoverBackground
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
: isThreadReplyLayout
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
45 changes: 35 additions & 10 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 All @@ -28,20 +33,20 @@ function ParticipantAvatar({
}) {
return (
<div
className={index > 0 ? "-ml-2" : ""}
className={index > 0 ? "-ml-1" : ""}
data-testid="message-thread-summary-participant"
style={{
zIndex: index + 1,
...(index < participantCount - 1 && {
mask: "radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
mask: "radial-gradient(circle 14px at calc(100% + 6px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
"radial-gradient(circle 14px at calc(100% + 6px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-7 w-7 text-2xs"
className="h-6 w-6 text-2xs"
Comment thread
klopez4212 marked this conversation as resolved.
displayName={participant.author}
size="sm"
/>
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-[1.875rem] 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 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
2 changes: 1 addition & 1 deletion desktop/src/shared/ui/markdown/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function MarkdownCodeBlock({
<TooltipTrigger asChild>
<Button
aria-label="Copy code block"
className="absolute right-2 top-2 h-7 w-7 bg-background/80 text-muted-foreground opacity-0 shadow-xs ring-1 ring-border/60 backdrop-blur-sm transition-opacity hover:bg-background hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 disabled:opacity-60"
className="absolute right-2 top-2 z-20 h-7 w-7 bg-background/80 text-muted-foreground opacity-0 shadow-xs ring-1 ring-border/60 backdrop-blur-sm transition-opacity hover:bg-background hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 disabled:opacity-60"
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
disabled={isCopying}
onClick={handleCopy}
size="icon"
Expand Down
4 changes: 2 additions & 2 deletions desktop/tests/e2e/custom-emoji.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ test("reacting with a custom emoji renders via the loopback media proxy", async
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
}),
)
.toBe("40x32");
.toBe("40x28");
await expect
.poll(() =>
inlineAddReactionButton.evaluate((button) => {
Expand All @@ -295,7 +295,7 @@ test("reacting with a custom emoji renders via the loopback media proxy", async
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
}),
)
.toBe("40x32");
.toBe("40x28");

// Toggle the reaction back off: click the pill, which fires remove_reaction
// -> emits a kind:5 deletion targeting the reaction event. The pill must
Expand Down
101 changes: 100 additions & 1 deletion 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 @@ -582,7 +643,45 @@ test("opens a single-level thread panel with inline expansion", async ({
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
}),
)
.toBe("28x28");
.toBe("24x24");
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
Loading