feat(ui): shared DmMemberRow component (used by roster panel, settings modal, mobile info)

This commit is contained in:
Jannis Braun
2026-05-10 19:59:18 +02:00
parent c0151996c4
commit 68c4133abd
2 changed files with 448 additions and 0 deletions
@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { User } from '@backspace/shared';
// Stub AudioManager — pulled in transitively via spaceStore from useCanonicalUserView.
vi.mock('../../audio/AudioManager', () => ({
AudioManager: {
getInstance: vi.fn().mockReturnValue({
setOutputDevice: vi.fn(),
setVolume: vi.fn(),
}),
},
}));
// Force desktop rendering in ContextMenuRenderer (mobile path triggers
// document-level long-press listeners we don't need here).
vi.mock('../../stores/uiStore', () => ({
useUIStore: (selector: (s: { isMobile: boolean; openUserProfile: () => void }) => unknown) =>
selector({ isMobile: false, openUserProfile: () => {} }),
}));
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
import { ContextMenuRenderer } from '../ui/ContextMenuRenderer';
import { useContextMenuStore } from '../../stores/contextMenuStore';
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'u-1',
username: 'alice',
displayName: 'Alice',
avatar: null,
banner: null,
accentColor: null,
avatarColor: null,
bio: null,
status: 'online',
customStatus: null,
isAdmin: false,
createdAt: 0,
homeInstance: null,
homeUserId: null,
replicatedInstances: [],
...overrides,
};
}
beforeEach(() => {
// Make sure each test starts with no menu open.
act(() => {
useContextMenuStore.getState().close();
});
});
function renderRow(props: Partial<Parameters<typeof DmMemberRow>[0]> = {}) {
const onMenuAction = vi.fn<(action: DmMemberRowAction, member: User) => void>();
const member = props.member ?? makeUser();
const utils = render(
<>
<DmMemberRow
member={member}
isOwner={props.isOwner ?? false}
isSelf={props.isSelf ?? false}
callerIsOwner={props.callerIsOwner ?? false}
isFriend={props.isFriend ?? false}
showKebab={props.showKebab ?? false}
onMenuAction={props.onMenuAction ?? onMenuAction}
/>
<ContextMenuRenderer />
</>,
);
return { ...utils, onMenuAction, member };
}
function openMenuByContextMenu(target: HTMLElement) {
fireEvent.contextMenu(target, { clientX: 100, clientY: 100 });
}
function getMenuLabels(): string[] {
// Menu items render as <button> elements inside the portal.
// Use the action-button class fingerprint via role lookup.
const buttons = document.querySelectorAll<HTMLButtonElement>('div.fixed.z-\\[200\\] button');
return Array.from(buttons).map((b) => b.textContent?.trim() ?? '');
}
describe('DmMemberRow — menu visibility', () => {
it('owner caller, friend non-self → Profile + Transfer + Remove from Group + (sep) Remove Friend', () => {
const { container } = renderRow({
callerIsOwner: true,
isSelf: false,
isFriend: true,
});
openMenuByContextMenu(container.querySelector('[data-dm-member-row]')!);
const labels = getMenuLabels();
expect(labels).toEqual([
'View Profile',
'Transfer Ownership',
'Remove from Group',
'Remove Friend',
]);
// Separator should be present between kick and remove-friend.
const separators = document.querySelectorAll('div.fixed.z-\\[200\\] > div.h-px');
expect(separators.length).toBe(1);
});
it('owner caller, viewing self → Profile only', () => {
const { container } = renderRow({
callerIsOwner: true,
isSelf: true,
isFriend: true,
});
openMenuByContextMenu(container.querySelector('[data-dm-member-row]')!);
expect(getMenuLabels()).toEqual(['View Profile']);
});
it('owner caller, non-friend non-self → Profile + Transfer + Remove from Group (no separator, no Remove Friend)', () => {
const { container } = renderRow({
callerIsOwner: true,
isSelf: false,
isFriend: false,
});
openMenuByContextMenu(container.querySelector('[data-dm-member-row]')!);
expect(getMenuLabels()).toEqual([
'View Profile',
'Transfer Ownership',
'Remove from Group',
]);
const separators = document.querySelectorAll('div.fixed.z-\\[200\\] > div.h-px');
expect(separators.length).toBe(0);
});
it('non-owner caller, friend non-self → Profile + Remove Friend (no Transfer, no Kick, no separator)', () => {
const { container } = renderRow({
callerIsOwner: false,
isSelf: false,
isFriend: true,
});
openMenuByContextMenu(container.querySelector('[data-dm-member-row]')!);
expect(getMenuLabels()).toEqual(['View Profile', 'Remove Friend']);
const separators = document.querySelectorAll('div.fixed.z-\\[200\\] > div.h-px');
expect(separators.length).toBe(0);
});
it('non-owner caller, non-friend non-self → Profile only', () => {
const { container } = renderRow({
callerIsOwner: false,
isSelf: false,
isFriend: false,
});
openMenuByContextMenu(container.querySelector('[data-dm-member-row]')!);
expect(getMenuLabels()).toEqual(['View Profile']);
});
});
describe('DmMemberRow — visual markers', () => {
it('renders the federation globe + @domain subtitle for federated members', () => {
const federated = makeUser({
id: 'u-fed',
username: 'bob@orbit.example',
homeInstance: 'orbit.example',
});
const { container } = renderRow({ member: federated });
expect(container.querySelector('[data-federation-globe]')).toBeTruthy();
// Subtitle "@orbit.example"
expect(container.textContent).toContain('@orbit.example');
});
it('does NOT render the globe for native members', () => {
const { container } = renderRow({ member: makeUser() });
expect(container.querySelector('[data-federation-globe]')).toBeFalsy();
});
it('renders the owner crown when isOwner=true', () => {
const { container } = renderRow({ isOwner: true });
expect(container.querySelector('[data-owner-crown]')).toBeTruthy();
});
it('does NOT render the crown when isOwner=false', () => {
const { container } = renderRow({ isOwner: false });
expect(container.querySelector('[data-owner-crown]')).toBeFalsy();
});
});
describe('DmMemberRow — kebab', () => {
it('hides the kebab when showKebab=false (default)', () => {
const { container } = renderRow();
expect(container.querySelector('[data-dm-member-kebab]')).toBeFalsy();
});
it('shows the kebab when showKebab=true and clicking it opens the same menu', async () => {
const user = userEvent.setup();
const { container } = renderRow({
showKebab: true,
callerIsOwner: true,
isFriend: true,
});
const kebab = container.querySelector<HTMLButtonElement>('[data-dm-member-kebab]');
expect(kebab).toBeTruthy();
await user.click(kebab!);
expect(getMenuLabels()).toEqual([
'View Profile',
'Transfer Ownership',
'Remove from Group',
'Remove Friend',
]);
});
});
describe('DmMemberRow — onMenuAction', () => {
it('invokes onMenuAction with the correct action key when an item is clicked', async () => {
const user = userEvent.setup();
const { container, onMenuAction, member } = renderRow({
callerIsOwner: true,
isSelf: false,
isFriend: true,
});
openMenuByContextMenu(container.querySelector('[data-dm-member-row]')!);
const transferBtn = await screen.findByText('Transfer Ownership');
await user.click(transferBtn);
expect(onMenuAction).toHaveBeenCalledTimes(1);
expect(onMenuAction).toHaveBeenCalledWith('transfer', expect.objectContaining({ id: member.id }));
});
});
@@ -0,0 +1,221 @@
import React from 'react';
import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
import { Tooltip } from '../ui/Tooltip';
import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import {
useContextMenuStore,
type ContextMenuItem,
} from '../../stores/contextMenuStore';
export type DmMemberRowAction = 'profile' | 'transfer' | 'kick' | 'remove-friend';
export interface DmMemberRowProps {
member: User;
/** True when this row represents the channel owner. */
isOwner: boolean;
/** True when this row is the viewer themselves. */
isSelf: boolean;
/** True when the viewer is the channel owner (controls transfer/kick visibility). */
callerIsOwner: boolean;
/** True when the viewer and this member are friends (controls Remove Friend visibility). */
isFriend: boolean;
/** Whether to render the kebab "⋮" trigger button (right side of the row). */
showKebab?: boolean;
/** Fired when the viewer activates a menu entry. The component closes the menu itself. */
onMenuAction: (action: DmMemberRowAction, member: User) => void;
}
// ── Inline icons ─────────────────────────────────────────────────────────────
function GlobeIcon({ size = 12 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/80 flex-shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
);
}
function CrownIcon() {
// Simple inline crown — matches the warm-amber accent used elsewhere for owner emphasis.
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
className="text-accent-amber flex-shrink-0"
aria-hidden="true"
>
<path d="M5 16l-2-9 5 4 4-7 4 7 5-4-2 9H5zm0 2h14v2H5v-2z" />
</svg>
);
}
function KebabIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="5" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="12" cy="19" r="1.6" />
</svg>
);
}
// ── Component ────────────────────────────────────────────────────────────────
export function DmMemberRow({
member,
isOwner,
isSelf,
callerIsOwner,
isFriend,
showKebab = false,
onMenuAction,
}: DmMemberRowProps) {
const canonical = useCanonicalUserView(member);
const openContextMenu = useContextMenuStore((s) => s.open);
const { baseName, domain } = parseFederatedUsername(canonical.username);
const displayName = canonical.displayName ?? baseName;
const isOffline = canonical.status === 'offline';
const showGlobe = isFederationGlobeApplicable(canonical);
const buildMenuItems = (): ContextMenuItem[] => {
const items: ContextMenuItem[] = [];
items.push({
key: 'profile',
type: 'action',
label: 'View Profile',
onClick: () => onMenuAction('profile', canonical),
});
const showTransfer = callerIsOwner && !isSelf;
const showKick = callerIsOwner && !isSelf;
const showRemoveFriend = isFriend && !isSelf;
if (showTransfer) {
items.push({
key: 'transfer',
type: 'action',
label: 'Transfer Ownership',
onClick: () => onMenuAction('transfer', canonical),
});
}
if (showKick) {
items.push({
key: 'kick',
type: 'action',
label: 'Remove from Group',
danger: true,
onClick: () => onMenuAction('kick', canonical),
});
}
if ((showTransfer || showKick) && showRemoveFriend) {
items.push({ key: 'sep', type: 'separator' });
}
if (showRemoveFriend) {
items.push({
key: 'remove-friend',
type: 'action',
label: 'Remove Friend',
danger: true,
onClick: () => onMenuAction('remove-friend', canonical),
});
}
return items;
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
openContextMenu({ x: e.clientX, y: e.clientY }, buildMenuItems());
};
const handleKebabClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
openContextMenu({ x: rect.right, y: rect.bottom + 4 }, buildMenuItems());
};
return (
<div
data-context-menu
data-dm-member-row
data-user-id={canonical.id}
onContextMenu={handleContextMenu}
className="group flex items-center gap-2.5 px-2 py-1.5 rounded-[6px] hover:bg-interactive-hover transition-colors select-none"
>
<div className="flex-shrink-0">
<Avatar
src={canonical.avatar}
name={displayName}
size={32}
status={isOffline ? null : canonical.status}
user={canonical}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<Username
username={displayName}
className={`text-[13.5px] leading-[1.2] font-medium truncate ${
isOffline ? 'text-txt-tertiary' : 'text-txt-primary'
}`}
/>
{showGlobe && (
<Tooltip content={canonical.username} position="top">
<span data-federation-globe className="inline-flex">
<GlobeIcon />
</span>
</Tooltip>
)}
{isOwner && (
<Tooltip content="Group Owner" position="top">
<span data-owner-crown className="inline-flex">
<CrownIcon />
</span>
</Tooltip>
)}
</div>
{showGlobe && domain && (
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">
@{domain}
</div>
)}
{!isOffline && canonical.customStatus && (
<div className="text-[11px] leading-[1.3] text-txt-tertiary truncate">
{canonical.customStatus}
</div>
)}
</div>
{showKebab && (
<button
type="button"
aria-label="Member actions"
data-dm-member-kebab
onClick={handleKebabClick}
onContextMenu={(e) => {
// Right-click on the kebab still opens the same menu at cursor.
handleContextMenu(e);
}}
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-[4px] text-txt-tertiary hover:text-txt-primary hover:bg-interactive-active opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
>
<KebabIcon />
</button>
)}
</div>
);
}