feat(web): add shared useSpaceJoin hook over exploreStore

This commit is contained in:
Jannis Braun
2026-07-01 18:42:45 +02:00
parent 215750cb7a
commit 6c66af7a88
2 changed files with 173 additions and 0 deletions
@@ -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> = {}): 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);
});
});
+85
View File
@@ -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<SpaceWithChannelsAndMembers | null>;
sendRequest: () => Promise<void>;
}
/**
* 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<SpaceWithChannelsAndMembers | null> => {
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<void> => {
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,
};
}