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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,126 +1,460 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { isElectron } from '../../platform/platform';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { api } from '../../api/client';
|
||||
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||
import type { Friend, MemberWithUser, SpaceInviteRequest } from '@backspace/shared';
|
||||
|
||||
type SendStatus =
|
||||
| { kind: 'pending' }
|
||||
| { kind: 'success' }
|
||||
| { kind: 'failure'; reason: string };
|
||||
|
||||
const FAILURE_COPY: Record<string, string> = {
|
||||
invite_invalid: 'Invite link no longer valid',
|
||||
not_a_friend: 'Not a friend',
|
||||
user_not_found: 'User not found',
|
||||
already_member: 'Already a member',
|
||||
upstream: "Couldn't verify invite — try again",
|
||||
cannot_invite_self: 'Cannot invite yourself',
|
||||
invalid_body: 'Invalid request',
|
||||
invalid_target: 'Invalid target',
|
||||
};
|
||||
|
||||
function reasonForError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
// The api client throws Error(message) where message is the server's
|
||||
// `error` field (e.g. 'invite_invalid'). Fall back to message-based
|
||||
// network detection for transport failures.
|
||||
const msg = error.message;
|
||||
const mapped = FAILURE_COPY[msg];
|
||||
if (mapped) return mapped;
|
||||
if (/network|fetch|failed to fetch/i.test(msg)) {
|
||||
return "Couldn't reach your instance";
|
||||
}
|
||||
}
|
||||
return "Couldn't send (server error)";
|
||||
}
|
||||
|
||||
export function InviteModal() {
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedDeepLink, setCopiedDeepLink] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const generateInvite = useSpaceStore((s) => s.generateInvite);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const currentSpace = spaces.find(s => s.id === currentSpaceId);
|
||||
const instanceOrigin = currentSpace?._instanceOrigin ?? '';
|
||||
const spaceMembers = useSpaceStore((s) => s.members);
|
||||
const friends = useSocialStore((s) => s.friends);
|
||||
const myUser = useAuthStore((s) => s.user);
|
||||
|
||||
const isOpen = activeModal === 'invite';
|
||||
const inviteUrl = inviteCode ? `${instanceOrigin || window.location.origin}/join/${inviteCode}` : '';
|
||||
const currentSpace = spaces.find((s) => s.id === currentSpaceId);
|
||||
const instanceOrigin = currentSpace?._instanceOrigin ?? '';
|
||||
|
||||
// Deep link for Electron desktop app
|
||||
const deepLinkUrl = inviteCode
|
||||
? instanceOrigin
|
||||
? `backspace://join/${inviteCode}@${new URL(instanceOrigin).host}`
|
||||
: `backspace://join/${inviteCode}`
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [codeError, setCodeError] = useState('');
|
||||
const [codeLoading, setCodeLoading] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [sending, setSending] = useState(false);
|
||||
const [results, setResults] = useState<Map<string, SendStatus>>(new Map());
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const inviteUrl = inviteCode
|
||||
? `${instanceOrigin || window.location.origin}/join/${inviteCode}`
|
||||
: '';
|
||||
|
||||
// Fetch / generate the per-space invite code on open.
|
||||
useEffect(() => {
|
||||
if (isOpen && currentSpaceId) {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
generateInvite(currentSpaceId)
|
||||
.then(code => {
|
||||
setInviteCode(code);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate invite link');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
if (!isOpen || !currentSpaceId) return;
|
||||
setCodeLoading(true);
|
||||
setCodeError('');
|
||||
generateInvite(currentSpaceId).then(
|
||||
(code) => {
|
||||
setInviteCode(code);
|
||||
setCodeLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
setCodeError((err as Error)?.message ?? 'Failed to generate invite link');
|
||||
setCodeLoading(false);
|
||||
},
|
||||
);
|
||||
}, [isOpen, currentSpaceId, generateInvite]);
|
||||
|
||||
// Reset modal state on open.
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setSelected(new Set());
|
||||
setResults(new Map());
|
||||
setSending(false);
|
||||
setLinkCopied(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Federated-identity match per CLAUDE.md rule. The currently-loaded space's
|
||||
// member list lives on the store as `members: MemberWithUser[]`. Read the
|
||||
// federated identity tuple (user.homeUserId / user.homeInstance) on each side,
|
||||
// falling back to the local id for non-federated users.
|
||||
const isFriendAlreadyMember = (friend: Friend): boolean => {
|
||||
if (!currentSpace || spaceMembers.length === 0) return false;
|
||||
const fId = friend.homeUserId ?? friend.id;
|
||||
const fHome = friend.homeInstance ?? '';
|
||||
return spaceMembers.some((m: MemberWithUser) => {
|
||||
const mId = m.user.homeUserId ?? m.userId;
|
||||
const mHome = m.user.homeInstance ?? '';
|
||||
return mId === fId && mHome === fHome;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredFriends = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return friends.filter((f) => {
|
||||
if (isSelf(f, myUser)) return false;
|
||||
if (!q) return true;
|
||||
const dn = (f.displayName ?? '').toLowerCase();
|
||||
const un = f.username.toLowerCase();
|
||||
return dn.includes(q) || un.includes(q);
|
||||
});
|
||||
}, [friends, query, myUser]);
|
||||
|
||||
const toggleFriend = (friendId: string, friend: Friend) => {
|
||||
if (isFriendAlreadyMember(friend)) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(friendId)) next.delete(friendId);
|
||||
else next.add(friendId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFriend = (friendId: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(friendId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedFriends = useMemo(
|
||||
() => friends.filter((f) => selected.has(f.id)),
|
||||
[friends, selected],
|
||||
);
|
||||
|
||||
const sendInvitesTo = async (targets: Friend[]) => {
|
||||
if (!currentSpace || !inviteCode || targets.length === 0) return;
|
||||
setSending(true);
|
||||
|
||||
// Mark all targets as pending in the results map (preserving prior successes).
|
||||
setResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const f of targets) next.set(f.id, { kind: 'pending' });
|
||||
return next;
|
||||
});
|
||||
|
||||
const calls = targets.map(async (friend) => {
|
||||
const target: SpaceInviteRequest['target'] = friend.homeInstance
|
||||
? {
|
||||
homeUserId: friend.homeUserId ?? friend.id,
|
||||
homeInstance: friend.homeInstance,
|
||||
}
|
||||
: { userId: friend.id };
|
||||
try {
|
||||
await api.dm.spaceInvite({
|
||||
target,
|
||||
spaceId: currentSpace.id,
|
||||
spaceInstanceOrigin: instanceOrigin,
|
||||
inviteCode,
|
||||
});
|
||||
return { friend, status: { kind: 'success' as const } };
|
||||
} catch (err) {
|
||||
return {
|
||||
friend,
|
||||
status: { kind: 'failure' as const, reason: reasonForError(err) },
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const settled = await Promise.allSettled(calls);
|
||||
setResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const s of settled) {
|
||||
if (s.status === 'fulfilled') next.set(s.value.friend.id, s.value.status);
|
||||
}
|
||||
// If all targets succeeded, close the modal silently. Toast infra does
|
||||
// not exist in this codebase yet — see plan Task 12 / Step 2.
|
||||
const allSucceeded = targets.every(
|
||||
(f) => next.get(f.id)?.kind === 'success',
|
||||
);
|
||||
if (allSucceeded) {
|
||||
// Defer close until after this state batch settles.
|
||||
queueMicrotask(() => closeModal());
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
const onSubmit = () => sendInvitesTo(selectedFriends);
|
||||
|
||||
const onRetryFailed = () => {
|
||||
const failed = selectedFriends.filter(
|
||||
(f) => results.get(f.id)?.kind === 'failure',
|
||||
);
|
||||
sendInvitesTo(failed);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!inviteUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => setLinkCopied(false), 2000);
|
||||
} catch {
|
||||
const input = document.querySelector<HTMLInputElement>('.invite-code-input');
|
||||
if (input) {
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
/* clipboard denied — silent */
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDeepLink = async () => {
|
||||
if (!deepLinkUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(deepLinkUrl);
|
||||
setCopiedDeepLink(true);
|
||||
setTimeout(() => setCopiedDeepLink(false), 2000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
const inResultsView = results.size > 0 && !sending;
|
||||
const submitLabel =
|
||||
selectedFriends.length === 0
|
||||
? 'Select Friends'
|
||||
: `Send ${selectedFriends.length} Invite${selectedFriends.length > 1 ? 's' : ''}`;
|
||||
const hasFailures =
|
||||
inResultsView &&
|
||||
selectedFriends.some((f) => results.get(f.id)?.kind === 'failure');
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends" mobileStyle="sheet">
|
||||
<p className="text-txt-secondary text-sm mb-4">
|
||||
Share this invite link with friends to let them join your space.
|
||||
</p>
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={isLoading ? 'Generating...' : inviteUrl}
|
||||
readOnly
|
||||
className="input-standard invite-code-input flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={isLoading || !inviteUrl}
|
||||
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
|
||||
copied
|
||||
? 'bg-status-online text-white'
|
||||
: 'bg-accent-primary hover:bg-accent-primary/80 text-white'
|
||||
}`}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
{isElectron() && deepLinkUrl && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={closeModal}
|
||||
title="Invite Friends"
|
||||
mobileStyle="sheet"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-[13px] text-txt-tertiary">
|
||||
Send to friends, or share a link.
|
||||
</p>
|
||||
|
||||
{/* Selected chips — hidden in results view */}
|
||||
{!inResultsView && selectedFriends.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{selectedFriends.map((f) => (
|
||||
<span
|
||||
key={f.id}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[12px] bg-accent-mint/15 text-accent-mint"
|
||||
>
|
||||
{f.displayName ?? parseFederatedUsername(f.username).baseName}
|
||||
<button
|
||||
onClick={() => removeFriend(f.id)}
|
||||
className="opacity-60 hover:opacity-100 transition-opacity text-[14px] leading-none"
|
||||
aria-label={`Remove ${f.displayName ?? f.username}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search input — hidden in results view */}
|
||||
{!inResultsView && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={deepLinkUrl}
|
||||
readOnly
|
||||
className="input-standard flex-1 font-mono text-xs"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search friends..."
|
||||
className="input-search w-full py-2 text-[14px]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyDeepLink}
|
||||
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
|
||||
copiedDeepLink
|
||||
? 'bg-status-online text-white'
|
||||
: 'bg-surface-elevated hover:bg-surface-elevated/80 text-txt-secondary'
|
||||
}`}
|
||||
>
|
||||
{copiedDeepLink ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Friend list / Results view */}
|
||||
<div className="max-h-[280px] overflow-y-auto space-y-[2px]">
|
||||
{inResultsView ? (
|
||||
selectedFriends.map((f) => {
|
||||
const status = results.get(f.id);
|
||||
const { baseName } = parseFederatedUsername(f.username);
|
||||
const dn = f.displayName ?? baseName;
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-[4px]"
|
||||
>
|
||||
<Avatar
|
||||
src={f.avatar}
|
||||
name={dn}
|
||||
size={30}
|
||||
userId={f.homeUserId ?? f.id}
|
||||
avatarColor={f.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-txt-primary truncate">
|
||||
{dn}
|
||||
</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
@{f.username}
|
||||
</div>
|
||||
</div>
|
||||
{status?.kind === 'success' && (
|
||||
<span className="text-[12px] text-accent-mint flex-shrink-0">
|
||||
✓ Sent
|
||||
</span>
|
||||
)}
|
||||
{status?.kind === 'failure' && (
|
||||
<span className="text-[12px] text-txt-danger flex-shrink-0">
|
||||
✗ {status.reason}
|
||||
</span>
|
||||
)}
|
||||
{status?.kind === 'pending' && (
|
||||
<span className="text-[12px] text-txt-tertiary flex-shrink-0">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{filteredFriends.length === 0 && (
|
||||
<div className="py-4 text-center text-txt-tertiary text-[14px]">
|
||||
{query.trim()
|
||||
? 'No friends match your search'
|
||||
: 'No friends yet'}
|
||||
</div>
|
||||
)}
|
||||
{filteredFriends.map((friend) => {
|
||||
const alreadyMember = isFriendAlreadyMember(friend);
|
||||
const isSelected = selected.has(friend.id);
|
||||
const { baseName } = parseFederatedUsername(friend.username);
|
||||
const dn = friend.displayName ?? baseName;
|
||||
return (
|
||||
<button
|
||||
key={friend.id}
|
||||
onClick={() => toggleFriend(friend.id, friend)}
|
||||
disabled={alreadyMember || sending}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-[4px] transition-colors text-left ${
|
||||
alreadyMember
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-accent-mint/[0.08]'
|
||||
: 'hover:bg-interactive-hover'
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={friend.avatar}
|
||||
name={dn}
|
||||
size={30}
|
||||
status={friend.status as any}
|
||||
userId={friend.homeUserId ?? friend.id}
|
||||
avatarColor={friend.avatarColor}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-txt-primary truncate">
|
||||
{dn}
|
||||
</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
{alreadyMember
|
||||
? 'Already in space'
|
||||
: `@${friend.username}`}
|
||||
</div>
|
||||
</div>
|
||||
{!alreadyMember && (
|
||||
<div
|
||||
className={`w-[18px] h-[18px] rounded flex-shrink-0 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-accent-mint'
|
||||
: 'border-2 border-border-hard'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className="text-surface-base"
|
||||
>
|
||||
<path
|
||||
d="M2.5 6L5 8.5L9.5 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit / Retry / Done */}
|
||||
{inResultsView ? (
|
||||
<div className="flex gap-2">
|
||||
{hasFailures && (
|
||||
<button
|
||||
onClick={onRetryFailed}
|
||||
disabled={sending}
|
||||
className="flex-1 py-2 rounded-md text-[13px] font-semibold transition-colors bg-accent-mint text-surface-base hover:bg-accent-mint/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Retry failed
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="flex-1 py-2 rounded-md text-[13px] font-semibold glass-pill text-txt-primary"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={selectedFriends.length === 0 || sending || codeLoading}
|
||||
className="w-full py-2 rounded-md text-[13px] font-semibold transition-colors bg-accent-mint text-surface-base hover:bg-accent-mint/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sending ? 'Sending...' : submitLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Share-link footer */}
|
||||
<div className="pt-3 border-t border-white/[0.06]">
|
||||
<p className="text-[12px] text-txt-tertiary mb-2">
|
||||
Or share a link
|
||||
</p>
|
||||
{codeError && (
|
||||
<div className="mb-2 text-[12px] text-txt-danger">{codeError}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={codeLoading ? 'Generating...' : inviteUrl}
|
||||
readOnly
|
||||
className="input-embedded flex-1 font-mono text-xs px-2 py-1.5"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={codeLoading || !inviteUrl}
|
||||
className={`glass-pill px-3 py-1.5 text-[12px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
linkCopied ? 'text-accent-mint' : 'text-txt-primary'
|
||||
}`}
|
||||
>
|
||||
{linkCopied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user