From f01c5aba8c8dadde219dc5c772478d1059aa4cf4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 10 May 2026 17:49:54 +0200 Subject: [PATCH] feat(ui): reusable AvatarStack with +N badge and single-member group affordance --- .../src/components/ui/AvatarStack.test.tsx | 154 +++++++++++ .../web/src/components/ui/AvatarStack.tsx | 259 ++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 packages/web/src/components/ui/AvatarStack.test.tsx create mode 100644 packages/web/src/components/ui/AvatarStack.tsx diff --git a/packages/web/src/components/ui/AvatarStack.test.tsx b/packages/web/src/components/ui/AvatarStack.test.tsx new file mode 100644 index 00000000..aa50815c --- /dev/null +++ b/packages/web/src/components/ui/AvatarStack.test.tsx @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import type { User } from '@backspace/shared'; + +// AvatarStack pulls in `useCanonicalUserView` → spaceStore, which transitively +// imports AudioManager. Stub it out so jsdom doesn't choke on AudioWorkletNode. +vi.mock('../../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +import { AvatarStack } from './AvatarStack'; + +function makeUser(n: number): User { + return { + id: `u-${n}`, + username: `user${n}`, + displayName: `User ${n}`, + avatar: null, + banner: null, + accentColor: null, + avatarColor: null, + bio: null, + status: 'offline', + customStatus: null, + isAdmin: false, + createdAt: 0, + homeInstance: null, + homeUserId: null, + replicatedInstances: [], + }; +} + +function makeUsers(count: number): User[] { + return Array.from({ length: count }, (_, i) => makeUser(i + 1)); +} + +describe('AvatarStack', () => { + it('renders empty placeholder + group badge for an empty group', () => { + const { container } = render( + + ); + // Placeholder slot present + expect(container.querySelector('[data-avatar-stack-placeholder]')).toBeTruthy(); + // Group badge present + expect(container.querySelector('[data-group-badge]')).toBeTruthy(); + // No avatar tiles rendered + expect(container.querySelectorAll('[data-avatar-stack-tile]').length).toBe(0); + // No +N tile + expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy(); + }); + + it('renders centered avatar + 12x12 group badge for a single member', () => { + const { container } = render( + + ); + const tiles = container.querySelectorAll('[data-avatar-stack-tile]'); + expect(tiles.length).toBe(1); + const badge = container.querySelector('[data-group-badge]') as HTMLElement | null; + expect(badge).toBeTruthy(); + expect(badge!.getAttribute('data-group-badge')).toBe('true'); + // Badge is sized 12x12 + const badgeStyle = badge!.style; + expect(badgeStyle.width).toBe('12px'); + expect(badgeStyle.height).toBe('12px'); + // No +N + expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy(); + }); + + it('renders two avatars in offset overlap for two members', () => { + const { container } = render( + + ); + const tiles = container.querySelectorAll('[data-avatar-stack-tile]'); + expect(tiles.length).toBe(2); + // No group badge in multi-member case + expect(container.querySelector('[data-group-badge]')).toBeFalsy(); + // Layout marker for overlap + expect(container.querySelector('[data-avatar-stack-layout="overlap"]')).toBeTruthy(); + // No +N + expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy(); + }); + + it('renders 2x2 grid with three tiles and no +N for three members', () => { + const { container } = render( + + ); + const tiles = container.querySelectorAll('[data-avatar-stack-tile]'); + expect(tiles.length).toBe(3); + expect(container.querySelector('[data-avatar-stack-layout="grid"]')).toBeTruthy(); + expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy(); + }); + + it('renders three tiles + "+2" overflow for five members', () => { + const { container } = render( + + ); + const tiles = container.querySelectorAll('[data-avatar-stack-tile]'); + expect(tiles.length).toBe(3); + const overflow = container.querySelector('[data-avatar-stack-overflow]'); + expect(overflow).toBeTruthy(); + expect(overflow!.textContent).toBe('+2'); + expect(container.querySelector('[data-avatar-stack-layout="grid"]')).toBeTruthy(); + }); + + it('renders three tiles + "+7" overflow for ten members (cap)', () => { + const { container } = render( + + ); + const tiles = container.querySelectorAll('[data-avatar-stack-tile]'); + expect(tiles.length).toBe(3); + const overflow = container.querySelector('[data-avatar-stack-overflow]'); + expect(overflow).toBeTruthy(); + expect(overflow!.textContent).toBe('+7'); + }); + + it('renders icon override and ignores the stack', () => { + const { container } = render( + + ); + // Single img filling the box + const imgs = container.querySelectorAll('img'); + expect(imgs.length).toBe(1); + const img = imgs[0] as HTMLImageElement; + // Bare filename → /api/uploads/ prefix + expect(img.getAttribute('src')).toBe('/api/uploads/abc.png'); + // No tiles, no badge, no overflow + expect(container.querySelectorAll('[data-avatar-stack-tile]').length).toBe(0); + expect(container.querySelector('[data-group-badge]')).toBeFalsy(); + expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy(); + }); + + it('uses absolute icon URL as-is for icon override', () => { + const { container } = render( + + ); + const img = container.querySelector('img') as HTMLImageElement; + expect(img.getAttribute('src')).toBe('https://example.com/icon.png'); + }); +}); diff --git a/packages/web/src/components/ui/AvatarStack.tsx b/packages/web/src/components/ui/AvatarStack.tsx new file mode 100644 index 00000000..0ed91f48 --- /dev/null +++ b/packages/web/src/components/ui/AvatarStack.tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import type { User } from '@backspace/shared'; +import { Avatar } from './Avatar'; +import { useCanonicalUserView } from '../../utils/userViewLookup'; +import { parseFederatedUsername } from '../../utils/identity'; + +/** + * Reusable avatar-stack widget for group-DM identity slots. + * + * Renders one of five layouts depending on `members.length`, plus an + * optional `iconUrl` override that bypasses the stack entirely: + * + * - iconUrl set → single `` filling the box + * - 0 members → empty placeholder + group badge + * - 1 member → centered avatar + group badge (12×12, bottom-right) + * - 2 members → equal-size offset overlap + * - 3 members → 2×2 grid, three tiles + * - 4+ members → 2×2 grid, three tiles + `+N` overflow tile + * + * Status dots are never rendered — group identity wins regardless of + * member count. + * + * Hooks-in-loop safety: each rendered slot is its own `` + * component so `useCanonicalUserView` is called once per slot, not inside + * a variable-length `.map()`. + */ +export interface AvatarStackProps { + /** "Other" members already filtered to exclude self when applicable. */ + members: User[]; + /** Outer box edge length in px. Common: 24, 32, 40, 56, 80. */ + size: number; + /** Which surface tier this stack sits on; controls border color. */ + border: 'channel' | 'chat' | 'modal'; + /** When set, renders the icon and ignores the stack. Bare filename or absolute URL. */ + iconUrl?: string | null; +} + +const BORDER_CLASS: Record = { + channel: 'border-surface-channel', + chat: 'border-surface-chat', + // No `surface-modal` token in tailwind.config.js — fall back to elevated per plan note. + modal: 'border-surface-elevated', +}; + +/** Resolves a bare filename to /api/uploads/, leaves absolute URLs alone. */ +function resolveIconSrc(iconUrl: string): string { + if (iconUrl.startsWith('http') || iconUrl.startsWith('blob:') || iconUrl.startsWith('data:') || iconUrl.startsWith('/')) { + return iconUrl; + } + return `/api/uploads/${iconUrl}`; +} + +/** Two-figure people SVG used as the group badge for 0/1-member groups. */ +function GroupBadgeIcon({ size }: { size: number }) { + return ( + + ); +} + +/** + * Single avatar slot. Extracted as a component so `useCanonicalUserView` + * is called exactly once per slot (hooks rules: no hooks inside .map()). + */ +function AvatarTile({ + member, + size, + borderClass, + className = '', + style, +}: { + member: User; + size: number; + borderClass: string; + className?: string; + style?: React.CSSProperties; +}) { + const canonical = useCanonicalUserView(member); + const displayName = canonical.displayName ?? parseFederatedUsername(canonical.username).baseName; + return ( +
+ +
+ ); +} + +export function AvatarStack({ members, size, border, iconUrl }: AvatarStackProps) { + const borderClass = BORDER_CLASS[border]; + + // Icon override — bypass the stack entirely. + if (iconUrl) { + return ( +
+ +
+ ); + } + + // Group-badge sizing: fixed 12×12 per spec. + const badgeBoxSize = 12; + const badgeIconSize = 8; + const badgeOffset = -2; + + // ─── Empty group ──────────────────────────────────────────────────────── + if (members.length === 0) { + return ( +
+
+
+ +
+
+ ); + } + + // ─── Single member: centered avatar + group badge ─────────────────────── + if (members.length === 1) { + return ( +
+ +
+ +
+
+ ); + } + + // ─── Two members: equal-size offset overlap ───────────────────────────── + if (members.length === 2) { + const tileSize = Math.round(size * 0.7); + const offset = Math.round(size * 0.3); + return ( +
+ {members.map((m, i) => ( + + ))} +
+ ); + } + + // ─── 3+ members: 2×2 grid (three avatar tiles, fourth slot empty or +N) ─ + const tileSize = Math.round((size - 2) / 2); // half the box, with a 2px gutter + const tileGap = size - tileSize * 2; + // Four grid positions: top-left, top-right, bottom-left, bottom-right. + const positions = [ + { left: 0, top: 0 }, + { left: tileSize + tileGap, top: 0 }, + { left: 0, top: tileSize + tileGap }, + { left: tileSize + tileGap, top: tileSize + tileGap }, + ]; + + const visibleMembers = members.slice(0, 3); + const overflow = members.length > 3 ? members.length - 3 : 0; + const overflowFontSize = Math.max(9, Math.round(tileSize * 0.45)); + + return ( +
+ {visibleMembers.map((m, i) => ( + + ))} + {overflow > 0 && ( +
+ {`+${members.length - 3}`} +
+ )} +
+ ); +}