399 lines
16 KiB
TypeScript
399 lines
16 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
||
import { Modal } from '../ui/Modal';
|
||
import { useUIStore } from '../../stores/uiStore';
|
||
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
|
||
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
||
import { useAuthStore } from '../../stores/authStore';
|
||
import { useExploreStore } from '../../stores/exploreStore';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { parseInviteInput } from '../../utils/inviteParser';
|
||
import { ExploreSpacePreviewCard } from './ExploreSpacePreviewCard';
|
||
|
||
type JoinPhase = 'input' | 'connect' | 'fallback';
|
||
|
||
export function JoinSpaceModal() {
|
||
const [inviteCode, setInviteCode] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [phase, setPhase] = useState<JoinPhase>('input');
|
||
const [parsedCode, setParsedCode] = useState('');
|
||
const [parsedOrigin, setParsedOrigin] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [fallbackUsername, setFallbackUsername] = useState('');
|
||
const [fallbackPassword, setFallbackPassword] = useState('');
|
||
|
||
const activeModal = useUIStore((s) => s.activeModal);
|
||
const closeModal = useUIStore((s) => s.closeModal);
|
||
const joinByCode = useSpaceStore((s) => s.joinByCode);
|
||
const connectToRemote = useInstanceStore((s) => s.connectToRemote);
|
||
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
|
||
const user = useAuthStore((s) => s.user);
|
||
const navigate = useNavigate();
|
||
|
||
const isMobile = useUIStore((s) => s.isMobile);
|
||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||
|
||
const discoverySpaces = useExploreStore((s) => s.spaces);
|
||
const discoveryLoading = useExploreStore((s) => s.isLoading);
|
||
const discoveryEnabled = useExploreStore((s) => s.discoveryEnabled);
|
||
const discoveryError = useExploreStore((s) => s.error);
|
||
const fetchSpaces = useExploreStore((s) => s.fetchSpaces);
|
||
const fetchMyRequests = useExploreStore((s) => s.fetchMyRequests);
|
||
|
||
const isOpen = activeModal === 'joinSpace';
|
||
|
||
// Fetch discoverable spaces when the modal opens. Fire-and-forget; the invite
|
||
// section never depends on this resolving.
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
void fetchSpaces();
|
||
void fetchMyRequests();
|
||
}
|
||
}, [isOpen, fetchSpaces, fetchMyRequests]);
|
||
|
||
const previewSpaces = useMemo(
|
||
() => discoverySpaces.filter((s) => !s.joined).slice(0, 6),
|
||
[discoverySpaces],
|
||
);
|
||
|
||
const handleBrowseExplore = () => {
|
||
closeModal();
|
||
if (isMobile) pushMobileScreen('explore');
|
||
else navigate('/explore');
|
||
};
|
||
|
||
const handlePreviewJoinSuccess = (spaceId: string) => {
|
||
closeModal();
|
||
navigate(`/channels/${spaceId}`);
|
||
};
|
||
|
||
// Reset state on close
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
setInviteCode('');
|
||
setError('');
|
||
setPhase('input');
|
||
setParsedCode('');
|
||
setParsedOrigin('');
|
||
setPassword('');
|
||
setFallbackUsername('');
|
||
setFallbackPassword('');
|
||
}
|
||
}, [isOpen]);
|
||
|
||
const joinAndNavigate = async (code: string, origin?: string) => {
|
||
const space = await joinByCode(code, origin || undefined);
|
||
closeModal();
|
||
navigate(`/channels/${space.id}`);
|
||
};
|
||
|
||
// Phase 1: Submit invite code/URL
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError('');
|
||
|
||
let parsed: { code: string; origin?: string };
|
||
try {
|
||
parsed = parseInviteInput(inviteCode);
|
||
} catch (err) {
|
||
setError((err as Error).message);
|
||
return;
|
||
}
|
||
|
||
setParsedCode(parsed.code);
|
||
setParsedOrigin(parsed.origin || '');
|
||
|
||
setIsLoading(true);
|
||
try {
|
||
await joinAndNavigate(parsed.code, parsed.origin);
|
||
} catch (err) {
|
||
if (err instanceof NotConnectedError) {
|
||
setPhase('connect');
|
||
setError('');
|
||
} else {
|
||
setError(err instanceof Error ? err.message : 'Failed to join space');
|
||
}
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// Phase 2: Connect to remote instance with password, then join
|
||
const handleConnect = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError('');
|
||
setIsLoading(true);
|
||
try {
|
||
await connectToRemote(parsedOrigin, password, user?.displayName || undefined);
|
||
await joinAndNavigate(parsedCode, parsedOrigin);
|
||
} catch (err) {
|
||
if (err instanceof DifferentPasswordError) {
|
||
setPhase('fallback');
|
||
setFallbackUsername(err.remoteUsername);
|
||
setFallbackPassword('');
|
||
setError('');
|
||
} else {
|
||
setError((err as Error).message);
|
||
}
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// Phase 3: Fallback login with different credentials, then join
|
||
const handleFallbackLogin = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError('');
|
||
setIsLoading(true);
|
||
try {
|
||
await loginToRemote(parsedOrigin, fallbackUsername, fallbackPassword);
|
||
await joinAndNavigate(parsedCode, parsedOrigin);
|
||
} catch (err) {
|
||
setError((err as Error).message);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
let hostDisplay = '';
|
||
try {
|
||
if (parsedOrigin) hostDisplay = new URL(parsedOrigin).host;
|
||
} catch { /* ignore */ }
|
||
|
||
return (
|
||
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Space" mobileStyle="sheet">
|
||
{/* Error display (shared across all phases) */}
|
||
{error && (
|
||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Phase: input — discovery-first, with invite code as a secondary path */}
|
||
{phase === 'input' && (
|
||
<div>
|
||
{/* ── Discovery section ── */}
|
||
{discoveryEnabled ? (
|
||
<div className="mb-1">
|
||
<p className="text-txt-secondary text-sm mb-3">
|
||
Discover spaces to join, or browse them all in Explore.
|
||
</p>
|
||
|
||
{discoveryLoading && previewSpaces.length === 0 ? (
|
||
<div className="space-y-2">
|
||
{[0, 1, 2].map((i) => (
|
||
<div key={i} className="h-[60px] rounded-lg bg-surface-channel border border-border-soft animate-pulse" />
|
||
))}
|
||
</div>
|
||
) : discoveryError ? (
|
||
<div className="p-2.5 rounded-lg bg-surface-channel border border-border-soft text-[13px] text-txt-tertiary">
|
||
Couldn’t load spaces to discover right now. You can still join with an invite code below.
|
||
</div>
|
||
) : previewSpaces.length === 0 ? (
|
||
<div className="p-3 rounded-lg bg-surface-channel border border-border-soft text-[13px] text-txt-tertiary text-center">
|
||
No spaces to discover yet — try an invite code below, or check back later.
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2 max-h-[280px] overflow-y-auto pr-0.5">
|
||
{previewSpaces.map((space) => (
|
||
<ExploreSpacePreviewCard
|
||
key={`${space.id}:${space._instanceOrigin}`}
|
||
space={space}
|
||
onJoinSuccess={handlePreviewJoinSuccess}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleBrowseExplore}
|
||
className="mt-3 w-full py-2 flex items-center justify-center gap-1.5 text-sm font-medium text-accent-primary hover:bg-accent-primary/10 rounded-lg transition-colors"
|
||
>
|
||
Browse all in Explore
|
||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="mb-1 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30 text-[13px] text-accent-amber">
|
||
Space discovery is turned off on this instance. You can still join with an invite code.
|
||
</div>
|
||
)}
|
||
|
||
{/* Divider */}
|
||
<div className="flex items-center gap-3 my-4">
|
||
<div className="flex-1 h-px bg-white/[0.06]" />
|
||
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||
Have an invite code?
|
||
</span>
|
||
<div className="flex-1 h-px bg-white/[0.06]" />
|
||
</div>
|
||
|
||
{/* ── Invite-code section (secondary) ── */}
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="mb-4">
|
||
<input
|
||
type="text"
|
||
value={inviteCode}
|
||
onChange={(e) => setInviteCode(e.target.value)}
|
||
className="input-standard w-full"
|
||
placeholder="e.g. abc123 or https://instance.com/join/abc123"
|
||
/>
|
||
</div>
|
||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||
<div className="flex justify-center pt-3 pb-1">
|
||
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
|
||
<button
|
||
type="button"
|
||
onClick={closeModal}
|
||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading || !inviteCode.trim()}
|
||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||
>
|
||
{isLoading ? 'Joining...' : 'Join Space'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* Phase: connect — password prompt to connect to remote instance */}
|
||
{phase === 'connect' && (
|
||
<form onSubmit={handleConnect}>
|
||
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
|
||
<p className="text-txt-secondary text-sm mb-4">
|
||
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space.
|
||
</p>
|
||
<div className="mb-4 space-y-2">
|
||
<div>
|
||
<label className="block text-xs text-txt-tertiary mb-1">
|
||
Enter your password to connect
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
placeholder="Your account password"
|
||
className="input-standard w-full"
|
||
disabled={isLoading}
|
||
autoFocus
|
||
autoComplete="current-password"
|
||
/>
|
||
<div className="text-xs text-txt-tertiary mt-1">
|
||
Your password is verified locally, then used to create or access your account on the remote instance.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||
<div className="flex justify-center pt-3 pb-1">
|
||
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
|
||
<button
|
||
type="button"
|
||
onClick={() => { setPhase('input'); setPassword(''); setError(''); }}
|
||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors flex items-center gap-1"
|
||
>
|
||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||
</svg>
|
||
Back
|
||
</button>
|
||
<div className="w-px h-5 bg-white/10" />
|
||
<button
|
||
type="button"
|
||
onClick={closeModal}
|
||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading || !password}
|
||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||
>
|
||
{isLoading ? 'Connecting...' : 'Connect & Join'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{/* Phase: fallback — different password on remote instance */}
|
||
{phase === 'fallback' && (
|
||
<form onSubmit={handleFallbackLogin}>
|
||
<div className="mb-3 p-2 bg-accent-amber/10 border border-accent-amber/30 rounded text-xs text-accent-amber">
|
||
An account already exists on {hostDisplay} with a different password. Enter the credentials you used on that instance.
|
||
</div>
|
||
<div className="mb-4 space-y-3">
|
||
<div>
|
||
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
||
<input
|
||
type="text"
|
||
value={fallbackUsername}
|
||
onChange={(e) => setFallbackUsername(e.target.value)}
|
||
placeholder="Your username on this instance"
|
||
className="input-standard w-full"
|
||
disabled={isLoading}
|
||
autoComplete="username"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
|
||
<input
|
||
type="password"
|
||
value={fallbackPassword}
|
||
onChange={(e) => setFallbackPassword(e.target.value)}
|
||
placeholder="Password on the remote instance"
|
||
className="input-standard w-full"
|
||
disabled={isLoading}
|
||
autoFocus
|
||
autoComplete="current-password"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||
<div className="flex justify-center pt-3 pb-1">
|
||
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
|
||
<button
|
||
type="button"
|
||
onClick={() => { setPhase('connect'); setFallbackPassword(''); setError(''); }}
|
||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors flex items-center gap-1"
|
||
>
|
||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||
</svg>
|
||
Back
|
||
</button>
|
||
<div className="w-px h-5 bg-white/10" />
|
||
<button
|
||
type="button"
|
||
onClick={closeModal}
|
||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading || !fallbackUsername || !fallbackPassword}
|
||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||
>
|
||
{isLoading ? 'Logging in...' : 'Login & Join'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</Modal>
|
||
);
|
||
}
|