feat(ui): shared DmMemberRow component (used by roster panel, settings modal, mobile info)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user