diff --git a/packages/web/src/components/chat/SpaceInviteCard.test.tsx b/packages/web/src/components/chat/SpaceInviteCard.test.tsx index 7ca65b0a..de37a774 100644 --- a/packages/web/src/components/chat/SpaceInviteCard.test.tsx +++ b/packages/web/src/components/chat/SpaceInviteCard.test.tsx @@ -3,11 +3,12 @@ import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { SpaceInviteCard } from './SpaceInviteCard'; -const { mockJoinByCode, mockGetApiForOrigin } = vi.hoisted(() => ({ +const { mockJoinByCode, mockGetApiForOrigin, mockNavigate } = vi.hoisted(() => ({ mockJoinByCode: vi.fn(), mockGetApiForOrigin: vi.fn(() => ({ spaces: { invitePreview: vi.fn() }, })), + mockNavigate: vi.fn(), })); vi.mock('../../stores/spaceStore', () => ({ useSpaceStore: (selector: any) => selector({ joinByCode: mockJoinByCode }), @@ -16,6 +17,10 @@ vi.mock('../../stores/spaceStore', () => ({ vi.mock('../../api/client', () => ({ createApiClient: vi.fn(), })); +vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useNavigate: () => mockNavigate, +})); const basePayload = { event: 'space_invite' as const, @@ -36,6 +41,7 @@ describe('SpaceInviteCard', () => { beforeEach(() => { vi.clearAllMocks(); mockJoinByCode.mockReset(); + mockNavigate.mockReset(); mockJoinByCode.mockResolvedValue({ id: 'S1', name: 'Aether' }); }); @@ -91,4 +97,21 @@ describe('SpaceInviteCard', () => { expect(mockJoinByCode).not.toHaveBeenCalledWith('abc', ''); expect(mockJoinByCode).not.toHaveBeenCalledWith('abc', undefined); }); + + it('navigates to space when join returns "already a member" (no error shown)', async () => { + const userEvent = (await import('@testing-library/user-event')).default; + const user = userEvent.setup(); + + mockJoinByCode.mockRejectedValueOnce(new Error('You are already a member of this space')); + mockGetApiForOrigin.mockReturnValue({ + spaces: { invitePreview: vi.fn().mockResolvedValue({ ...basePayload.snapshot, spaceId: 'S1' }) }, + }); + + render(); + const btn = await screen.findByRole('button', { name: /^join$/i }); + await user.click(btn); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/spaces/S1')); + expect(screen.queryByText(/already a member of this space/i)).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/components/chat/SpaceInviteCard.tsx b/packages/web/src/components/chat/SpaceInviteCard.tsx index 67ee64d4..249f51d8 100644 --- a/packages/web/src/components/chat/SpaceInviteCard.tsx +++ b/packages/web/src/components/chat/SpaceInviteCard.tsx @@ -56,7 +56,15 @@ export function SpaceInviteCard({ payload, senderName }: Props) { const space = await joinByCode(payload.inviteCode, payload.spaceInstanceOrigin || undefined); navigate(`/spaces/${space.id}`); } catch (err) { - setJoinError((err as Error)?.message ?? 'Failed to join'); + const msg = (err as Error)?.message ?? ''; + if (msg.toLowerCase().includes('already a member')) { + // Already a member is a successful state — just navigate to the space. + // Look up the space in the store by id; if not found (rare race), stay + // silent rather than block the user with a noisy error. + navigate(`/spaces/${payload.spaceId}`); + return; + } + setJoinError(msg || 'Failed to join'); setJoining(false); } };