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 { useAuthStore } from '../../stores/authStore'; import { useSocialStore } from '../../stores/socialStore'; import { api } from '../../api/client'; import { isSelf, parseFederatedUsername } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import type { Friend, MemberWithUser, SpaceInviteRequest, User } 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)"; } function InviteResultFriendRow({ friend, status, }: { friend: Friend; status: SendStatus | undefined; }) { const canonical = useCanonicalUserView(friend as unknown as User); const { baseName } = parseFederatedUsername(canonical.username); const dn = canonical.displayName ?? baseName; return (
{dn}
@{canonical.username}
{status?.kind === 'success' && ( ✓ Sent )} {status?.kind === 'failure' && ( ✗ {status.reason} )} {status?.kind === 'pending' && ( ... )}
); } function InviteSelectFriendRow({ friend, isSelected, alreadyMember, sending, onToggle, }: { friend: Friend; isSelected: boolean; alreadyMember: boolean; sending: boolean; onToggle: (id: string, friend: Friend) => void; }) { const canonical = useCanonicalUserView(friend as unknown as User); const { baseName } = parseFederatedUsername(canonical.username); const dn = canonical.displayName ?? baseName; return ( ); } export function InviteModal() { 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 spaceMembers = useSpaceStore((s) => s.members); const friends = useSocialStore((s) => s.friends); const myUser = useAuthStore((s) => s.user); const isOpen = activeModal === 'invite'; const currentSpace = spaces.find((s) => s.id === currentSpaceId); const instanceOrigin = currentSpace?._instanceOrigin ?? ''; 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) 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); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 2000); } catch { /* clipboard denied — silent */ } }; 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 (

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) => ( )) ) : ( <> {filteredFriends.length === 0 && (
{query.trim() ? 'No friends match your search' : 'No friends yet'}
)} {filteredFriends.map((friend) => ( ))} )}
{/* Submit / Retry / Done */} {inResultsView ? (
{hasFailures && ( )}
) : ( )} {/* Share-link footer */}

Or share a link

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