feat: public invite landing page with federation redirect
Make /join/:code a public route with a standalone JoinPage that shows a space preview and handles authenticated, unauthenticated, and cross-instance users. Adds GET /api/spaces/invite/:code/preview (no auth) endpoint, ?redirect= param support on login/register, and cleans up dead invite handling from AppLayout and JoinSpace modal.
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useSpaceStore, NotConnectedError } from '../stores/spaceStore';
|
||||
import { useInstanceStore, DifferentPasswordError } from '../stores/instanceStore';
|
||||
import { api, createApiClient } from '../api/client';
|
||||
import { parseInviteInput } from '../utils/inviteParser';
|
||||
import { Avatar } from './ui/Avatar';
|
||||
import type { InvitePreview } from '@backspace/shared';
|
||||
|
||||
type JoinPhase = 'preview' | 'connect' | 'fallback' | 'other-instance';
|
||||
|
||||
export function JoinPage() {
|
||||
const { inviteCode: rawInviteCode } = useParams<{ inviteCode: string }>();
|
||||
const navigate = useNavigate();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const joinByCode = useSpaceStore((s) => s.joinByCode);
|
||||
const connectToRemote = useInstanceStore((s) => s.connectToRemote);
|
||||
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
|
||||
|
||||
const [preview, setPreview] = useState<InvitePreview | null>(null);
|
||||
const [previewError, setPreviewError] = useState('');
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(true);
|
||||
|
||||
const [phase, setPhase] = useState<JoinPhase>('preview');
|
||||
const [error, setError] = useState('');
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
|
||||
// Federation connect state
|
||||
const [password, setPassword] = useState('');
|
||||
const [fallbackUsername, setFallbackUsername] = useState('');
|
||||
const [fallbackPassword, setFallbackPassword] = useState('');
|
||||
|
||||
// Other instance state
|
||||
const [otherDomain, setOtherDomain] = useState('');
|
||||
|
||||
// Parse the invite code from the URL
|
||||
const parsed = useMemo(() => {
|
||||
if (!rawInviteCode) return null;
|
||||
try {
|
||||
return parseInviteInput(rawInviteCode);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [rawInviteCode]);
|
||||
|
||||
// Fetch preview on mount
|
||||
useEffect(() => {
|
||||
if (!rawInviteCode) {
|
||||
setPreviewError('No invite code provided');
|
||||
setIsLoadingPreview(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
setPreviewError('Invalid invite code');
|
||||
setIsLoadingPreview(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingPreview(true);
|
||||
setPreviewError('');
|
||||
|
||||
const fetchPreview = async () => {
|
||||
try {
|
||||
let client;
|
||||
if (parsed.origin) {
|
||||
client = createApiClient(parsed.origin, () => null);
|
||||
} else {
|
||||
client = api;
|
||||
}
|
||||
const data = await client.spaces.invitePreview(parsed.code);
|
||||
setPreview(data);
|
||||
} catch (err) {
|
||||
setPreviewError(err instanceof Error ? err.message : 'Failed to load invite');
|
||||
} finally {
|
||||
setIsLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPreview();
|
||||
}, [rawInviteCode, parsed]);
|
||||
|
||||
// Join handler
|
||||
const handleJoin = async () => {
|
||||
if (!parsed) return;
|
||||
setError('');
|
||||
setIsJoining(true);
|
||||
try {
|
||||
const space = await joinByCode(parsed.code, parsed.origin || undefined);
|
||||
navigate(`/channels/${space.id}`);
|
||||
} catch (err) {
|
||||
if (err instanceof NotConnectedError) {
|
||||
setPhase('connect');
|
||||
setError('');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join space');
|
||||
}
|
||||
} finally {
|
||||
setIsJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Federation connect handler
|
||||
const handleConnect = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!parsed?.origin) return;
|
||||
setError('');
|
||||
setIsJoining(true);
|
||||
try {
|
||||
await connectToRemote(parsed.origin, password, user?.displayName || undefined);
|
||||
const space = await joinByCode(parsed.code, parsed.origin);
|
||||
navigate(`/channels/${space.id}`);
|
||||
} catch (err) {
|
||||
if (err instanceof DifferentPasswordError) {
|
||||
setPhase('fallback');
|
||||
setFallbackUsername(err.remoteUsername);
|
||||
setFallbackPassword('');
|
||||
setError('');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
} finally {
|
||||
setIsJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback login handler
|
||||
const handleFallbackLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!parsed?.origin) return;
|
||||
setError('');
|
||||
setIsJoining(true);
|
||||
try {
|
||||
await loginToRemote(parsed.origin, fallbackUsername, fallbackPassword);
|
||||
const space = await joinByCode(parsed.code, parsed.origin);
|
||||
navigate(`/channels/${space.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to log in');
|
||||
} finally {
|
||||
setIsJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Other instance redirect
|
||||
const handleOtherInstanceRedirect = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const domain = otherDomain.trim();
|
||||
if (!domain) return;
|
||||
|
||||
// Build the qualified invite code: code@thisHost
|
||||
const thisHost = window.location.host;
|
||||
const code = parsed?.code || rawInviteCode || '';
|
||||
const qualifiedCode = `${code}@${thisHost}`;
|
||||
const targetUrl = `https://${domain}/join/${encodeURIComponent(qualifiedCode)}`;
|
||||
window.location.href = targetUrl;
|
||||
};
|
||||
|
||||
let hostDisplay = '';
|
||||
try {
|
||||
if (parsed?.origin) hostDisplay = new URL(parsed.origin).host;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const redirectParam = rawInviteCode ? `?redirect=/join/${encodeURIComponent(rawInviteCode)}` : '';
|
||||
|
||||
// Loading state
|
||||
if (isLoadingPreview) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
||||
<div className="text-center relative z-10">
|
||||
<svg className="animate-spin w-10 h-10 text-accent-primary mx-auto mb-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<p className="text-txt-tertiary">Loading invite...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state — invalid/expired invite
|
||||
if (previewError || !preview) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
||||
<div className="w-full max-w-[420px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-accent-rose/10 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-accent-rose" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-txt-primary mb-2">Invalid Invite</h1>
|
||||
<p className="text-txt-secondary text-sm mb-6">
|
||||
{previewError || 'This invite link is invalid or has expired.'}
|
||||
</p>
|
||||
{token ? (
|
||||
<button
|
||||
onClick={() => navigate('/channels/@me')}
|
||||
className="px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
||||
>
|
||||
Back to Backspace
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-block px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
||||
>
|
||||
Log In
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main invite page
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
||||
<div className="w-full max-w-[420px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
|
||||
{/* Space preview */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Avatar
|
||||
src={preview.icon ? (parsed?.origin ? `${parsed.origin}/api/uploads/${preview.icon}` : `/api/uploads/${preview.icon}`) : null}
|
||||
name={preview.spaceName}
|
||||
size={72}
|
||||
avatarColor={preview.avatarColor}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">You've been invited to join</p>
|
||||
<h1 className="text-2xl font-bold text-txt-primary">{preview.spaceName}</h1>
|
||||
{preview.description && (
|
||||
<p className="text-txt-secondary text-sm mt-2">{preview.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-4 mt-3 text-xs text-txt-tertiary">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-txt-tertiary/40" />
|
||||
{preview.memberCount} {preview.memberCount === 1 ? 'member' : 'members'}
|
||||
</span>
|
||||
<span>{preview.instanceName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase: preview — main join UI */}
|
||||
{phase === 'preview' && (
|
||||
<>
|
||||
{token ? (
|
||||
/* Authenticated user */
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={isJoining}
|
||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isJoining ? 'Joining...' : 'Join Space'}
|
||||
</button>
|
||||
) : (
|
||||
/* Unauthenticated user */
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
to={`/login${redirectParam}`}
|
||||
className="block w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors text-center"
|
||||
>
|
||||
Log in to join
|
||||
</Link>
|
||||
<Link
|
||||
to={`/register${redirectParam}`}
|
||||
className="block w-full py-2.5 bg-surface-input hover:bg-surface-input/80 text-txt-primary font-medium rounded transition-colors text-center"
|
||||
>
|
||||
Create an account
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other instance section */}
|
||||
<details className="mt-5 group">
|
||||
<summary className="text-xs text-txt-tertiary hover:text-txt-secondary cursor-pointer select-none transition-colors">
|
||||
I use Backspace on another instance
|
||||
</summary>
|
||||
<form onSubmit={handleOtherInstanceRedirect} className="mt-3">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">
|
||||
Your instance domain
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={otherDomain}
|
||||
onChange={(e) => setOtherDomain(e.target.value)}
|
||||
placeholder="e.g. my-instance.com"
|
||||
className="flex-1 px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!otherDomain.trim()}
|
||||
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-txt-tertiary mt-1.5">
|
||||
You'll be redirected to your home instance to complete joining.
|
||||
</p>
|
||||
</form>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Phase: connect — password prompt for federation */}
|
||||
{phase === 'connect' && (
|
||||
<form onSubmit={handleConnect}>
|
||||
<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">
|
||||
<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="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
disabled={isJoining}
|
||||
autoFocus
|
||||
/>
|
||||
<p 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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPhase('preview'); setPassword(''); setError(''); }}
|
||||
className="px-4 py-2.5 text-txt-tertiary hover:text-txt-secondary text-sm transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isJoining || !password}
|
||||
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isJoining ? 'Connecting...' : 'Connect & Join'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Phase: fallback — different password on remote */}
|
||||
{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="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
disabled={isJoining}
|
||||
/>
|
||||
</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="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||
disabled={isJoining}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPhase('connect'); setFallbackPassword(''); setError(''); }}
|
||||
className="px-4 py-2.5 text-txt-tertiary hover:text-txt-secondary text-sm transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isJoining || !fallbackUsername || !fallbackPassword}
|
||||
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isJoining ? 'Logging in...' : 'Login & Join'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { RateLimitError } from '../../api/client';
|
||||
|
||||
@@ -11,6 +11,8 @@ export function LoginPage() {
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirect = searchParams.get('redirect');
|
||||
|
||||
useEffect(() => {
|
||||
if (retryAfter <= 0) return;
|
||||
@@ -41,7 +43,11 @@ export function LoginPage() {
|
||||
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
navigate('/channels/@me');
|
||||
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||
navigate(redirect);
|
||||
} else {
|
||||
navigate('/channels/@me');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitError) {
|
||||
setRetryAfter(err.retryAfter);
|
||||
@@ -118,7 +124,7 @@ export function LoginPage() {
|
||||
|
||||
<p className="mt-3 text-sm text-txt-tertiary">
|
||||
Need an account?{' '}
|
||||
<Link to="/register" className="text-accent-primary hover:underline">
|
||||
<Link to={`/register${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { ImageCropModal } from '../ui/ImageCropModal';
|
||||
@@ -43,6 +43,8 @@ export function RegisterPage() {
|
||||
const register = useAuthStore((s) => s.register);
|
||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirect = searchParams.get('redirect');
|
||||
|
||||
// Cleanup blob URL on unmount
|
||||
useEffect(() => {
|
||||
@@ -202,7 +204,11 @@ export function RegisterPage() {
|
||||
}
|
||||
}
|
||||
|
||||
navigate('/channels/@me');
|
||||
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||
navigate(redirect);
|
||||
} else {
|
||||
navigate('/channels/@me');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitError) {
|
||||
setRetryAfter(err.retryAfter);
|
||||
@@ -318,7 +324,7 @@ export function RegisterPage() {
|
||||
|
||||
<p className="mt-3 text-sm text-txt-tertiary">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent-primary hover:underline">
|
||||
<Link to={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
|
||||
Log In
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { AudioManager } from '../../audio/AudioManager';
|
||||
|
||||
export function AppLayout() {
|
||||
const { spaceId, channelId, inviteCode } = useParams<{ spaceId?: string; channelId?: string; inviteCode?: string }>();
|
||||
const { spaceId, channelId } = useParams<{ spaceId?: string; channelId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Global interaction handler to resume AudioContext
|
||||
@@ -91,7 +91,6 @@ export function AppLayout() {
|
||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
@@ -200,12 +199,6 @@ export function AppLayout() {
|
||||
}
|
||||
}, [spaceId, setCurrentSpace, loadSpaceDetail, setShowDms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inviteCode) {
|
||||
openModal('joinSpace');
|
||||
}
|
||||
}, [inviteCode, openModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channelId) {
|
||||
setCurrentChannel(channelId);
|
||||
|
||||
@@ -4,13 +4,12 @@ import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
|
||||
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { parseInviteInput } from '../../utils/inviteParser';
|
||||
|
||||
type JoinPhase = 'input' | 'connect' | 'fallback';
|
||||
|
||||
export function JoinSpaceModal() {
|
||||
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -31,12 +30,9 @@ export function JoinSpaceModal() {
|
||||
|
||||
const isOpen = activeModal === 'joinSpace';
|
||||
|
||||
// Pre-fill from URL param and reset state on open/close
|
||||
// Reset state on close
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (urlInviteCode) setInviteCode(urlInviteCode);
|
||||
} else {
|
||||
// Reset all state when modal closes
|
||||
if (!isOpen) {
|
||||
setInviteCode('');
|
||||
setError('');
|
||||
setPhase('input');
|
||||
@@ -46,7 +42,7 @@ export function JoinSpaceModal() {
|
||||
setFallbackUsername('');
|
||||
setFallbackPassword('');
|
||||
}
|
||||
}, [isOpen, urlInviteCode]);
|
||||
}, [isOpen]);
|
||||
|
||||
const joinAndNavigate = async (code: string, origin?: string) => {
|
||||
const space = await joinByCode(code, origin || undefined);
|
||||
|
||||
Reference in New Issue
Block a user