feat(web): InviteModal — friend picker + restyled share-link footer, drop deep link
This commit is contained in:
@@ -13,62 +13,385 @@ vi.mock('../../audio/AudioManager', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the API client so we can assert spaceInvite calls.
|
||||
const mockSpaceInvite = vi.fn();
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
dm: {
|
||||
spaceInvite: (...args: unknown[]) => mockSpaceInvite(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { InviteModal } from './InviteModal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
// Helpers — minimal fixtures for store state.
|
||||
function makeFriend(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'friend-1',
|
||||
username: 'alex',
|
||||
displayName: 'Alex',
|
||||
avatar: null,
|
||||
banner: null,
|
||||
accentColor: null,
|
||||
avatarColor: null,
|
||||
bio: null,
|
||||
status: 'online' as const,
|
||||
customStatus: null,
|
||||
createdAt: 0,
|
||||
addedAt: 0,
|
||||
homeUserId: null,
|
||||
homeInstance: null,
|
||||
_instanceOrigin: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMember(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
spaceId: 'space-1',
|
||||
userId: 'member-1',
|
||||
nickname: null,
|
||||
joinedAt: 0,
|
||||
roles: [],
|
||||
user: {
|
||||
id: 'member-1',
|
||||
username: 'memberA',
|
||||
displayName: null,
|
||||
avatar: null,
|
||||
banner: null,
|
||||
accentColor: null,
|
||||
avatarColor: null,
|
||||
bio: null,
|
||||
status: 'offline',
|
||||
customStatus: null,
|
||||
isAdmin: false,
|
||||
createdAt: 0,
|
||||
homeInstance: null,
|
||||
homeUserId: null,
|
||||
replicatedInstances: [],
|
||||
...(overrides.user ?? {}),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSpace(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'space-1',
|
||||
name: 'Test Space',
|
||||
icon: null,
|
||||
avatarColor: null,
|
||||
description: null,
|
||||
ownerId: 'owner-1',
|
||||
public: false,
|
||||
discoverable: false,
|
||||
joinPolicy: 'invite',
|
||||
createdAt: 0,
|
||||
_instanceOrigin: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setUpStore({
|
||||
friends = [],
|
||||
members = [],
|
||||
myUser = { id: 'me', username: 'me' } as any,
|
||||
generateInvite = vi.fn().mockResolvedValue('test-code'),
|
||||
}: {
|
||||
friends?: any[];
|
||||
members?: any[];
|
||||
myUser?: any;
|
||||
generateInvite?: ReturnType<typeof vi.fn>;
|
||||
} = {}) {
|
||||
useUIStore.setState({ activeModal: 'invite', modalData: {} });
|
||||
useSpaceStore.setState({
|
||||
currentSpaceId: 'space-1',
|
||||
spaces: [makeSpace()] as any,
|
||||
members,
|
||||
generateInvite,
|
||||
} as any);
|
||||
useSocialStore.setState({ friends } as any);
|
||||
useAuthStore.setState({ user: myUser } as any);
|
||||
return { generateInvite };
|
||||
}
|
||||
|
||||
// Mock the stores by spying on their getState
|
||||
beforeEach(() => {
|
||||
// Reset stores to default state
|
||||
useUIStore.setState({
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
});
|
||||
mockSpaceInvite.mockReset();
|
||||
|
||||
useUIStore.setState({ activeModal: null, modalData: {} });
|
||||
useSpaceStore.setState({
|
||||
currentSpaceId: null,
|
||||
spaces: [],
|
||||
members: [],
|
||||
} as any);
|
||||
useSocialStore.setState({ friends: [] } as any);
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
|
||||
// Mock clipboard.
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('InviteModal', () => {
|
||||
it('does not render when activeModal is not "invite"', () => {
|
||||
useUIStore.setState({ activeModal: null });
|
||||
render(<InviteModal />);
|
||||
expect(screen.queryByText('Invite Friends')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls generateInvite and displays the invite URL when opened', async () => {
|
||||
const mockGenerateInvite = vi.fn().mockResolvedValue('test-invite-code');
|
||||
useUIStore.setState({ activeModal: 'invite' });
|
||||
useSpaceStore.setState({
|
||||
currentSpaceId: 'server-123',
|
||||
generateInvite: mockGenerateInvite,
|
||||
it('renders friend list, excluding self', async () => {
|
||||
setUpStore({
|
||||
friends: [
|
||||
makeFriend({ id: 'me', username: 'me', displayName: 'Me' }),
|
||||
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
||||
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
||||
],
|
||||
myUser: { id: 'me', username: 'me' },
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
// Modal title should be visible
|
||||
expect(screen.getByText('Invite Friends')).toBeInTheDocument();
|
||||
|
||||
// Should show "Generating..." initially
|
||||
expect(screen.getByDisplayValue('Generating...')).toBeInTheDocument();
|
||||
|
||||
// Wait for the invite code to load
|
||||
await waitFor(() => {
|
||||
const input = screen.getByDisplayValue(/\/join\/test-invite-code/);
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(screen.getByText('Alex')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// generateInvite should have been called with the server ID
|
||||
expect(mockGenerateInvite).toHaveBeenCalledWith('server-123');
|
||||
expect(screen.getByText('Sam')).toBeInTheDocument();
|
||||
// Self is filtered out.
|
||||
expect(screen.queryByText('Me')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays an error when generateInvite fails', async () => {
|
||||
const mockGenerateInvite = vi.fn().mockRejectedValue(new Error('Not authorized'));
|
||||
useUIStore.setState({ activeModal: 'invite' });
|
||||
useSpaceStore.setState({
|
||||
currentSpaceId: 'server-123',
|
||||
generateInvite: mockGenerateInvite,
|
||||
it('grays out friends already in the space using federated identity', async () => {
|
||||
// Friend on remote instance orbit.ddns.net, member entry replicated
|
||||
// locally with user.homeUserId+homeInstance matching that identity.
|
||||
setUpStore({
|
||||
friends: [
|
||||
makeFriend({
|
||||
id: 'local-shadow-id',
|
||||
username: 'remoteFriend',
|
||||
displayName: 'Remote Friend',
|
||||
homeUserId: 'remote-uid-7',
|
||||
homeInstance: 'https://orbit.ddns.net',
|
||||
}),
|
||||
],
|
||||
members: [
|
||||
makeMember({
|
||||
userId: 'local-replicated-id',
|
||||
user: {
|
||||
id: 'local-replicated-id',
|
||||
username: 'remoteFriend',
|
||||
homeUserId: 'remote-uid-7',
|
||||
homeInstance: 'https://orbit.ddns.net',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Already in space')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clicking the disabled row does not select the friend.
|
||||
const button = screen.getByRole('button', { name: /Remote Friend/ });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('selecting friends and submitting calls api.dm.spaceInvite once per friend', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSpaceInvite.mockResolvedValue({});
|
||||
|
||||
setUpStore({
|
||||
friends: [
|
||||
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
||||
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alex')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Alex/ }));
|
||||
await user.click(screen.getByRole('button', { name: /Sam/ }));
|
||||
|
||||
const submit = screen.getByRole('button', { name: /Send 2 Invites/ });
|
||||
await user.click(submit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSpaceInvite).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(mockSpaceInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spaceId: 'space-1',
|
||||
inviteCode: 'test-code',
|
||||
target: { userId: 'f1' },
|
||||
}),
|
||||
);
|
||||
expect(mockSpaceInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spaceId: 'space-1',
|
||||
inviteCode: 'test-code',
|
||||
target: { userId: 'f2' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the modal after a fully successful send', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSpaceInvite.mockResolvedValue({});
|
||||
|
||||
setUpStore({
|
||||
friends: [makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' })],
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alex')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Alex/ }));
|
||||
await user.click(screen.getByRole('button', { name: /Send 1 Invite/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUIStore.getState().activeModal).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the results view with reason text on partial failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
// f1 succeeds, f2 fails with `not_a_friend`.
|
||||
mockSpaceInvite.mockImplementation(({ target }: any) => {
|
||||
if (target.userId === 'f1') return Promise.resolve({});
|
||||
return Promise.reject(new Error('not_a_friend'));
|
||||
});
|
||||
|
||||
setUpStore({
|
||||
friends: [
|
||||
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
||||
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alex')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Alex/ }));
|
||||
await user.click(screen.getByRole('button', { name: /Sam/ }));
|
||||
await user.click(screen.getByRole('button', { name: /Send 2 Invites/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('✓ Sent')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('✗ Not a friend')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Retry failed' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
|
||||
// Modal must remain open while results are visible.
|
||||
expect(useUIStore.getState().activeModal).toBe('invite');
|
||||
});
|
||||
|
||||
it('Retry failed re-runs only the failed subset', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSpaceInvite.mockImplementation(({ target }: any) => {
|
||||
if (target.userId === 'f1') return Promise.resolve({});
|
||||
return Promise.reject(new Error('upstream'));
|
||||
});
|
||||
|
||||
setUpStore({
|
||||
friends: [
|
||||
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
||||
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alex')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Alex/ }));
|
||||
await user.click(screen.getByRole('button', { name: /Sam/ }));
|
||||
await user.click(screen.getByRole('button', { name: /Send 2 Invites/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Retry failed' })).toBeInTheDocument();
|
||||
});
|
||||
expect(mockSpaceInvite).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second pass: Sam succeeds.
|
||||
mockSpaceInvite.mockReset();
|
||||
mockSpaceInvite.mockResolvedValue({});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Retry failed' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSpaceInvite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockSpaceInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ target: { userId: 'f2' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('share-link footer shows the invite URL and Copy writes to clipboard', async () => {
|
||||
setUpStore({
|
||||
friends: [],
|
||||
generateInvite: vi.fn().mockResolvedValue('abc123'),
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue(/\/join\/abc123/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Re-install clipboard spy after render. userEvent.setup() may have replaced
|
||||
// navigator.clipboard during initialization; we need a fresh spy that
|
||||
// matches what the component will call directly.
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const copyBtn = screen.getByRole('button', { name: 'Copy' });
|
||||
copyBtn.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/join/abc123'),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText('Copied!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Copy button is disabled while the invite code is loading', () => {
|
||||
setUpStore({
|
||||
friends: [],
|
||||
generateInvite: vi.fn().mockReturnValue(new Promise(() => {})),
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: 'Copy' });
|
||||
expect(copyButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows an error in the share-link footer when generateInvite fails', async () => {
|
||||
setUpStore({
|
||||
friends: [],
|
||||
generateInvite: vi.fn().mockRejectedValue(new Error('Not authorized')),
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
@@ -78,50 +401,63 @@ describe('InviteModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Copy button is disabled while loading', () => {
|
||||
const mockGenerateInvite = vi.fn().mockReturnValue(new Promise(() => {})); // never resolves
|
||||
useUIStore.setState({ activeModal: 'invite' });
|
||||
useSpaceStore.setState({
|
||||
currentSpaceId: 'server-123',
|
||||
generateInvite: mockGenerateInvite,
|
||||
it('search filters the friend list', async () => {
|
||||
const user = userEvent.setup();
|
||||
setUpStore({
|
||||
friends: [
|
||||
makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' }),
|
||||
makeFriend({ id: 'f2', username: 'sam', displayName: 'Sam' }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
const copyButton = screen.getByText('Copy');
|
||||
expect(copyButton).toBeDisabled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alex')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const search = screen.getByPlaceholderText('Search friends...');
|
||||
await user.type(search, 'sam');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Alex')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Sam')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Copy button calls clipboard.writeText with the invite URL', async () => {
|
||||
it('passes federated target shape for remote friends', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockGenerateInvite = vi.fn().mockResolvedValue('abc123');
|
||||
const mockClipboard = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: mockClipboard },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
mockSpaceInvite.mockResolvedValue({});
|
||||
|
||||
useUIStore.setState({ activeModal: 'invite' });
|
||||
useSpaceStore.setState({
|
||||
currentSpaceId: 'server-123',
|
||||
generateInvite: mockGenerateInvite,
|
||||
setUpStore({
|
||||
friends: [
|
||||
makeFriend({
|
||||
id: 'local-shadow',
|
||||
username: 'remoteAlex',
|
||||
displayName: 'Remote Alex',
|
||||
homeUserId: 'home-uid-9',
|
||||
homeInstance: 'https://orbit.ddns.net',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
// Wait for invite to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue(/\/join\/abc123/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Remote Alex')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Remote Alex/ }));
|
||||
await user.click(screen.getByRole('button', { name: /Send 1 Invite/ }));
|
||||
|
||||
// Click copy
|
||||
const copyButton = screen.getByText('Copy');
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockClipboard).toHaveBeenCalledWith(expect.stringContaining('/join/abc123'));
|
||||
|
||||
// Button text should change to "Copied!"
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mockSpaceInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: {
|
||||
homeUserId: 'home-uid-9',
|
||||
homeInstance: 'https://orbit.ddns.net',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user