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.
133 lines
5.3 KiB
TypeScript
133 lines
5.3 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
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 =
|
|
| { kind: 'loading' }
|
|
| { kind: 'confirmed'; memberCount: number }
|
|
| { kind: 'revoked' };
|
|
|
|
interface Props {
|
|
payload: SpaceInviteSystemPayload;
|
|
senderName: string;
|
|
}
|
|
|
|
export function SpaceInviteCard({ payload, senderName }: Props) {
|
|
const navigate = useNavigate();
|
|
const joinByCode = useSpaceStore(s => s.joinByCode);
|
|
const [live, setLive] = useState<LiveState>({ kind: 'loading' });
|
|
const [joining, setJoining] = useState(false);
|
|
const [joinError, setJoinError] = useState<string | null>(null);
|
|
|
|
// Live-preview overlay: snapshot is authoritative until live confirms or
|
|
// revokes it. A mismatched spaceId is treated as revoked because the invite
|
|
// code now points to a different space than was captured at send time.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const client = getApiForOrigin(payload.spaceInstanceOrigin);
|
|
client.spaces.invitePreview(payload.inviteCode).then(
|
|
(preview) => {
|
|
if (cancelled) return;
|
|
if (preview.spaceId !== payload.spaceId) {
|
|
setLive({ kind: 'revoked' });
|
|
} else {
|
|
setLive({ kind: 'confirmed', memberCount: preview.memberCount });
|
|
}
|
|
},
|
|
() => { if (!cancelled) setLive({ kind: 'revoked' }); },
|
|
);
|
|
return () => { cancelled = true; };
|
|
}, [payload.inviteCode, payload.spaceId, payload.spaceInstanceOrigin]);
|
|
|
|
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);
|
|
setJoinError(null);
|
|
try {
|
|
// Three-way federation invariant: target the space's home origin, not
|
|
// 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);
|
|
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.
|
|
landOnSpace(payload.spaceId);
|
|
return;
|
|
}
|
|
setJoinError(msg || 'Failed to join');
|
|
setJoining(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`my-1.5 max-w-md rounded-lg border border-white/[0.06] bg-surface-channel overflow-hidden ${isRevoked ? 'opacity-50' : ''}`}>
|
|
<div className="px-3 py-1 text-[11px] text-txt-tertiary border-b border-white/[0.06]">
|
|
{senderName} sent an invite
|
|
</div>
|
|
<div className="flex items-center gap-3 p-3">
|
|
<Avatar
|
|
src={payload.snapshot.icon}
|
|
name={payload.snapshot.spaceName}
|
|
size={48}
|
|
avatarColor={payload.snapshot.avatarColor ?? undefined}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[14px] font-semibold text-txt-primary truncate">
|
|
{payload.snapshot.spaceName}
|
|
</div>
|
|
<div className="text-[12px] text-txt-tertiary truncate">
|
|
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
|
{payload.snapshot.instanceName ? ` · ${payload.snapshot.instanceName}` : ''}
|
|
{live.kind === 'loading' && (
|
|
<span aria-hidden className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-txt-tertiary animate-pulse" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
{isRevoked ? (
|
|
<span className="glass-pill px-3 py-1 text-[12px] text-txt-tertiary">
|
|
Invite no longer valid
|
|
</span>
|
|
) : (
|
|
<button
|
|
onClick={onJoin}
|
|
disabled={joining}
|
|
className="px-4 py-1.5 rounded-md text-[13px] font-medium bg-accent-mint text-surface-base hover:bg-accent-mint/90 disabled:opacity-50"
|
|
>
|
|
{joining ? 'Joining…' : 'Join'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{joinError && (
|
|
<div className="px-3 pb-2 text-[12px] text-txt-danger">{joinError}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|