diff --git a/packages/web/src/components/modals/ExploreSpacePreviewCard.test.tsx b/packages/web/src/components/modals/ExploreSpacePreviewCard.test.tsx new file mode 100644 index 00000000..4f05a56d --- /dev/null +++ b/packages/web/src/components/modals/ExploreSpacePreviewCard.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom. +// Reached transitively via exploreStore → spaceStore → chatStore → useWebSocket → voiceStore. +vi.mock('../../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +import { ExploreSpacePreviewCard } from './ExploreSpacePreviewCard'; +import { useExploreStore, type TaggedExploreSpace } from '../../stores/exploreStore'; + +function makeSpace(overrides: Partial = {}): TaggedExploreSpace { + return { + id: 's1', name: 'Design Guild', icon: null, banner: null, avatarColor: null, + description: null, visibility: 'public', memberCount: 5, createdAt: 0, + joined: false, _instanceOrigin: '', ...overrides, + }; +} + +beforeEach(() => { + useExploreStore.setState({ + myRequests: [], + publicJoin: vi.fn().mockResolvedValue({ id: 's1', name: 'Design Guild' }), + requestJoin: vi.fn().mockResolvedValue({ id: 'r1', spaceId: 's1', status: 'pending' }), + }); +}); + +describe('ExploreSpacePreviewCard', () => { + it('renders name, member count and a Join button for public spaces', () => { + render(); + expect(screen.getByText('Design Guild')).toBeInTheDocument(); + expect(screen.getByText(/5 members/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /join/i })).toBeInTheDocument(); + }); + + it('joins a public space and reports success', async () => { + const user = userEvent.setup(); + const onJoinSuccess = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: /join/i })); + await waitFor(() => expect(onJoinSuccess).toHaveBeenCalledWith('s1')); + }); + + it('shows Request for request-visibility spaces', () => { + render(); + expect(screen.getByRole('button', { name: /request/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/modals/ExploreSpacePreviewCard.tsx b/packages/web/src/components/modals/ExploreSpacePreviewCard.tsx new file mode 100644 index 00000000..4422a216 --- /dev/null +++ b/packages/web/src/components/modals/ExploreSpacePreviewCard.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { useSpaceJoin } from '../../hooks/useSpaceJoin'; +import { getSpaceGradient } from '../../utils/gradients'; +import type { TaggedExploreSpace } from '../../stores/exploreStore'; + +export function ExploreSpacePreviewCard({ + space, + onJoinSuccess, +}: { + space: TaggedExploreSpace; + onJoinSuccess: (spaceId: string) => void; +}) { + const { + isPublic, + isPending, + joining, + joinError, + showRequestForm, + requestMessage, + setRequestMessage, + openRequestForm, + cancelRequestForm, + join, + sendRequest, + } = useSpaceJoin(space); + + const [expanded, setExpanded] = useState(false); + + const fallbackGradient = getSpaceGradient(space.id, space.name, space.avatarColor).gradient; + const iconUrl = space.icon + ? (space.icon.startsWith('http') || space.icon.startsWith('/') ? space.icon : `/api/uploads/${space.icon}`) + : null; + const originLabel = space._instanceOrigin + ? (() => { try { return new URL(space._instanceOrigin).host; } catch { return space._instanceOrigin; } })() + : null; + + const handleJoin = async () => { + const full = await join(); + if (full) onJoinSuccess(full.id); + }; + + return ( +
+
+ {/* Icon */} + {iconUrl ? ( + {space.name} + ) : ( +
+ {space.name.charAt(0).toUpperCase()} +
+ )} + + {/* Name + meta */} +
+
{space.name}
+
+ {space.memberCount} {space.memberCount === 1 ? 'member' : 'members'} + {originLabel && · {originLabel}} +
+
+ + {/* Action */} +
+ {isPublic ? ( + + ) : isPending ? ( + Pending + ) : ( + + )} +
+
+ + {/* Inline request form */} + {showRequestForm && expanded && ( +
+