feat(web): InviteModal — friend picker + restyled share-link footer, drop deep link

This commit is contained in:
Jannis Braun
2026-04-29 22:02:23 +02:00
parent 1859f2963c
commit a46d35f5f8
2 changed files with 821 additions and 151 deletions
@@ -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}`}
>
&times;
</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>
);
}