From a46d35f5f88b7ba99e43c6e8fcd1e082b2d68be4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:02:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20InviteModal=20=E2=80=94=20friend?= =?UTF-8?q?=20picker=20+=20restyled=20share-link=20footer,=20drop=20deep?= =?UTF-8?q?=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/modals/InviteModal.test.tsx | 456 ++++++++++++++-- .../web/src/components/modals/InviteModal.tsx | 516 +++++++++++++++--- 2 files changed, 821 insertions(+), 151 deletions(-) diff --git a/packages/web/src/components/modals/InviteModal.test.tsx b/packages/web/src/components/modals/InviteModal.test.tsx index 0ef1ecd9..f384228e 100644 --- a/packages/web/src/components/modals/InviteModal.test.tsx +++ b/packages/web/src/components/modals/InviteModal.test.tsx @@ -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 = {}) { + 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 = {}) { + 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 = {}) { + 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; +} = {}) { + 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(); 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(); - // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); @@ -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(); - 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(); - // 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', + }, + }), + ); + }); }); }); diff --git a/packages/web/src/components/modals/InviteModal.tsx b/packages/web/src/components/modals/InviteModal.tsx index 8b42742b..7286dc53 100644 --- a/packages/web/src/components/modals/InviteModal.tsx +++ b/packages/web/src/components/modals/InviteModal.tsx @@ -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 = { + 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>(new Set()); + const [sending, setSending] = useState(false); + const [results, setResults] = useState>(new Map()); + const inputRef = useRef(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('.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 ( - -

- Share this invite link with friends to let them join your space. -

- {error && ( -
- {error} -
- )} -
- - -
- {isElectron() && deepLinkUrl && ( -
+ +
+

+ Send to friends, or share a link. +

+ + {/* Selected chips — hidden in results view */} + {!inResultsView && selectedFriends.length > 0 && ( +
+ {selectedFriends.map((f) => ( + + {f.displayName ?? parseFederatedUsername(f.username).baseName} + + + ))} +
+ )} + + {/* Search input — hidden in results view */} + {!inResultsView && ( setQuery(e.target.value)} + placeholder="Search friends..." + className="input-search w-full py-2 text-[14px]" /> - + )} + + {/* Friend list / Results view */} +
+ {inResultsView ? ( + selectedFriends.map((f) => { + const status = results.get(f.id); + const { baseName } = parseFederatedUsername(f.username); + const dn = f.displayName ?? baseName; + return ( +
+ +
+
+ {dn} +
+
+ @{f.username} +
+
+ {status?.kind === 'success' && ( + + ✓ Sent + + )} + {status?.kind === 'failure' && ( + + ✗ {status.reason} + + )} + {status?.kind === 'pending' && ( + + ... + + )} +
+ ); + }) + ) : ( + <> + {filteredFriends.length === 0 && ( +
+ {query.trim() + ? 'No friends match your search' + : 'No friends yet'} +
+ )} + {filteredFriends.map((friend) => { + const alreadyMember = isFriendAlreadyMember(friend); + const isSelected = selected.has(friend.id); + const { baseName } = parseFederatedUsername(friend.username); + const dn = friend.displayName ?? baseName; + return ( + + ); + })} + + )}
- )} + + {/* Submit / Retry / Done */} + {inResultsView ? ( +
+ {hasFailures && ( + + )} + +
+ ) : ( + + )} + + {/* Share-link footer */} +
+

+ Or share a link +

+ {codeError && ( +
{codeError}
+ )} +
+ + +
+
+
); }