Steward groups read org.gamecult.steward from the m.space.child content the hierarchy returns, so an invited-only member gets them; root-state stewardships and GameCultStewardships are deleted. Tapping Join waits for the client store to hold the room before navigating (no invite-card frame). The header search opens the room finder on every width. Create room and Join with address are navigator actions; joined non-DM rooms outside the Space sit under Elsewhere; invited DMs list in Direct with Join. harness/estate holds the fake-homeserver Playwright pin. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
|
import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds';
|
|
import { PageNav, PageNavContent, PageNavHeader } from '../../components/page';
|
|
import {
|
|
NavButton,
|
|
NavCategory,
|
|
NavCategoryHeader,
|
|
NavItem,
|
|
NavItemContent,
|
|
} from '../../components/nav';
|
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
|
import { JoinAddressPrompt } from '../../components/join-address-prompt';
|
|
import { RoomNavCategoryButton, RoomNavItem } from '../room-nav';
|
|
import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar';
|
|
import {
|
|
encodeSearchParamValueArray,
|
|
getDirectRoomPath,
|
|
getHomeCreatePath,
|
|
getHomeRoomPath,
|
|
getSpaceRoomPath,
|
|
withSearchParam,
|
|
} from '../../pages/pathUtils';
|
|
import { _RoomSearchParams } from '../../pages/paths';
|
|
import { searchModalAtom } from '../../state/searchModal';
|
|
import { isDirectInvite } from '../../utils/room';
|
|
import {
|
|
addRoomIdToMDirect,
|
|
getCanonicalAliasOrRoomId,
|
|
guessDmRoomUserId,
|
|
} from '../../utils/matrix';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
|
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|
import {
|
|
getRoomNotificationMode,
|
|
useRoomsNotificationPreferencesContext,
|
|
} from '../../hooks/useRoomsNotificationPreferences';
|
|
import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories';
|
|
import { makeNavCategoryId } from '../../state/closedNavCategories';
|
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|
import { EstateRoom, EstateTier } from './buildEstate';
|
|
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
|
|
|
|
/** How long a tap waits for the client store to hold the room it just joined. */
|
|
const JOIN_ARRIVAL_TIMEOUT_MS = 20000;
|
|
import { useEstate, useEstateRootId } from './useEstate';
|
|
|
|
const estateRoomPath = (mx: ReturnType<typeof useMatrixClient>, room: EstateRoom): string => {
|
|
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
|
if (room.direct) return getDirectRoomPath(alias);
|
|
// A Space the user has not joined has no Space route; the room still resolves from Home.
|
|
if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') {
|
|
return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias);
|
|
}
|
|
return getHomeRoomPath(alias);
|
|
};
|
|
|
|
function JoinRow({ room }: { room: EstateRoom }) {
|
|
const mx = useMatrixClient();
|
|
const navigate = useNavigate();
|
|
const [state, join] = useAsyncCallback(
|
|
useCallback(async () => {
|
|
const userId = mx.getSafeUserId();
|
|
const dmRoom = mx.getRoom(room.roomId);
|
|
const dmUserId =
|
|
room.direct && dmRoom && isDirectInvite(dmRoom, userId)
|
|
? guessDmRoomUserId(dmRoom, userId)
|
|
: undefined;
|
|
const result = await activateEstateRoom(mx, room);
|
|
if (result.kind === 'refused') throw new Error(result.message);
|
|
// joinRoom resolves before the store holds the room; opening it now shows the invite card.
|
|
if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) {
|
|
throw new Error('Joined, but the room has not arrived yet. Tap Join again in a moment.');
|
|
}
|
|
if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId);
|
|
navigate(estateRoomPath(mx, room));
|
|
}, [mx, navigate, room])
|
|
);
|
|
const busy = state.status === AsyncStatus.Loading;
|
|
|
|
return (
|
|
<NavItem variant="Background" radii="400">
|
|
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px', minHeight: 38 }}>
|
|
<NavItemContent>
|
|
<Text as="span" size="Inherit" truncate>
|
|
{room.name}
|
|
</Text>
|
|
</NavItemContent>
|
|
{state.status === AsyncStatus.Error && (
|
|
<Text as="span" size="T200" style={{ color: color.Critical.Main }} truncate>
|
|
{(state.error as Error).message}
|
|
</Text>
|
|
)}
|
|
<Chip
|
|
variant={room.state === 'invited' ? 'Primary' : 'Secondary'}
|
|
fill="Soft"
|
|
radii="Pill"
|
|
before={busy ? <Spinner size="100" /> : <Icon src={Icons.Plus} size="50" />}
|
|
onClick={join}
|
|
disabled={busy}
|
|
aria-label={`Join ${room.name}`}
|
|
>
|
|
<Text size="B300">Join</Text>
|
|
</Chip>
|
|
</Box>
|
|
</NavItem>
|
|
);
|
|
}
|
|
|
|
function EstateRow({ room }: { room: EstateRoom }) {
|
|
const mx = useMatrixClient();
|
|
const selectedRoomId = useSelectedRoom();
|
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
|
const mxRoom = room.state === 'joined' ? mx.getRoom(room.roomId) : null;
|
|
|
|
if (!mxRoom) return <JoinRow room={room} />;
|
|
return (
|
|
<RoomNavItem
|
|
room={mxRoom}
|
|
selected={selectedRoomId === room.roomId}
|
|
direct={room.direct}
|
|
linkPath={estateRoomPath(mx, room)}
|
|
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type CollapsibleProps = {
|
|
categoryId: string;
|
|
label: string;
|
|
rooms: EstateRoom[];
|
|
children: ReactNode;
|
|
};
|
|
function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
|
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
|
const selectedRoomId = useSelectedRoom();
|
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
|
const callRoomId = useCallEmbed()?.roomId;
|
|
const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom());
|
|
|
|
const active = rooms.some(
|
|
(r) => r.roomId === selectedRoomId || r.roomId === callRoomId || roomToUnread.has(r.roomId)
|
|
);
|
|
// Phones start collapsed unless the section holds something live; wide screens start open.
|
|
// A tap flips that default; the flip is remembered per device and per screen class.
|
|
const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', categoryId);
|
|
const closed = (mobile && !active) !== toggled.has(key);
|
|
|
|
const handleClick: MouseEventHandler<HTMLButtonElement> = () =>
|
|
setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key });
|
|
|
|
return (
|
|
<NavCategory>
|
|
<NavCategoryHeader>
|
|
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
|
|
{label}
|
|
</RoomNavCategoryButton>
|
|
</NavCategoryHeader>
|
|
{!closed && children}
|
|
</NavCategory>
|
|
);
|
|
}
|
|
|
|
function Tier({ tier }: { tier: EstateTier }) {
|
|
const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]);
|
|
return (
|
|
<Collapsible categoryId={tier.id} label={tier.label} rooms={allRooms}>
|
|
{tier.loose.map((r) => (
|
|
<EstateRow key={r.roomId} room={r} />
|
|
))}
|
|
{tier.groups.map((g) => (
|
|
<Collapsible
|
|
key={g.mind}
|
|
categoryId={`${tier.id}|${g.mind}`}
|
|
label={g.label}
|
|
rooms={g.rooms}
|
|
>
|
|
{g.rooms.map((r) => (
|
|
<EstateRow key={r.roomId} room={r} />
|
|
))}
|
|
</Collapsible>
|
|
))}
|
|
</Collapsible>
|
|
);
|
|
}
|
|
|
|
function EntryPoints() {
|
|
const navigate = useNavigate();
|
|
return (
|
|
<NavCategory>
|
|
<NavItem variant="Background" radii="400">
|
|
<NavButton onClick={() => navigate(getHomeCreatePath())}>
|
|
<NavItemContent>
|
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
|
<Avatar size="200" radii="400">
|
|
<Icon src={Icons.Plus} size="100" />
|
|
</Avatar>
|
|
<Text as="span" size="Inherit" truncate>
|
|
Create Room
|
|
</Text>
|
|
</Box>
|
|
</NavItemContent>
|
|
</NavButton>
|
|
</NavItem>
|
|
<UseStateProvider initial={false}>
|
|
{(open, setOpen) => (
|
|
<>
|
|
<NavItem variant="Background" radii="400">
|
|
<NavButton onClick={() => setOpen(true)}>
|
|
<NavItemContent>
|
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
|
<Avatar size="200" radii="400">
|
|
<Icon src={Icons.Link} size="100" />
|
|
</Avatar>
|
|
<Text as="span" size="Inherit" truncate>
|
|
Join with Address
|
|
</Text>
|
|
</Box>
|
|
</NavItemContent>
|
|
</NavButton>
|
|
</NavItem>
|
|
{open && (
|
|
<JoinAddressPrompt
|
|
onCancel={() => setOpen(false)}
|
|
onOpen={(roomIdOrAlias, viaServers, eventId) => {
|
|
setOpen(false);
|
|
const path = getHomeRoomPath(roomIdOrAlias, eventId);
|
|
navigate(
|
|
viaServers
|
|
? withSearchParam<_RoomSearchParams>(path, {
|
|
viaServers: encodeSearchParamValueArray(viaServers),
|
|
})
|
|
: path
|
|
);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</UseStateProvider>
|
|
</NavCategory>
|
|
);
|
|
}
|
|
|
|
function EstateTiers({ rootId }: { rootId: string }) {
|
|
const { fetching, error, tiers } = useEstate(rootId);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
return (
|
|
<PageNavContent scrollRef={scrollRef}>
|
|
<Box direction="Column" gap="300">
|
|
<EntryPoints />
|
|
{tiers.map((tier) => (
|
|
<Tier key={tier.id} tier={tier} />
|
|
))}
|
|
{fetching && (
|
|
<Box justifyContent="Center" style={{ padding: 16 }}>
|
|
<Spinner />
|
|
</Box>
|
|
)}
|
|
{error && (
|
|
<Text size="T300" style={{ color: color.Critical.Main, padding: 16 }}>
|
|
{error.message}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</PageNavContent>
|
|
);
|
|
}
|
|
|
|
/** The single navigator: every nav slot that lists rooms mounts this. */
|
|
export function EstateNavigator() {
|
|
const openRoomFinder = useSetAtom(searchModalAtom);
|
|
const { rootId, error } = useEstateRootId();
|
|
const mx = useMatrixClient();
|
|
const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult';
|
|
|
|
return (
|
|
<PageNav>
|
|
<PageNavHeader>
|
|
<Box alignItems="Center" grow="Yes" gap="300">
|
|
<Box grow="Yes">
|
|
<Text size="H4" truncate>
|
|
{title}
|
|
</Text>
|
|
</Box>
|
|
<IconButton
|
|
variant="Background"
|
|
aria-label="Find a room"
|
|
onClick={() => openRoomFinder(true)}
|
|
>
|
|
<Icon src={Icons.Search} size="200" />
|
|
</IconButton>
|
|
</Box>
|
|
</PageNavHeader>
|
|
{rootId ? (
|
|
<EstateTiers rootId={rootId} />
|
|
) : (
|
|
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
|
{error ? <Text size="T300">{error.message}</Text> : <Spinner />}
|
|
</Box>
|
|
)}
|
|
<Box shrink="No" alignItems="Center" justifyContent="Center" gap="200" style={{ padding: 8 }}>
|
|
<UnverifiedTab />
|
|
<InboxTab />
|
|
<SettingsTab />
|
|
</Box>
|
|
</PageNav>
|
|
);
|
|
}
|