feat(web): SpaceInviteCard component with snapshot/live/revoked render states
Renders space invite system messages in DMs as embed-style cards on the chat surface. Three render states: snapshot-only on mount (Join enabled, loading dot), live-confirmed (memberCount refreshed from preview), revoked (gray-out + glass-pill indicator). Join targets the space's home origin via joinByCode(code, spaceInstanceOrigin || undefined) — the three-way federation correctness rule. Re-exports getApiForOrigin from api/client to expose the cross-store resolver under a natural import surface.
This commit is contained in:
@@ -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(<MemoryRouter><SpaceInviteCard payload={basePayload} senderName="Alice" /></MemoryRouter>);
|
||||
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(<MemoryRouter><SpaceInviteCard payload={basePayload} senderName="Alice" /></MemoryRouter>);
|
||||
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(<MemoryRouter><SpaceInviteCard payload={basePayload} senderName="Alice" /></MemoryRouter>);
|
||||
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(<MemoryRouter><SpaceInviteCard payload={basePayload} senderName="Alice" /></MemoryRouter>);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<LiveState>({ kind: 'loading' });
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [joinError, setJoinError] = useState<string | null>(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 (
|
||||
<div className={`my-1.5 max-w-md rounded-lg border border-border-subtle bg-surface-elevated overflow-hidden ${isRevoked ? 'opacity-50' : ''}`}>
|
||||
<div className="px-3 py-1 text-[11px] text-txt-tertiary border-b border-border-subtle">
|
||||
{senderName} sent an invite
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<Avatar
|
||||
src={payload.snapshot.icon}
|
||||
name={payload.snapshot.spaceName}
|
||||
size={48}
|
||||
avatarColor={payload.snapshot.avatarColor ?? undefined}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-semibold text-txt-primary truncate">
|
||||
{payload.snapshot.spaceName}
|
||||
</div>
|
||||
<div className="text-[12px] text-txt-tertiary truncate">
|
||||
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||
{payload.snapshot.instanceName ? ` · ${payload.snapshot.instanceName}` : ''}
|
||||
{live.kind === 'loading' && (
|
||||
<span aria-hidden className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-txt-tertiary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isRevoked ? (
|
||||
<span className="glass-pill px-3 py-1 text-[12px] text-txt-tertiary">
|
||||
Invite no longer valid
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onJoin}
|
||||
disabled={joining}
|
||||
className="px-4 py-1.5 rounded-md text-[13px] font-medium bg-accent-mint text-surface-base hover:bg-accent-mint/90 disabled:opacity-50"
|
||||
>
|
||||
{joining ? 'Joining…' : 'Join'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{joinError && (
|
||||
<div className="px-3 pb-2 text-[12px] text-txt-danger">{joinError}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user