mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish thread reply hover states (#1329)
This commit is contained in:
@@ -169,16 +169,22 @@ export function InboxListPane({
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"relative block w-full border-l px-3 py-4 text-left transition-colors after:pointer-events-none after:absolute after:bottom-0 after:left-[3.625rem] after:right-0 after:h-px after:bg-border/45 after:content-['']",
|
||||
isSelected
|
||||
? "border-l-transparent bg-[var(--inbox-row-highlight-bg)]"
|
||||
: "border-l-transparent group-hover/inbox-item:bg-[var(--inbox-row-highlight-bg)] group-focus-within/inbox-item:bg-[var(--inbox-row-highlight-bg)] active:bg-muted/40",
|
||||
"relative block w-full border-l border-l-transparent px-3 py-4 text-left after:pointer-events-none after:absolute after:bottom-0 after:left-[3.625rem] after:right-3 after:h-px after:bg-border/45 after:content-['']",
|
||||
index === items.length - 1 && "after:hidden",
|
||||
)}
|
||||
onClick={() => onSelect(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-0 left-0 right-3 transition-colors",
|
||||
isSelected
|
||||
? "bg-[var(--inbox-row-highlight-bg)]"
|
||||
: "group-hover/inbox-item:bg-[var(--inbox-row-highlight-bg)] group-focus-within/inbox-item:bg-[var(--inbox-row-highlight-bg)] group-active/inbox-item:bg-muted/40",
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex min-w-0 items-start gap-2.5">
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatarUrl={item.avatarUrl}
|
||||
@@ -233,7 +239,7 @@ export function InboxListPane({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="pointer-events-none absolute right-2 top-2 z-10 flex items-center gap-0.5 rounded-full bg-[var(--inbox-row-highlight-bg)] p-1 opacity-0 transition-opacity duration-150 ease-out group-hover/inbox-item:pointer-events-auto group-hover/inbox-item:opacity-100 group-focus-within/inbox-item:pointer-events-auto group-focus-within/inbox-item:opacity-100">
|
||||
<div className="pointer-events-none absolute right-3 top-2 z-10 flex items-center gap-0.5 rounded-full bg-[var(--inbox-row-highlight-bg)] p-1 opacity-0 transition-opacity duration-150 ease-out group-hover/inbox-item:pointer-events-auto group-hover/inbox-item:opacity-100 group-focus-within/inbox-item:pointer-events-auto group-focus-within/inbox-item:opacity-100">
|
||||
{isDone ? (
|
||||
<InboxRowActionButton
|
||||
label="Mark unread"
|
||||
@@ -444,7 +450,7 @@ export function InboxListPane({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
|
||||
data-testid="home-inbox-list"
|
||||
ref={scrollRef}
|
||||
>
|
||||
|
||||
@@ -12,27 +12,29 @@ import {
|
||||
|
||||
test("getThreadReplyIndentRem uses a visible Tailwind spacing step", () => {
|
||||
assert.equal(getThreadReplyIndentRem(0), 0);
|
||||
assert.equal(getThreadReplyIndentRem(1), 2.25);
|
||||
assert.equal(getThreadReplyIndentRem(2), 4.5);
|
||||
assert.equal(getThreadReplyIndentRem(3), 6.75);
|
||||
assert.equal(getThreadReplyIndentRem(1), 0);
|
||||
assert.equal(getThreadReplyIndentRem(2), 2.25);
|
||||
assert.equal(getThreadReplyIndentRem(3), 4.5);
|
||||
});
|
||||
|
||||
test("avatar center helpers expose the rail anchor points", () => {
|
||||
assert.equal(getThreadReplyAvatarCenterRem(0), 1.875);
|
||||
assert.equal(getThreadReplyAvatarCenterRem(1), 4.125);
|
||||
assert.equal(getThreadReplyAvatarCenterRem(1), 1.875);
|
||||
assert.equal(getThreadReplyAvatarCenterRem(2), 4.125);
|
||||
assert.equal(getThreadReplyAvatarCenterYRem(), 1.5);
|
||||
assert.equal(getThreadReplyDescendantRailStartYRem(), 2.875);
|
||||
});
|
||||
|
||||
test("getThreadReplyConnectorLayout stops before the child avatar edge", () => {
|
||||
assert.equal(getThreadReplyConnectorLayout(0), null);
|
||||
assert.deepEqual(getThreadReplyConnectorLayout(1), {
|
||||
assert.equal(getThreadReplyConnectorLayout(1), null);
|
||||
assert.deepEqual(getThreadReplyConnectorLayout(2), {
|
||||
childOffsetRem: 4.125,
|
||||
heightRem: 1.5,
|
||||
parentOffsetRem: 1.875,
|
||||
widthRem: 0.875,
|
||||
});
|
||||
assert.deepEqual(getThreadReplyConnectorLayout(2), {
|
||||
assert.deepEqual(getThreadReplyConnectorLayout(3), {
|
||||
childOffsetRem: 6.375,
|
||||
heightRem: 1.5,
|
||||
parentOffsetRem: 4.125,
|
||||
|
||||
@@ -34,18 +34,34 @@ function clampVisibleDepth(depth: number) {
|
||||
return Math.min(Math.max(depth, 0), THREAD_REPLY_MAX_VISIBLE_DEPTH);
|
||||
}
|
||||
|
||||
export function getThreadReplyIndentRem(depth: number) {
|
||||
const visibleDepth = clampVisibleDepth(depth);
|
||||
function getThreadReplyVisualDepth(depth: number) {
|
||||
return clampVisibleDepth(Math.max(0, depth - 1));
|
||||
}
|
||||
|
||||
function getThreadReplyIndentForVisibleDepthRem(visibleDepth: number) {
|
||||
return visibleDepth > 0
|
||||
? THREAD_REPLY_ROOT_INDENT_REM +
|
||||
(visibleDepth - 1) * THREAD_REPLY_NESTED_INDENT_REM
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function getThreadReplyIndentRem(depth: number) {
|
||||
return getThreadReplyIndentForVisibleDepthRem(
|
||||
getThreadReplyVisualDepth(depth),
|
||||
);
|
||||
}
|
||||
|
||||
export function getThreadReplyAvatarCenterRem(depth: number) {
|
||||
return getThreadReplyIndentRem(depth) + THREAD_REPLY_AVATAR_CENTER_OFFSET_REM;
|
||||
}
|
||||
|
||||
function getThreadReplyAvatarCenterForVisibleDepthRem(visibleDepth: number) {
|
||||
return (
|
||||
getThreadReplyIndentForVisibleDepthRem(visibleDepth) +
|
||||
THREAD_REPLY_AVATAR_CENTER_OFFSET_REM
|
||||
);
|
||||
}
|
||||
|
||||
export function getThreadReplyAvatarCenterYRem() {
|
||||
return THREAD_REPLY_AVATAR_CENTER_Y_REM;
|
||||
}
|
||||
@@ -59,13 +75,16 @@ export function getThreadReplyDescendantRailStartYRem() {
|
||||
}
|
||||
|
||||
export function getThreadReplyConnectorLayout(depth: number) {
|
||||
const visibleDepth = clampVisibleDepth(depth);
|
||||
const visibleDepth = getThreadReplyVisualDepth(depth);
|
||||
if (visibleDepth === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentOffsetRem = getThreadReplyAvatarCenterRem(visibleDepth - 1);
|
||||
const childOffsetRem = getThreadReplyAvatarCenterRem(visibleDepth);
|
||||
const parentOffsetRem = getThreadReplyAvatarCenterForVisibleDepthRem(
|
||||
visibleDepth - 1,
|
||||
);
|
||||
const childOffsetRem =
|
||||
getThreadReplyAvatarCenterForVisibleDepthRem(visibleDepth);
|
||||
const childEdgeOffsetRem =
|
||||
childOffsetRem -
|
||||
THREAD_REPLY_AVATAR_RADIUS_REM -
|
||||
|
||||
@@ -197,7 +197,10 @@ export const MessageRow = React.memo(
|
||||
const depthGuideItems = React.useMemo(() => {
|
||||
const depths =
|
||||
depthGuideDepths ??
|
||||
Array.from({ length: message.depth }, (_, depth) => depth);
|
||||
Array.from(
|
||||
{ length: Math.max(0, message.depth - 1) },
|
||||
(_, index) => index + 1,
|
||||
);
|
||||
|
||||
return depths.map((depth) => ({
|
||||
depth,
|
||||
|
||||
@@ -149,6 +149,10 @@ function getActiveContinuationDepths({
|
||||
const depths: number[] = [];
|
||||
|
||||
for (const ancestor of ancestors) {
|
||||
if (ancestor.message.depth === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const childDepth = ancestor.message.depth + 1;
|
||||
const pathChild =
|
||||
message.depth === childDepth
|
||||
@@ -626,27 +630,10 @@ export function MessageThreadPanel({
|
||||
actionBarPlacement="inside"
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
collapseDescendantsLabel="Collapse thread"
|
||||
connectDescendants={
|
||||
shouldShowThreadBranchGuides &&
|
||||
!isThreadHeadRepliesCollapsed &&
|
||||
deferredThreadReplies.length > 0
|
||||
}
|
||||
highlightDescendantRail={
|
||||
shouldShowThreadBranchGuides &&
|
||||
!isThreadHeadRepliesCollapsed &&
|
||||
highlightedBranch?.id === threadHead.id
|
||||
}
|
||||
isFollowingThread={isFollowingThread}
|
||||
isUnread={isMessageUnreadById?.(threadHead.id)}
|
||||
layoutVariant="thread-reply"
|
||||
message={threadHead}
|
||||
onCollapseDescendants={
|
||||
isThreadHeadRepliesCollapsed
|
||||
? undefined
|
||||
: collapseThreadHeadReplies
|
||||
}
|
||||
onCollapseDescendantsHoverChange={handleCollapseBranchHoverChange}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
@@ -730,7 +717,7 @@ export function MessageThreadPanel({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"content-visibility-auto flex flex-col gap-0",
|
||||
"content-visibility-auto-interactive flex flex-col gap-0",
|
||||
entry.summary &&
|
||||
"group/message rounded-2xl px-0 py-0.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
)}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function MessageThreadSummaryRow({
|
||||
: `View thread with ${summary.replyCount} ${replyLabel}`;
|
||||
const guideDepths = depthGuideDepths
|
||||
? [...depthGuideDepths]
|
||||
: Array.from({ length: depth }, (_, index) => index);
|
||||
: Array.from({ length: Math.max(0, depth - 1) }, (_, index) => index + 1);
|
||||
const depthGuideItems = guideDepths.map((guideDepth) => ({
|
||||
depth: guideDepth,
|
||||
offset: getThreadReplyAvatarCenterRem(guideDepth),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { formatFullDateTime } from "@/features/messages/lib/dateFormatters";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/ui/tooltip";
|
||||
|
||||
const TIMESTAMP_TOOLTIP_DELAY_MS = 500;
|
||||
|
||||
export function MessageTimestamp({
|
||||
createdAt,
|
||||
@@ -9,15 +16,20 @@ export function MessageTimestamp({
|
||||
time: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55">
|
||||
{time}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{formatFullDateTime(createdAt)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipProvider
|
||||
delayDuration={TIMESTAMP_TOOLTIP_DELAY_MS}
|
||||
skipDelayDuration={0}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55">
|
||||
{time}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{formatFullDateTime(createdAt)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ type UserProfilePopoverProps = {
|
||||
botIdenticonValue?: string;
|
||||
};
|
||||
|
||||
const HOVER_OPEN_DELAY_MS = 300;
|
||||
const HOVER_OPEN_DELAY_MS = 500;
|
||||
const HOVER_CLOSE_DELAY_MS = 200;
|
||||
|
||||
const RUNTIME_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -1891,6 +1891,15 @@
|
||||
contain-intrinsic-size: auto 200px;
|
||||
}
|
||||
|
||||
.content-visibility-auto-interactive {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 200px;
|
||||
}
|
||||
|
||||
.content-visibility-auto-interactive:is(:hover, :focus-within) {
|
||||
content-visibility: visible;
|
||||
}
|
||||
|
||||
/* Compact variant for short single-line rows (sidebar channels, etc). */
|
||||
.content-visibility-auto-row {
|
||||
content-visibility: auto;
|
||||
|
||||
@@ -207,7 +207,7 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => {
|
||||
await expect(
|
||||
page.getByTestId("message-thread-replies").getByTestId("message-row"),
|
||||
).toHaveCount(2);
|
||||
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(2);
|
||||
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(1);
|
||||
|
||||
await screenshotThreadPanel(page, `${SHOTS}/01-baseline-depth-2.png`);
|
||||
});
|
||||
@@ -344,7 +344,7 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => {
|
||||
await expect(
|
||||
page.getByTestId("message-thread-replies").getByTestId("message-row"),
|
||||
).toHaveCount(2);
|
||||
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(2);
|
||||
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(1);
|
||||
|
||||
await screenshotThreadPanel(page, `${SHOTS}/04-agent-only-nested.png`);
|
||||
});
|
||||
|
||||
@@ -365,23 +365,18 @@ test.describe("thread unread indicator", () => {
|
||||
await divider.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const panel = page.getByTestId("message-thread-panel");
|
||||
await page.getByTestId("message-thread-head").scrollIntoViewIfNeeded();
|
||||
await page
|
||||
.locator(
|
||||
await expect(
|
||||
panel.locator(
|
||||
`[data-testid="thread-collapse-rail"][data-thread-head-id="mock-general-welcome"]`,
|
||||
)
|
||||
.click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await expect(replies).toHaveCount(0);
|
||||
const rootStack = page
|
||||
.getByTestId("message-thread-replies")
|
||||
.locator(
|
||||
`[data-testid="message-thread-summary"][data-thread-head-id="mock-general-welcome"]`,
|
||||
);
|
||||
await expect(rootStack).toBeVisible();
|
||||
await expect(rootStack).toContainText("6 replies");
|
||||
await rootStack.click();
|
||||
await expect(replies).toHaveCount(6);
|
||||
),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
panel.locator(
|
||||
`[data-testid="thread-collapse-guide"][data-thread-head-id="mock-general-welcome"]`,
|
||||
),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page
|
||||
.locator(
|
||||
|
||||
Reference in New Issue
Block a user