diff --git a/packages/web/src/hooks/useSpaceJoin.test.ts b/packages/web/src/hooks/useSpaceJoin.test.ts new file mode 100644 index 00000000..9b4eed1a --- /dev/null +++ b/packages/web/src/hooks/useSpaceJoin.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom. The +// hook imports exploreStore, which transitively pulls in spaceStore -> +// AudioManager; the audio worklet is unavailable under jsdom. +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +import { useSpaceJoin } from './useSpaceJoin'; +import { useExploreStore, type TaggedExploreSpace } from '../stores/exploreStore'; + +function makeSpace(overrides: Partial = {}): TaggedExploreSpace { + return { + id: 's1', + name: 'Test Space', + icon: null, + banner: null, + avatarColor: null, + description: null, + visibility: 'public', + memberCount: 3, + createdAt: 0, + joined: false, + _instanceOrigin: '', + ...overrides, + }; +} + +beforeEach(() => { + useExploreStore.setState({ + myRequests: [], + publicJoin: vi.fn().mockResolvedValue({ id: 's1', name: 'Test Space' }), + requestJoin: vi.fn().mockResolvedValue({ id: 'req1', spaceId: 's1', status: 'pending' }), + }); +}); + +describe('useSpaceJoin', () => { + it('reports public/joined/pending flags from the space and store', () => { + const { result } = renderHook(() => useSpaceJoin(makeSpace())); + expect(result.current.isPublic).toBe(true); + expect(result.current.isJoined).toBe(false); + expect(result.current.isPending).toBe(false); + }); + + it('derives isPending from a matching pending request in the store', () => { + useExploreStore.setState({ + myRequests: [{ id: 'r', spaceId: 's1', status: 'pending' } as never], + }); + const { result } = renderHook(() => useSpaceJoin(makeSpace({ visibility: 'request' }))); + expect(result.current.isPending).toBe(true); + }); + + it('join() calls publicJoin and returns the full space', async () => { + const { result } = renderHook(() => useSpaceJoin(makeSpace())); + let full: unknown; + await act(async () => { full = await result.current.join(); }); + expect(useExploreStore.getState().publicJoin).toHaveBeenCalled(); + expect((full as { id: string }).id).toBe('s1'); + }); + + it('join() surfaces an error and returns null on failure', async () => { + useExploreStore.setState({ publicJoin: vi.fn().mockRejectedValue(new Error('nope')) }); + const { result } = renderHook(() => useSpaceJoin(makeSpace())); + let full: unknown = 'unset'; + await act(async () => { full = await result.current.join(); }); + expect(full).toBeNull(); + expect(result.current.joinError).toBe('nope'); + expect(result.current.joining).toBe(false); + }); + + it('sendRequest() calls requestJoin and flips to pending', async () => { + const { result } = renderHook(() => useSpaceJoin(makeSpace({ visibility: 'request' }))); + act(() => result.current.setRequestMessage(' please ')); + await act(async () => { await result.current.sendRequest(); }); + expect(useExploreStore.getState().requestJoin).toHaveBeenCalledWith( + expect.objectContaining({ id: 's1' }), 'please', + ); + await waitFor(() => expect(result.current.isPending).toBe(true)); + expect(result.current.showRequestForm).toBe(false); + }); +}); diff --git a/packages/web/src/hooks/useSpaceJoin.ts b/packages/web/src/hooks/useSpaceJoin.ts new file mode 100644 index 00000000..262970b8 --- /dev/null +++ b/packages/web/src/hooks/useSpaceJoin.ts @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { useExploreStore, type TaggedExploreSpace } from '../stores/exploreStore'; +import type { SpaceWithChannelsAndMembers } from '@backspace/shared'; + +export interface SpaceJoinControls { + isJoined: boolean; + isPublic: boolean; + isPending: boolean; + joining: boolean; + joinError: string; + showRequestForm: boolean; + requestMessage: string; + setRequestMessage: (v: string) => void; + openRequestForm: () => void; + cancelRequestForm: () => void; + join: () => Promise; + sendRequest: () => Promise; +} + +/** + * Shared join/request state machine over exploreStore, used by both the full + * Explore SpaceCard and the compact JoinSpace modal preview card. Single source + * of truth so the two surfaces cannot drift. + */ +export function useSpaceJoin(space: TaggedExploreSpace): SpaceJoinControls { + const publicJoin = useExploreStore((s) => s.publicJoin); + const requestJoin = useExploreStore((s) => s.requestJoin); + const myRequests = useExploreStore((s) => s.myRequests); + + const [joining, setJoining] = useState(false); + const [joinError, setJoinError] = useState(''); + const [showRequestForm, setShowRequestForm] = useState(false); + const [requestMessage, setRequestMessage] = useState(''); + const [localRequestSent, setLocalRequestSent] = useState(false); + + const isJoined = space.joined === true; + const isPublic = space.visibility === 'public'; + const isPending = + localRequestSent || + myRequests.some((r) => r.spaceId === space.id && r.status === 'pending'); + + // On success the caller navigates away and this component unmounts, so we do + // not reset `joining` — matches the pre-refactor SpaceCard behavior and + // avoids a flash of the enabled button before navigation. + const join = async (): Promise => { + setJoining(true); + setJoinError(''); + try { + return await publicJoin(space); + } catch (err) { + setJoinError(err instanceof Error ? err.message : 'Failed to join'); + setJoining(false); + return null; + } + }; + + const sendRequest = async (): Promise => { + setJoining(true); + setJoinError(''); + try { + await requestJoin(space, requestMessage.trim() || undefined); + setLocalRequestSent(true); + setShowRequestForm(false); + } catch (err) { + setJoinError(err instanceof Error ? err.message : 'Failed to send request'); + } finally { + setJoining(false); + } + }; + + return { + isJoined, + isPublic, + isPending, + joining, + joinError, + showRequestForm, + requestMessage, + setRequestMessage, + openRequestForm: () => setShowRequestForm(true), + cancelRequestForm: () => setShowRequestForm(false), + join, + sendRequest, + }; +}