feat(web): discovery-first Join a Space modal
This commit is contained in:
@@ -17,6 +17,7 @@ vi.mock('../../audio/AudioManager', () => ({
|
|||||||
import { JoinSpaceModal } from './JoinSpace';
|
import { JoinSpaceModal } from './JoinSpace';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useExploreStore } from '../../stores/exploreStore';
|
||||||
|
|
||||||
const mockNavigate = vi.fn();
|
const mockNavigate = vi.fn();
|
||||||
vi.mock('react-router-dom', async () => {
|
vi.mock('react-router-dom', async () => {
|
||||||
@@ -34,6 +35,18 @@ beforeEach(() => {
|
|||||||
spaces: [],
|
spaces: [],
|
||||||
currentSpaceId: null,
|
currentSpaceId: null,
|
||||||
});
|
});
|
||||||
|
useExploreStore.setState({
|
||||||
|
spaces: [],
|
||||||
|
myRequests: [],
|
||||||
|
isLoading: false,
|
||||||
|
discoveryEnabled: true,
|
||||||
|
error: null,
|
||||||
|
fetchSpaces: vi.fn().mockResolvedValue(undefined),
|
||||||
|
fetchMyRequests: vi.fn().mockResolvedValue(undefined),
|
||||||
|
publicJoin: vi.fn().mockResolvedValue({ id: 'p1', name: 'Preview Space' }),
|
||||||
|
requestJoin: vi.fn().mockResolvedValue({ id: 'r1', spaceId: 'p1', status: 'pending' }),
|
||||||
|
});
|
||||||
|
useUIStore.setState({ isMobile: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderModal() {
|
function renderModal() {
|
||||||
@@ -120,4 +133,54 @@ describe('JoinSpaceModal', () => {
|
|||||||
expect(screen.getByText('Invalid invite code')).toBeInTheDocument();
|
expect(screen.getByText('Invalid invite code')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the discovery heading and fetches spaces on open', () => {
|
||||||
|
useUIStore.setState({ activeModal: 'joinSpace' });
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByText(/Discover spaces to join/i)).toBeInTheDocument();
|
||||||
|
expect(useExploreStore.getState().fetchSpaces).toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('Browse all in Explore')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders live preview cards for unjoined discoverable spaces', () => {
|
||||||
|
useExploreStore.setState({
|
||||||
|
spaces: [{
|
||||||
|
id: 'p1', name: 'Preview Space', icon: null, banner: null, avatarColor: null,
|
||||||
|
description: null, visibility: 'public', memberCount: 4, createdAt: 0,
|
||||||
|
joined: false, _instanceOrigin: '',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
useUIStore.setState({ activeModal: 'joinSpace' });
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByText('Preview Space')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Browse all navigates to /explore on desktop and closes the modal', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
useUIStore.setState({ activeModal: 'joinSpace', isMobile: false });
|
||||||
|
renderModal();
|
||||||
|
await user.click(screen.getByText('Browse all in Explore'));
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/explore');
|
||||||
|
expect(useUIStore.getState().activeModal).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Browse all uses the mobile screen stack when on mobile', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const pushMobileScreen = vi.fn();
|
||||||
|
useUIStore.setState({ activeModal: 'joinSpace', isMobile: true, pushMobileScreen });
|
||||||
|
renderModal();
|
||||||
|
await user.click(screen.getByText('Browse all in Explore'));
|
||||||
|
expect(pushMobileScreen).toHaveBeenCalledWith('explore');
|
||||||
|
expect(mockNavigate).not.toHaveBeenCalledWith('/explore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides discovery and shows a notice when discovery is disabled', () => {
|
||||||
|
useExploreStore.setState({ discoveryEnabled: false });
|
||||||
|
useUIStore.setState({ activeModal: 'joinSpace' });
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByText(/discovery is turned off/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Browse all in Explore')).not.toBeInTheDocument();
|
||||||
|
// invite path still available
|
||||||
|
expect(screen.getByPlaceholderText('e.g. abc123 or https://instance.com/join/abc123')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
|
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
|
||||||
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useExploreStore } from '../../stores/exploreStore';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { parseInviteInput } from '../../utils/inviteParser';
|
import { parseInviteInput } from '../../utils/inviteParser';
|
||||||
|
import { ExploreSpacePreviewCard } from './ExploreSpacePreviewCard';
|
||||||
|
|
||||||
type JoinPhase = 'input' | 'connect' | 'fallback';
|
type JoinPhase = 'input' | 'connect' | 'fallback';
|
||||||
|
|
||||||
@@ -28,8 +30,43 @@ export function JoinSpaceModal() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const navigate = useNavigate();
|
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';
|
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
|
// Reset state on close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -132,46 +169,101 @@ export function JoinSpaceModal() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Phase: input — enter invite code or URL */}
|
{/* Phase: input — discovery-first, with invite code as a secondary path */}
|
||||||
{phase === 'input' && (
|
{phase === 'input' && (
|
||||||
<form onSubmit={handleSubmit}>
|
<div>
|
||||||
<p className="text-txt-secondary text-sm mb-4">
|
{/* ── Discovery section ── */}
|
||||||
Enter an invite code or link to join a space.
|
{discoveryEnabled ? (
|
||||||
</p>
|
<div className="mb-1">
|
||||||
<div className="mb-4">
|
<p className="text-txt-secondary text-sm mb-3">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
Discover spaces to join, or browse them all in Explore.
|
||||||
Invite Code or Link
|
</p>
|
||||||
</label>
|
|
||||||
<input
|
{discoveryLoading && previewSpaces.length === 0 ? (
|
||||||
type="text"
|
<div className="space-y-2">
|
||||||
value={inviteCode}
|
{[0, 1, 2].map((i) => (
|
||||||
onChange={(e) => setInviteCode(e.target.value)}
|
<div key={i} className="h-[60px] rounded-lg bg-surface-channel border border-border-soft animate-pulse" />
|
||||||
className="input-standard w-full"
|
))}
|
||||||
placeholder="e.g. abc123 or https://instance.com/join/abc123"
|
</div>
|
||||||
autoFocus
|
) : 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>
|
</div>
|
||||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
{/* ── Invite-code section (secondary) ── */}
|
||||||
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
|
<form onSubmit={handleSubmit}>
|
||||||
<button
|
<div className="mb-4">
|
||||||
type="button"
|
<input
|
||||||
onClick={closeModal}
|
type="text"
|
||||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
value={inviteCode}
|
||||||
>
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
Cancel
|
className="input-standard w-full"
|
||||||
</button>
|
placeholder="e.g. abc123 or https://instance.com/join/abc123"
|
||||||
<button
|
/>
|
||||||
type="submit"
|
</div>
|
||||||
disabled={isLoading || !inviteCode.trim()}
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
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"
|
<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">
|
||||||
{isLoading ? 'Joining...' : 'Join Space'}
|
<button
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Phase: connect — password prompt to connect to remote instance */}
|
{/* Phase: connect — password prompt to connect to remote instance */}
|
||||||
|
|||||||
Reference in New Issue
Block a user