fix(mobile): SpaceInviteCard Join lands on Spaces tab; skip auto-channel-redirect

AppLayout's auto-channel-redirect (turning /channels/<spaceId> into
/channels/<spaceId>/<firstChannelId>) is desktop-correct but on mobile
catapulted users past the channel sidebar straight into a chat — Join
from a SpaceInviteCard or Spaces-tab tap both inherited this. Guard
the effect with isMobile so /channels/<spaceId> settles at the channel
sidebar overview on mobile.

SpaceInviteCard's join handler now switches to the Spaces tab and seeds
spaceStore.currentSpaceId on mobile before navigating — clears the chat
stack the invite was tapped from and lands the user at the joined space's
channel sidebar instead of stuck behind the originating DM.

MobileSpacesScreen syncs its local selectedSpaceId from currentSpaceId
when external code (the Join handler) seeds the store — covers any future
programmatic space switch too.
This commit is contained in:
Jannis Braun
2026-05-05 23:23:46 +02:00
parent e2d938e6c0
commit fc9a07523f
3 changed files with 35 additions and 3 deletions
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { getApiForOrigin } from '../../stores/spaceStore'; import { getApiForOrigin } from '../../stores/spaceStore';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import type { SpaceInviteSystemPayload } from '@backspace/shared'; import type { SpaceInviteSystemPayload } from '@backspace/shared';
type LiveState = type LiveState =
@@ -45,6 +46,22 @@ export function SpaceInviteCard({ payload, senderName }: Props) {
const memberCount = live.kind === 'confirmed' ? live.memberCount : payload.snapshot.memberCount; const memberCount = live.kind === 'confirmed' ? live.memberCount : payload.snapshot.memberCount;
const isRevoked = live.kind === 'revoked'; 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 () => { const onJoin = async () => {
if (joining || isRevoked) return; if (joining || isRevoked) return;
setJoining(true); setJoining(true);
@@ -54,14 +71,14 @@ export function SpaceInviteCard({ payload, senderName }: Props) {
// the DM transport origin nor window.location.origin. Empty string maps // the DM transport origin nor window.location.origin. Empty string maps
// to undefined so joinByCode follows its local-instance branch. // to undefined so joinByCode follows its local-instance branch.
const space = await joinByCode(payload.inviteCode, payload.spaceInstanceOrigin || undefined); const space = await joinByCode(payload.inviteCode, payload.spaceInstanceOrigin || undefined);
navigate(`/channels/${space.id}`); landOnSpace(space.id);
} catch (err) { } catch (err) {
const msg = (err as Error)?.message ?? ''; const msg = (err as Error)?.message ?? '';
if (msg.toLowerCase().includes('already a member')) { if (msg.toLowerCase().includes('already a member')) {
// Already a member is a successful state — just navigate to the space. // 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 // 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. // silent rather than block the user with a noisy error.
navigate(`/channels/${payload.spaceId}`); landOnSpace(payload.spaceId);
return; return;
} }
setJoinError(msg || 'Failed to join'); setJoinError(msg || 'Failed to join');
@@ -295,8 +295,13 @@ export function AppLayout() {
} }
}, [channelId, spaceId, setCurrentChannel, loadMessages]); }, [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/<spaceId>` 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(() => { useEffect(() => {
if (useUIStore.getState().isMobile) return;
if (!spaceId || spaceId === '@me' || channelId) return; if (!spaceId || spaceId === '@me' || channelId) return;
if (channels.length === 0) return; if (channels.length === 0) return;
@@ -83,6 +83,16 @@ export function MobileSpacesScreen() {
} }
}, [selectedSpaceId, setCurrentSpace, loadSpaceDetail]); }, [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 // Auto-select first space if none selected
useEffect(() => { useEffect(() => {
if (!selectedSpaceId && spaces.length > 0 && spaces[0]) { if (!selectedSpaceId && spaces.length > 0 && spaces[0]) {