diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 3746b713..db5701a9 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -812,3 +812,10 @@ export function createApiClient(origin: string, getToken: () => string | null, o const baseUrl = origin ? `${origin}/api` : '/api'; return new BackspaceApiClient(baseUrl, getToken, onUnauthorized); } + +// Re-export the cross-store origin resolver so consumers can do +// `import { getApiForOrigin } from '../api/client'`. The real implementation +// lives in `utils/crossStoreResolvers` to avoid TDZ cycles between stores; +// re-exporting here keeps the public import surface clean for callers that +// don't need to know about the internal indirection. +export { getApiForOrigin } from '../utils/crossStoreResolvers'; diff --git a/packages/web/src/components/chat/SpaceInviteCard.test.tsx b/packages/web/src/components/chat/SpaceInviteCard.test.tsx new file mode 100644 index 00000000..014869f3 --- /dev/null +++ b/packages/web/src/components/chat/SpaceInviteCard.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SpaceInviteCard } from './SpaceInviteCard'; + +const mockJoinByCode = vi.fn(); +vi.mock('../../stores/spaceStore', () => ({ + useSpaceStore: (selector: any) => selector({ joinByCode: mockJoinByCode }), +})); +vi.mock('../../api/client', () => ({ + createApiClient: vi.fn(), + getApiForOrigin: vi.fn(() => ({ + spaces: { invitePreview:vi.fn() }, + })), +})); +import { getApiForOrigin } from '../../api/client'; + +const basePayload = { + event: 'space_invite' as const, + spaceId: 'S1', + spaceInstanceOrigin: 'https://z.example', + inviteCode: 'abc', + snapshot: { + spaceName: 'Aether', + icon: null, + avatarColor: 'mint' as const, + memberCount: 12, + description: 'A place', + instanceName: 'Backspace', + }, +}; + +describe('SpaceInviteCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockJoinByCode.mockReset(); + mockJoinByCode.mockResolvedValue({ id: 'S1', name: 'Aether' }); + }); + + it('renders snapshot fields immediately on mount (snapshot-only state)', () => { + (getApiForOrigin as any).mockReturnValue({ + spaces: { invitePreview:() => new Promise(() => {}) }, // never resolves + }); + render(); + expect(screen.getByText('Aether')).toBeInTheDocument(); + expect(screen.getByText(/12 members/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /join/i })).toBeEnabled(); + }); + + it('refreshes member count when live preview resolves (live-confirmed state)', async () => { + (getApiForOrigin as any).mockReturnValue({ + spaces: { invitePreview:vi.fn().mockResolvedValue({ ...basePayload.snapshot, spaceId: 'S1', memberCount: 99 }) }, + }); + render(); + await waitFor(() => expect(screen.getByText(/99 members/i)).toBeInTheDocument()); + }); + + it('shows revoked state when preview rejects (revoked state)', async () => { + (getApiForOrigin as any).mockReturnValue({ + spaces: { invitePreview:vi.fn().mockRejectedValue(new Error('not found')) }, + }); + render(); + await waitFor(() => + expect(screen.getByText(/invite no longer valid/i)).toBeInTheDocument(), + ); + // Join button replaced with disabled pill + expect(screen.queryByRole('button', { name: /^join$/i })).not.toBeInTheDocument(); + }); + + it('Join click passes payload.spaceInstanceOrigin to joinByCode (three-way federation invariant)', async () => { + // The space's home instance is what Join must target — NOT the DM transport + // origin, NOT window.location.origin, NOT the recipient's home. Unit-level + // proof of the three-way federation correctness rule. Runtime cross-instance + // verification (Task 19) is bonus. + const userEvent = (await import('@testing-library/user-event')).default; + const user = userEvent.setup(); + + (getApiForOrigin as any).mockReturnValue({ + spaces: { invitePreview:vi.fn().mockResolvedValue({ ...basePayload.snapshot, spaceId: 'S1' }) }, + }); + + render(); + const btn = await screen.findByRole('button', { name: /^join$/i }); + await user.click(btn); + + expect(mockJoinByCode).toHaveBeenCalledTimes(1); + expect(mockJoinByCode).toHaveBeenCalledWith('abc', 'https://z.example'); + // Specifically NOT called with empty string or undefined + expect(mockJoinByCode).not.toHaveBeenCalledWith('abc', ''); + expect(mockJoinByCode).not.toHaveBeenCalledWith('abc', undefined); + }); +}); diff --git a/packages/web/src/components/chat/SpaceInviteCard.tsx b/packages/web/src/components/chat/SpaceInviteCard.tsx new file mode 100644 index 00000000..56dcebd8 --- /dev/null +++ b/packages/web/src/components/chat/SpaceInviteCard.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Avatar } from '../ui/Avatar'; +import { getApiForOrigin } from '../../api/client'; +import { useSpaceStore } from '../../stores/spaceStore'; +import type { SpaceInviteSystemPayload } from '@backspace/shared'; + +type LiveState = + | { kind: 'loading' } + | { kind: 'confirmed'; memberCount: number } + | { kind: 'revoked' }; + +interface Props { + payload: SpaceInviteSystemPayload; + senderName: string; +} + +export function SpaceInviteCard({ payload, senderName }: Props) { + const navigate = useNavigate(); + const joinByCode = useSpaceStore(s => s.joinByCode); + const [live, setLive] = useState({ kind: 'loading' }); + const [joining, setJoining] = useState(false); + const [joinError, setJoinError] = useState(null); + + // Live-preview overlay: snapshot is authoritative until live confirms or + // revokes it. A mismatched spaceId is treated as revoked because the invite + // code now points to a different space than was captured at send time. + useEffect(() => { + let cancelled = false; + const client = getApiForOrigin(payload.spaceInstanceOrigin); + client.spaces.invitePreview(payload.inviteCode).then( + (preview) => { + if (cancelled) return; + if (preview.spaceId !== payload.spaceId) { + setLive({ kind: 'revoked' }); + } else { + setLive({ kind: 'confirmed', memberCount: preview.memberCount }); + } + }, + () => { if (!cancelled) setLive({ kind: 'revoked' }); }, + ); + return () => { cancelled = true; }; + }, [payload.inviteCode, payload.spaceId, payload.spaceInstanceOrigin]); + + const memberCount = live.kind === 'confirmed' ? live.memberCount : payload.snapshot.memberCount; + const isRevoked = live.kind === 'revoked'; + + const onJoin = async () => { + if (joining || isRevoked) return; + setJoining(true); + setJoinError(null); + try { + // Three-way federation invariant: target the space's home origin, not + // the DM transport origin nor window.location.origin. Empty string maps + // to undefined so joinByCode follows its local-instance branch. + const space = await joinByCode(payload.inviteCode, payload.spaceInstanceOrigin || undefined); + navigate(`/spaces/${space.id}`); + } catch (err) { + setJoinError((err as Error)?.message ?? 'Failed to join'); + setJoining(false); + } + }; + + return ( + + + {senderName} sent an invite + + + + + + {payload.snapshot.spaceName} + + + {memberCount} {memberCount === 1 ? 'member' : 'members'} + {payload.snapshot.instanceName ? ` · ${payload.snapshot.instanceName}` : ''} + {live.kind === 'loading' && ( + + )} + + + {isRevoked ? ( + + Invite no longer valid + + ) : ( + + {joining ? 'Joining…' : 'Join'} + + )} + + {joinError && ( + {joinError} + )} + + ); +}