From 309abd86e29fee338e304334df14c7ce0db467b8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:56:56 +0200 Subject: [PATCH] feat(web): discovery-first Join a Space modal --- .../src/components/modals/JoinSpace.test.tsx | 63 +++++++ .../web/src/components/modals/JoinSpace.tsx | 166 ++++++++++++++---- 2 files changed, 192 insertions(+), 37 deletions(-) diff --git a/packages/web/src/components/modals/JoinSpace.test.tsx b/packages/web/src/components/modals/JoinSpace.test.tsx index c4117388..2f18c43a 100644 --- a/packages/web/src/components/modals/JoinSpace.test.tsx +++ b/packages/web/src/components/modals/JoinSpace.test.tsx @@ -17,6 +17,7 @@ vi.mock('../../audio/AudioManager', () => ({ import { JoinSpaceModal } from './JoinSpace'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore } from '../../stores/spaceStore'; +import { useExploreStore } from '../../stores/exploreStore'; const mockNavigate = vi.fn(); vi.mock('react-router-dom', async () => { @@ -34,6 +35,18 @@ beforeEach(() => { spaces: [], 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() { @@ -120,4 +133,54 @@ describe('JoinSpaceModal', () => { 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(); + }); }); diff --git a/packages/web/src/components/modals/JoinSpace.tsx b/packages/web/src/components/modals/JoinSpace.tsx index 5aeaa0a0..9325bddd 100644 --- a/packages/web/src/components/modals/JoinSpace.tsx +++ b/packages/web/src/components/modals/JoinSpace.tsx @@ -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() { )} - {/* Phase: input — enter invite code or URL */} + {/* Phase: input — discovery-first, with invite code as a secondary path */} {phase === 'input' && ( -