feat(web): discovery-first Join a Space modal

This commit is contained in:
Jannis Braun
2026-07-01 18:56:56 +02:00
parent fc8df54cb0
commit 309abd86e2
2 changed files with 192 additions and 37 deletions
+129 -37
View File
@@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react';
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';
@@ -28,8 +30,43 @@ export function JoinSpaceModal() {
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) {
@@ -132,46 +169,101 @@ export function JoinSpaceModal() {
</div>
)}
{/* Phase: input — enter invite code or URL */}
{/* Phase: input — discovery-first, with invite code as a secondary path */}
{phase === 'input' && (
<form onSubmit={handleSubmit}>
<p className="text-txt-secondary text-sm mb-4">
Enter an invite code or link to join a space.
</p>
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Invite Code or Link
</label>
<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"
autoFocus
/>
<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">
Couldnt 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>
<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>
{/* ── 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>
</div>
</form>
</form>
</div>
)}
{/* Phase: connect — password prompt to connect to remote instance */}