fix(desktop): stabilize message action morph

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-08-26 10:36:18 -04:00
parent 147821a3a7
commit 7379f48a9a
4 changed files with 109 additions and 12 deletions

View File

@@ -14,7 +14,12 @@ import { MessageActionBloomSurface } from "@/features/messages/ui/MessageActionB
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { useIdentityQuery } from "@/shared/api/hooks";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown";
@@ -22,11 +27,13 @@ import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdo
const ACTION_BUTTON_CLASS = "h-8 w-8 rounded-full p-0";
export function BestieMessagePopover({
anchorRef,
channelId,
message,
onOpenChange,
open,
}: {
anchorRef: React.RefObject<HTMLElement | null>;
channelId: string;
message: TimelineMessage;
onOpenChange: (open: boolean) => void;
@@ -85,6 +92,7 @@ export function BestieMessagePopover({
return (
<Popover onOpenChange={onOpenChange} open={open}>
<PopoverAnchor virtualRef={anchorRef} />
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@@ -113,7 +121,7 @@ export function BestieMessagePopover({
align="end"
className="data-[state=closed]:animate-none data-[state=open]:animate-none"
side="top"
sideOffset={6}
sideOffset={2}
>
<MessageActionBloomSurface
className="w-[min(328px,calc(100vw-2rem))] p-4"

View File

@@ -41,7 +41,12 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { BestieMessagePopover } from "./BestieMessagePopover";
import {
@@ -386,6 +391,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
const [activeSurface, setActiveSurface] = React.useState<
"reactions" | "bestie" | "more" | null
>(null);
const toolbarAnchorRef = React.useRef<HTMLDivElement>(null);
const isReactionPickerOpen = activeSurface === "reactions";
const isBestiePopoverOpen = activeSurface === "bestie";
const isDropdownOpen = activeSurface === "more";
@@ -477,6 +483,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
"relative flex items-center gap-0.5 p-1",
activeSurface && "pointer-events-none",
)}
ref={toolbarAnchorRef}
transition={{
duration: 0.12,
ease: MESSAGE_ACTION_BLOOM_EASE_OUT,
@@ -489,6 +496,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
}
open={isReactionPickerOpen}
>
<PopoverAnchor virtualRef={toolbarAnchorRef} />
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@@ -511,7 +519,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
align="end"
className="data-[state=closed]:animate-none data-[state=open]:animate-none"
side="top"
sideOffset={6}
sideOffset={2}
>
<MessageActionBloomSurface
className="w-auto p-0"
@@ -540,6 +548,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
{canCopyMessageLink(message, channelId) ? (
<BestieMessagePopover
anchorRef={toolbarAnchorRef}
channelId={channelId}
message={message}
onOpenChange={(nextOpen) =>

View File

@@ -6,10 +6,9 @@ import { cn } from "@/shared/lib/cn";
export const MESSAGE_ACTION_BLOOM_LAYOUT_ID = "message-action-bloom-surface";
export const MESSAGE_ACTION_BLOOM_EASE_OUT = [0.23, 1, 0.32, 1] as const;
const BLOOM_LAYOUT_SPRING = {
type: "spring",
stiffness: 400,
damping: 28,
const BLOOM_LAYOUT_TRANSITION = {
duration: 0.24,
ease: MESSAGE_ACTION_BLOOM_EASE_OUT,
} as const;
type MessageActionBloomSurfaceProps = Omit<
@@ -43,6 +42,23 @@ export const MessageActionBloomSurface = React.forwardRef<
ref,
) {
const reduceMotion = useReducedMotion();
const [contentReady, setContentReady] = React.useState(
reduceMotion || !revealContent,
);
React.useEffect(() => {
if (reduceMotion || !revealContent) {
setContentReady(true);
return;
}
// Radix positions portalled content after it mounts. Keep the destination
// content out of sight through that measurement and the first part of the
// shell morph; the callback below normally wins, while this is a safety net
// for browsers that skip layout-complete notifications.
const fallback = window.setTimeout(() => setContentReady(true), 160);
return () => window.clearTimeout(fallback);
}, [reduceMotion, revealContent]);
return (
<motion.div
@@ -51,28 +67,28 @@ export const MessageActionBloomSurface = React.forwardRef<
className,
)}
layoutId={MESSAGE_ACTION_BLOOM_LAYOUT_ID}
onLayoutAnimationComplete={() => setContentReady(true)}
ref={ref}
style={{ ...style, borderRadius: surfaceRadius }}
transition={
reduceMotion
? { duration: 0 }
: {
layout: BLOOM_LAYOUT_SPRING,
layout: BLOOM_LAYOUT_TRANSITION,
}
}
{...props}
>
{revealContent ? (
<motion.div
animate={{ opacity: 1 }}
animate={{ opacity: contentReady ? 1 : 0 }}
className={contentClassName}
initial={{ opacity: 0 }}
transition={
reduceMotion
? { duration: 0 }
: {
delay: 0.08,
duration: 0.14,
duration: 0.12,
ease: MESSAGE_ACTION_BLOOM_EASE_OUT,
}
}

View File

@@ -129,6 +129,29 @@ test("message action sends a message link and optional note to Bestie", async ({
await expect(
page.getByTestId(`reaction-bloom-panel-${messageId}`),
).toBeVisible();
const reactionPanel = page.getByTestId(`reaction-bloom-panel-${messageId}`);
await expect
.poll(() =>
reactionPanel.evaluate(
(element) => window.getComputedStyle(element).transform,
),
)
.toBe("none");
const [reactionBox, reactionAnchorBox] = await Promise.all([
reactionPanel.boundingBox(),
actionBar.boundingBox(),
]);
expect(reactionBox).not.toBeNull();
expect(reactionAnchorBox).not.toBeNull();
if (reactionBox && reactionAnchorBox) {
expect(
Math.abs(
reactionBox.x +
reactionBox.width -
(reactionAnchorBox.x + reactionAnchorBox.width),
),
).toBeLessThanOrEqual(10);
}
await page.keyboard.press("Escape");
await expect(actionBar).toHaveAttribute("data-bloom-surface", "toolbar");
@@ -137,6 +160,27 @@ test("message action sends a message link and optional note to Bestie", async ({
await expect(
page.getByTestId(`more-actions-panel-${messageId}`),
).toBeVisible();
const morePanel = page.getByTestId(`more-actions-panel-${messageId}`);
await expect
.poll(() =>
morePanel.evaluate(
(element) => window.getComputedStyle(element).transform,
),
)
.toBe("none");
const [moreBox, moreAnchorBox] = await Promise.all([
morePanel.boundingBox(),
actionBar.boundingBox(),
]);
expect(moreBox).not.toBeNull();
expect(moreAnchorBox).not.toBeNull();
if (moreBox && moreAnchorBox) {
expect(
Math.abs(
moreBox.x + moreBox.width - (moreAnchorBox.x + moreAnchorBox.width),
),
).toBeLessThanOrEqual(10);
}
await page.keyboard.press("Escape");
await expect(actionBar).toHaveAttribute("data-bloom-surface", "toolbar");
@@ -166,6 +210,15 @@ test("message action sends a message link and optional note to Bestie", async ({
popover.evaluate((element) => window.getComputedStyle(element).transform),
)
.toBe("none");
await expect
.poll(() =>
popover.evaluate((element) =>
Number.parseFloat(
window.getComputedStyle(element.firstElementChild as Element).opacity,
),
),
)
.toBeGreaterThan(0.99);
await expect(popover).toContainText("Bestie");
await expect(popover).not.toContainText("Share this message with Bestie");
await expect(popover).toContainText("Please fold this launch decision");
@@ -184,6 +237,17 @@ test("message action sends a message link and optional note to Bestie", async ({
expect(snapshotBodyBox).not.toBeNull();
if (popoverBox && snapshotBox && snapshotBodyBox) {
expect(popoverBox.width).toBeLessThanOrEqual(328);
const actionBarBox = await actionBar.boundingBox();
expect(actionBarBox).not.toBeNull();
if (actionBarBox) {
expect(
Math.abs(
popoverBox.x +
popoverBox.width -
(actionBarBox.x + actionBarBox.width),
),
).toBeLessThanOrEqual(10);
}
expect(snapshotBox.width / (popoverBox.width - 32)).toBeCloseTo(0.75, 1);
expect(Math.abs(snapshotBox.x - (popoverBox.x + 16))).toBeLessThanOrEqual(
1,