fix(ui): DmMemberRow anchors profile popout to row bounds (matches MemberSidebar)

This commit is contained in:
Jannis Braun
2026-05-10 20:18:05 +02:00
parent c21bc8078b
commit b34caf5f2d
3 changed files with 122 additions and 4 deletions
@@ -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<Parameters<typeof DmMemberRow>[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 }));
});
});
@@ -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<HTMLDivElement>(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 (
<div
ref={rowRef}
data-context-menu
data-dm-member-row
data-user-id={canonical.id}
@@ -89,6 +89,10 @@ export function DmRosterPanel() {
const handleMenuAction = async (action: DmMemberRowAction, member: User) => {
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;
}