From 7379f48a9a4edc072ddec045ae2557d842dd2068 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Wed, 26 Aug 2026 10:36:18 -0400 Subject: [PATCH] fix(desktop): stabilize message action morph Co-authored-by: Codex Signed-off-by: Arjun Mahanti --- .../messages/ui/BestieMessagePopover.tsx | 12 +++- .../features/messages/ui/MessageActionBar.tsx | 13 +++- .../messages/ui/MessageActionBloomSurface.tsx | 32 +++++++--- desktop/tests/e2e/bestie.spec.ts | 64 +++++++++++++++++++ 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/messages/ui/BestieMessagePopover.tsx b/desktop/src/features/messages/ui/BestieMessagePopover.tsx index ba8b673f12..6327992fb4 100644 --- a/desktop/src/features/messages/ui/BestieMessagePopover.tsx +++ b/desktop/src/features/messages/ui/BestieMessagePopover.tsx @@ -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; channelId: string; message: TimelineMessage; onOpenChange: (open: boolean) => void; @@ -85,6 +92,7 @@ export function BestieMessagePopover({ return ( + @@ -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} > (null); + const toolbarAnchorRef = React.useRef(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} > + @@ -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} > diff --git a/desktop/src/features/messages/ui/MessageActionBloomSurface.tsx b/desktop/src/features/messages/ui/MessageActionBloomSurface.tsx index abc75ab29b..64c2a7c55c 100644 --- a/desktop/src/features/messages/ui/MessageActionBloomSurface.tsx +++ b/desktop/src/features/messages/ui/MessageActionBloomSurface.tsx @@ -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 ( setContentReady(true)} ref={ref} style={{ ...style, borderRadius: surfaceRadius }} transition={ reduceMotion ? { duration: 0 } : { - layout: BLOOM_LAYOUT_SPRING, + layout: BLOOM_LAYOUT_TRANSITION, } } {...props} > {revealContent ? ( + 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,