diff --git a/packages/web/src/components/layout/DmMemberRow.test.tsx b/packages/web/src/components/layout/DmMemberRow.test.tsx index 1a0d12a5..c449dd8c 100644 --- a/packages/web/src/components/layout/DmMemberRow.test.tsx +++ b/packages/web/src/components/layout/DmMemberRow.test.tsx @@ -15,9 +15,20 @@ vi.mock('../../audio/AudioManager', () => ({ // Force desktop rendering in ContextMenuRenderer (mobile path triggers // document-level long-press listeners we don't need here). +// DmMemberRow now anchors the profile popout itself via +// `useUIStore.getState().openUserProfile(...)`, so we expose a real `getState` +// hook that the test below asserts against. +const openUserProfileMock = vi.fn(); +const uiStoreState = { isMobile: false, openUserProfile: openUserProfileMock }; vi.mock('../../stores/uiStore', () => ({ - useUIStore: (selector: (s: { isMobile: boolean; openUserProfile: () => void }) => unknown) => - selector({ isMobile: false, openUserProfile: () => {} }), + useUIStore: Object.assign( + (selector: (s: typeof uiStoreState) => unknown) => selector(uiStoreState), + { + getState: () => uiStoreState, + setState: vi.fn(), + subscribe: vi.fn(), + }, + ), })); import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow'; @@ -50,6 +61,7 @@ beforeEach(() => { act(() => { useContextMenuStore.getState().close(); }); + openUserProfileMock.mockClear(); }); function renderRow(props: Partial[0]> = {}) { @@ -225,3 +237,87 @@ describe('DmMemberRow — onMenuAction', () => { expect(onMenuAction).toHaveBeenCalledWith('transfer', expect.objectContaining({ id: member.id })); }); }); + +describe('DmMemberRow — profile popout anchoring', () => { + it('anchors openUserProfile to the row bounding rect (matches MemberSidebar pattern)', async () => { + const user = userEvent.setup(); + const { container, onMenuAction, member } = renderRow(); + const row = container.querySelector('[data-dm-member-row]') as HTMLElement; + + // Stub the row's getBoundingClientRect so we get a stable anchor target. + const rect: DOMRect = { + top: 200, + left: 1500, + right: 1740, + bottom: 240, + width: 240, + height: 40, + x: 1500, + y: 200, + toJSON: () => ({}), + } as DOMRect; + row.getBoundingClientRect = () => rect; + + // jsdom defaults innerHeight to 768; make sure that matters for the math. + Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }); + + openMenuByContextMenu(row); + const profileBtn = await screen.findByText('View Profile'); + await user.click(profileBtn); + + expect(openUserProfileMock).toHaveBeenCalledTimes(1); + expect(openUserProfileMock).toHaveBeenCalledWith( + expect.objectContaining({ id: member.id }), + // Math.min(200, 800 - 450) = 200; left = 1500 - 316 = 1184. + { top: 200, left: 1184 }, + ); + // The row no longer routes 'profile' through onMenuAction. + expect(onMenuAction).not.toHaveBeenCalled(); + }); + + it('clamps top to (innerHeight - 450) when the row sits near the bottom of the viewport', async () => { + const user = userEvent.setup(); + const { container } = renderRow(); + const row = container.querySelector('[data-dm-member-row]') as HTMLElement; + + const rect: DOMRect = { + top: 700, + left: 1500, + right: 1740, + bottom: 740, + width: 240, + height: 40, + x: 1500, + y: 700, + toJSON: () => ({}), + } as DOMRect; + row.getBoundingClientRect = () => rect; + + Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }); + + openMenuByContextMenu(row); + await user.click(await screen.findByText('View Profile')); + + // Math.min(700, 800 - 450 = 350) → top clamped to 350. + expect(openUserProfileMock).toHaveBeenCalledWith( + expect.anything(), + { top: 350, left: 1184 }, + ); + }); + + it('falls back to onMenuAction("profile", ...) when the row has no bounding rect', async () => { + const user = userEvent.setup(); + const { container, onMenuAction, member } = renderRow(); + const row = container.querySelector('[data-dm-member-row]') as HTMLElement; + + // Simulate a missing rect — defensive fallback path. + row.getBoundingClientRect = () => null as unknown as DOMRect; + + openMenuByContextMenu(row); + await user.click(await screen.findByText('View Profile')); + + expect(openUserProfileMock).not.toHaveBeenCalled(); + expect(onMenuAction).toHaveBeenCalledTimes(1); + expect(onMenuAction).toHaveBeenCalledWith('profile', expect.objectContaining({ id: member.id })); + }); +}); diff --git a/packages/web/src/components/layout/DmMemberRow.tsx b/packages/web/src/components/layout/DmMemberRow.tsx index e2e0370d..5bf02b70 100644 --- a/packages/web/src/components/layout/DmMemberRow.tsx +++ b/packages/web/src/components/layout/DmMemberRow.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import type { User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; @@ -9,6 +9,7 @@ import { useContextMenuStore, type ContextMenuItem, } from '../../stores/contextMenuStore'; +import { useUIStore } from '../../stores/uiStore'; export type DmMemberRowAction = 'profile' | 'transfer' | 'kick' | 'remove-friend'; @@ -77,6 +78,7 @@ export function DmMemberRow({ }: DmMemberRowProps) { const canonical = useCanonicalUserView(member); const openContextMenu = useContextMenuStore((s) => s.open); + const rowRef = useRef(null); const { baseName, domain } = parseFederatedUsername(canonical.username); const displayName = canonical.displayName ?? baseName; @@ -90,7 +92,22 @@ export function DmMemberRow({ key: 'profile', type: 'action', label: 'View Profile', - onClick: () => onMenuAction('profile', canonical), + onClick: () => { + // Anchor the popout to this row's bounding rect — matches the + // MemberSidebar pattern (see MemberSidebar.tsx:158-165). On mobile + // the position arg is ignored by the store (full-screen push). + const rect = rowRef.current?.getBoundingClientRect(); + if (rect) { + useUIStore.getState().openUserProfile(canonical, { + top: Math.min(rect.top, window.innerHeight - 450), + left: rect.left - 316, + }); + } else { + // Fallback: defer to the consumer if we can't compute a rect + // (shouldn't happen in practice, but keeps the contract intact). + onMenuAction('profile', canonical); + } + }, }); const showTransfer = callerIsOwner && !isSelf; @@ -148,6 +165,7 @@ export function DmMemberRow({ return (
{ if (action === 'profile') { + // Profile popout is anchored by DmMemberRow itself (matches the + // MemberSidebar pattern). This branch only fires on the unlikely + // fallback path where the row couldn't compute its bounding rect — + // in that case, anchor to the top-left of the roster column. openUserProfile(member, { top: 100, left: 100 }); return; }