diff --git a/packages/web/src/components/chat/SpaceInviteCard.tsx b/packages/web/src/components/chat/SpaceInviteCard.tsx index fc858f2f..5e4d298c 100644 --- a/packages/web/src/components/chat/SpaceInviteCard.tsx +++ b/packages/web/src/components/chat/SpaceInviteCard.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Avatar } from '../ui/Avatar'; import { getApiForOrigin } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore'; +import { useUIStore } from '../../stores/uiStore'; import type { SpaceInviteSystemPayload } from '@backspace/shared'; type LiveState = @@ -45,6 +46,22 @@ export function SpaceInviteCard({ payload, senderName }: Props) { const memberCount = live.kind === 'confirmed' ? live.memberCount : payload.snapshot.memberCount; const isRevoked = live.kind === 'revoked'; + // Land the user at the space's channel sidebar after a successful join. + // On mobile this means switching to the Spaces tab (which clears the chat + // screen stack the invite was tapped from); on desktop it's just a route + // change since AppLayout's auto-channel-redirect handles the rest. The + // `setCurrentSpace` call seeds spaceStore synchronously so MobileSpacesScreen + // mounts with the right space already selected, before AppLayout's URL effect + // catches up. + const landOnSpace = (spaceId: string) => { + const ui = useUIStore.getState(); + if (ui.isMobile) { + useSpaceStore.getState().setCurrentSpace(spaceId); + ui.setMobileTab('spaces'); + } + navigate(`/channels/${spaceId}`); + }; + const onJoin = async () => { if (joining || isRevoked) return; setJoining(true); @@ -54,14 +71,14 @@ export function SpaceInviteCard({ payload, senderName }: Props) { // the DM transport origin nor window.location.origin. Empty string maps // to undefined so joinByCode follows its local-instance branch. const space = await joinByCode(payload.inviteCode, payload.spaceInstanceOrigin || undefined); - navigate(`/channels/${space.id}`); + landOnSpace(space.id); } catch (err) { const msg = (err as Error)?.message ?? ''; if (msg.toLowerCase().includes('already a member')) { // Already a member is a successful state — just navigate to the space. // Look up the space in the store by id; if not found (rare race), stay // silent rather than block the user with a noisy error. - navigate(`/channels/${payload.spaceId}`); + landOnSpace(payload.spaceId); return; } setJoinError(msg || 'Failed to join'); diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 4c498af6..55cc302b 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -295,8 +295,13 @@ export function AppLayout() { } }, [channelId, spaceId, setCurrentChannel, loadMessages]); - // Auto-select last visited (or first) channel when opening a server without a channelId + // Auto-select last visited (or first) channel when opening a server without a channelId. + // Desktop-only: on mobile, `/channels/` should leave the user at the channel + // sidebar overview (MobileSpacesScreen), not auto-jump into a text channel — otherwise + // joining a space via SpaceInviteCard or tapping the Spaces bottom-nav tab catapults the + // user past the sidebar straight into a chat screen, with no clean back path. useEffect(() => { + if (useUIStore.getState().isMobile) return; if (!spaceId || spaceId === '@me' || channelId) return; if (channels.length === 0) return; diff --git a/packages/web/src/components/layout/MobileSpacesScreen.tsx b/packages/web/src/components/layout/MobileSpacesScreen.tsx index 0233ba14..a61b55e2 100644 --- a/packages/web/src/components/layout/MobileSpacesScreen.tsx +++ b/packages/web/src/components/layout/MobileSpacesScreen.tsx @@ -83,6 +83,16 @@ export function MobileSpacesScreen() { } }, [selectedSpaceId, setCurrentSpace, loadSpaceDetail]); + // Sync local selection from store when external code changes the current + // space (e.g. SpaceInviteCard's join handler seeding currentSpaceId before + // routing to the Spaces tab). Without this, useState(currentSpaceId) is only + // captured on mount and the strip stays on the previously-selected space. + useEffect(() => { + if (currentSpaceId && currentSpaceId !== selectedSpaceId) { + setSelectedSpaceId(currentSpaceId); + } + }, [currentSpaceId, selectedSpaceId]); + // Auto-select first space if none selected useEffect(() => { if (!selectedSpaceId && spaces.length > 0 && spaces[0]) {