Estate navigator: one structure-first room list from the Space hierarchy, with one-tap Join

buildEstate is the only code that decides which rooms the navigator lists, in
which tier and steward group, and whether each is joined, invited or joinable.
The server hierarchy is the access rule. The Home, Direct and Space nav slots
mount it through EstatePageNav.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 17:06:44 +01:00
commit e90b70e966
10 changed files with 757 additions and 25 deletions

View file

@ -0,0 +1,225 @@
import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAtom, useAtomValue } from 'jotai';
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import { PageNav, PageNavContent, PageNavHeader } from '../../components/page';
import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav';
import { RoomNavCategoryButton, RoomNavItem } from '../room-nav';
import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar';
import {
getDirectRoomPath,
getHomeRoomPath,
getHomeSearchPath,
getSpaceRoomPath,
} from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId } 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 } from './joinEstateRoom';
import { useEstate, useEstateRootId } from './useEstate';
const estateRoomPath = (mx: MatrixClient, room: EstateRoom): string => {
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
if (room.parentId === null) 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 result = await activateEstateRoom(mx, room);
if (result.kind === 'refused') throw new Error(result.message);
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.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.parentId === null}
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 EstateTiers({ rootId }: { rootId: string }) {
const { fetching, error, tiers } = useEstate(rootId);
const scrollRef = useRef<HTMLDivElement>(null);
return (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
{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 navigate = useNavigate();
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="Search"
onClick={() => navigate(getHomeSearchPath())}
>
<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>
);
}

View file

@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest';
import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfRoom } from './buildEstate';
const ROOT = '!root:gamecult.org';
const PROJECTS = '!projects:gamecult.org';
const space = (id: string, name: string, children: string[]): EstateHierarchyRoom => ({
room_id: id,
name,
room_type: 'm.space',
children_state: children.map((c) => ({ state_key: c, content: { via: ['gamecult.org'] } })),
});
const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({
room_id: id,
name,
canonical_alias: alias,
});
const hierarchy = [
space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]),
room('!general:gamecult.org', 'General', '#general:gamecult.org'),
space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org']),
room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'),
room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'),
room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'),
];
const stewardships = { eureka: ['GameCult/CultLib'], void: ['GameCult/aetheria'] };
const input = (over: Partial<EstateInput> = {}): EstateInput => ({
rootId: ROOT,
hierarchy,
stewardships,
membership: new Map(),
directRoomIds: [],
...over,
});
const find = (tiers: ReturnType<typeof buildEstate>, roomId: string) =>
tiers
.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])
.find((r) => r.roomId === roomId);
describe('buildEstate', () => {
it('lists an invited project room as invited', () => {
const tiers = buildEstate(input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) }));
expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited');
});
it('lists a hierarchy room the user has not joined as joinable, beside joined ones', () => {
const tiers = buildEstate(input({ membership: new Map([['!general:gamecult.org', 'join']]) }));
expect(find(tiers, '!general:gamecult.org')?.state).toBe('joined');
expect(find(tiers, '!aeth:gamecult.org')?.state).toBe('joinable');
expect(find(tiers, '!aeth:gamecult.org')?.via).toEqual(['gamecult.org']);
});
it('omits a room absent from the hierarchy even when the client store knows it', () => {
const hidden = '!estate-secret:gamecult.org';
const tiers = buildEstate(
input({
membership: new Map([[hidden, 'join']]),
// The root names the room as a child, but the server did not return it.
hierarchy: [
space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, hidden]),
...hierarchy.slice(1),
],
})
);
expect(find(tiers, hidden)).toBeUndefined();
});
it('lists every room the server returned, whatever the user may join', () => {
const tiers = buildEstate(input());
expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4);
});
it('groups rooms by steward Mind from stewardships plus canonical alias, case-insensitively', () => {
const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!;
expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([
['eureka', ['!cultlib:gamecult.org']],
['void', ['!aeth:gamecult.org']],
]);
});
it('sorts steward groups by display label', () => {
const projects = buildEstate(
input({ mindLabels: new Map([['eureka', 'Zed'], ['void', 'Alpha']]) })
).find((t) => t.id === PROJECTS)!;
expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']);
});
it('lists a room with no alias match loose in its tier', () => {
const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!;
expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']);
});
it('degrades to loose rows when the stewardships are unreadable', () => {
const projects = buildEstate(input({ stewardships: undefined })).find(
(t) => t.id === PROJECTS
)!;
expect(projects.groups).toEqual([]);
expect(projects.loose).toHaveLength(3);
});
it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => {
const second = '!second:gamecult.org';
const tiers = buildEstate(
input({
hierarchy: [
space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, second]),
...hierarchy.slice(1),
space(second, 'Workshop', ['!w1:gamecult.org']),
room('!w1:gamecult.org', 'Bench'),
],
directRoomIds: ['!dm:gamecult.org'],
membership: new Map([['!dm:gamecult.org', 'join']]),
})
);
expect(tiers.map((t) => t.label)).toEqual(['GameCult', 'Projects', 'Workshop', 'Direct Messages']);
expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']);
expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null });
});
it('is identical for identical inputs', () => {
expect(buildEstate(input())).toEqual(buildEstate(input()));
});
});
describe('stewardOfRoom', () => {
it('matches the alias localpart to the repo name, ignoring case and the org prefix', () => {
expect(stewardOfRoom('#CULTLIB:gamecult.org', stewardships)).toBe('eureka');
expect(stewardOfRoom('#aetheria:gamecult.org', stewardships)).toBe('void');
});
it('has no steward without an alias, a match or readable stewardships', () => {
expect(stewardOfRoom(undefined, stewardships)).toBeUndefined();
expect(stewardOfRoom('#unknown:gamecult.org', stewardships)).toBeUndefined();
expect(stewardOfRoom('#cultlib:gamecult.org', { eureka: 'GameCult/CultLib' })).toBeUndefined();
});
});

View file

@ -0,0 +1,162 @@
/**
* The estate index: the only code that decides which rooms the navigator lists,
* in which tier and steward group, and whether each is joined, invited or joinable.
*
* The server's /hierarchy of the root Space is the access rule. A room the
* hierarchy does not return is never listed, whatever the client store knows;
* a room it returns is never hidden by the client.
*/
export const STEWARDSHIPS_EVENT_TYPE = 'org.gamecult.stewardships';
export type EstateHierarchyRoom = {
room_id: string;
name?: string;
canonical_alias?: string;
room_type?: string;
children_state?: { state_key: string; content?: { via?: string[] } }[];
};
/** Mind localpart to the repos she stewards, as projected on the root Space. */
export type Stewardships = Record<string, unknown>;
export type EstateMembership = 'joined' | 'invited' | 'joinable';
export type EstateRoom = {
roomId: string;
name: string;
state: EstateMembership;
/** Servers to join through, from the hierarchy's children_state. */
via: string[];
/** The Space tier the room sits in; null for direct messages. */
parentId: string | null;
};
export type EstateGroup = {
mind: string;
label: string;
rooms: EstateRoom[];
};
export type EstateTier = {
id: string;
label: string;
/** Rooms without a steward. */
loose: EstateRoom[];
groups: EstateGroup[];
};
export type EstateInput = {
rootId: string;
/** Rooms from the server's hierarchy of the root, in hierarchy order. */
hierarchy: readonly EstateHierarchyRoom[];
stewardships: Stewardships | undefined;
/** The user's own membership per room id, from the client store. */
membership: ReadonlyMap<string, 'join' | 'invite'>;
directRoomIds: readonly string[];
/** Display names for Minds, keyed by localpart. */
mindLabels?: ReadonlyMap<string, string>;
};
export const DIRECT_TIER_ID = 'direct';
const SPACE_TYPE = 'm.space';
const repoName = (repo: string): string => repo.split('/').pop()!.toLowerCase();
/**
* The single place a room's steward is derived: the canonical alias localpart,
* case-insensitively, against the repo names in the stewardships lists.
* (Follow-up ratatoskr:follow_up:stewardships-carry-room-ids swaps this to room ids.)
*/
export const stewardOfRoom = (
canonicalAlias: string | undefined,
stewardships: Stewardships | undefined
): string | undefined => {
if (!canonicalAlias || !stewardships) return undefined;
const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase();
const mind = Object.keys(stewardships).find((m) => {
const repos = stewardships[m];
return Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local);
});
return mind;
};
const stateOf = (
roomId: string,
membership: ReadonlyMap<string, 'join' | 'invite'>
): EstateMembership => {
const m = membership.get(roomId);
if (m === 'join') return 'joined';
if (m === 'invite') return 'invited';
return 'joinable';
};
const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alias || r.room_id;
export const buildEstate = ({
rootId,
hierarchy,
stewardships,
membership,
directRoomIds,
mindLabels,
}: EstateInput): EstateTier[] => {
const direct = new Set(directRoomIds);
const byId = new Map(hierarchy.map((r) => [r.room_id, r]));
const spaces = hierarchy.filter((r) => r.room_type === SPACE_TYPE);
const root = byId.get(rootId);
// Root first, then every sub-Space in hierarchy order.
const tierSpaces = root ? [root, ...spaces.filter((s) => s.room_id !== rootId)] : spaces;
const tiers: EstateTier[] = tierSpaces.map((space) => {
const childIds = new Set((space.children_state ?? []).map((c) => c.state_key));
const tier: EstateTier = { id: space.room_id, label: roomLabel(space), loose: [], groups: [] };
const groups = new Map<string, EstateGroup>();
hierarchy.forEach((r) => {
if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return;
const via = space.children_state?.find((c) => c.state_key === r.room_id)?.content?.via ?? [];
const room: EstateRoom = {
roomId: r.room_id,
name: roomLabel(r),
state: stateOf(r.room_id, membership),
via,
parentId: space.room_id,
};
const mind = stewardOfRoom(r.canonical_alias, stewardships);
if (!mind) {
tier.loose.push(room);
return;
}
let group = groups.get(mind);
if (!group) {
group = { mind, label: mindLabels?.get(mind) ?? mind, rooms: [] };
groups.set(mind, group);
}
group.rooms.push(room);
});
tier.groups = [...groups.values()].sort((a, b) =>
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
);
return tier;
});
const directTier: EstateTier = {
id: DIRECT_TIER_ID,
label: 'Direct Messages',
loose: directRoomIds.map((roomId) => ({
roomId,
name: roomId,
state: stateOf(roomId, membership),
via: [],
parentId: null,
})),
groups: [],
};
const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0);
return directTier.loose.length > 0 ? [...listed, directTier] : listed;
};

View file

@ -0,0 +1 @@
export * from './EstateNavigator';

View file

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { activateEstateRoom } from './joinEstateRoom';
const room = (state: 'joined' | 'invited' | 'joinable') => ({
roomId: '!r:gamecult.org',
state,
via: ['gamecult.org'],
});
/** A client that records every call; anything but joinRoom is a peek or a side door. */
const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:gamecult.org' })) => {
const calls: string[] = [];
const mx = new Proxy({} as Record<string, unknown>, {
get: (_t, prop: string) => {
if (prop === 'joinRoom') {
return (...args: unknown[]) => {
calls.push('joinRoom');
return joinRoom(...args);
};
}
return (...args: unknown[]) => {
void args;
calls.push(prop);
throw new Error(`unexpected client call: ${prop}`);
};
},
});
return { mx: mx as never, calls, joinRoom };
};
describe('activateEstateRoom', () => {
it('opens a joined room without touching the client', async () => {
const { mx, calls } = recordingClient();
expect(await activateEstateRoom(mx, room('joined'))).toEqual({
kind: 'open',
roomId: '!r:gamecult.org',
});
expect(calls).toEqual([]);
});
it.each(['invited', 'joinable'] as const)(
'joins a %s room on the tap, through the hierarchy servers, and nothing else',
async (state) => {
const { mx, calls, joinRoom } = recordingClient();
const result = await activateEstateRoom(mx, room(state));
expect(result).toEqual({ kind: 'joined', roomId: '!r:gamecult.org' });
expect(joinRoom).toHaveBeenCalledWith('!r:gamecult.org', { viaServers: ['gamecult.org'] });
expect(calls).toEqual(['joinRoom']); // no peekInRoom, no read, no second path
}
);
it('reports the server refusal and does not decide access itself', async () => {
const refusal = Object.assign(new Error('M_FORBIDDEN'), {
data: { error: 'You are not invited to this room.' },
});
const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal));
const result = await activateEstateRoom(mx, room('joinable'));
expect(result).toEqual({ kind: 'refused', message: 'You are not invited to this room.' });
expect(joinRoom).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,29 @@
import { MatrixClient } from 'matrix-js-sdk';
import { EstateRoom } from './buildEstate';
export type EstateActivation =
| { kind: 'open'; roomId: string }
| { kind: 'joined'; roomId: string }
| { kind: 'refused'; message: string };
type JoinClient = Pick<MatrixClient, 'joinRoom'>;
/**
* What a tap on an estate row does. A joined room opens; any other room is
* joined, and only here (plus a first post in the room view). Opening a room
* never joins it and the client has no peek path. The server decides whether
* the join is allowed; its refusal is reported, the row is not hidden.
*/
export const activateEstateRoom = async (
mx: JoinClient,
room: Pick<EstateRoom, 'roomId' | 'state' | 'via'>
): Promise<EstateActivation> => {
if (room.state === 'joined') return { kind: 'open', roomId: room.roomId };
try {
const joined = await mx.joinRoom(room.roomId, { viaServers: room.via });
return { kind: 'joined', roomId: joined.roomId };
} catch (e) {
const err = e as { data?: { error?: string }; message?: string };
return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' };
}
};

View file

@ -0,0 +1,110 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { getStateEvent } from '../../utils/room';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { mDirectAtom } from '../../state/mDirectList';
import { StateEvent } from '../../../types/matrix/room';
import { buildEstate, EstateTier, Stewardships } from './buildEstate';
const HIERARCHY_DEPTH = 10;
const findLoadedRoom = (mx: MatrixClient, alias: string): string | undefined =>
mx
.getRooms()
.find((r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias))?.roomId;
/** The estate's root Space: featuredCommunities.spaces[0] from the client config. */
export const useEstateRootId = (): { rootId?: string; error?: Error } => {
const mx = useMatrixClient();
const alias = useClientConfig().featuredCommunities?.spaces?.[0];
const loaded = alias && !alias.startsWith('#') ? alias : alias && findLoadedRoom(mx, alias);
const resolved = useQuery({
queryKey: ['estate-root', alias],
queryFn: async () => (await mx.getRoomIdForAlias(alias!)).room_id,
enabled: !!alias && !loaded,
});
if (!alias) return { error: new Error('No estate root Space is configured') };
return { rootId: loaded || resolved.data, error: resolved.error ?? undefined };
};
export type Estate = {
fetching: boolean;
error: Error | null;
tiers: EstateTier[];
};
/**
* Feeds buildEstate: the server hierarchy of the root Space, the root's
* stewardships from the client store, the user's membership and m.direct.
* Recomputes on membership, Space child and stewardships changes.
*/
export const useEstate = (rootId: string): Estate => {
const mx = useMatrixClient();
const queryClient = useQueryClient();
const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(rootId, true, HIERARCHY_DEPTH);
const joined = useAtomValue(allRoomsAtom);
const invited = useAtomValue(allInvitesAtom);
const mDirects = useAtomValue(mDirectAtom);
const [stewardshipsVersion, bumpStewardships] = useForceUpdate();
const refetchHierarchy = useCallback(
() => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }),
[queryClient, rootId]
);
useStateEventCallback(
mx,
useCallback(
(event) => {
if (event.getType() === StateEvent.SpaceChild) refetchHierarchy();
if (event.getType() === StateEvent.GameCultStewardships) bumpStewardships();
},
[refetchHierarchy, bumpStewardships]
)
);
// What the server lets this user see changes with invitations.
useEffect(() => {
refetchHierarchy();
}, [invited.length, refetchHierarchy]);
const stewardships = useMemo(
() => {
const rootRoom = mx.getRoom(rootId);
return rootRoom
? getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent<Stewardships>()
: undefined;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[mx, rootId, stewardshipsVersion, joined, invited]
);
const tiers = useMemo(() => {
const membership = new Map<string, 'join' | 'invite'>();
invited.forEach((id) => membership.set(id, 'invite'));
joined.forEach((id) => membership.set(id, 'join'));
const domain = mx.getDomain();
const mindLabels = new Map<string, string>();
Object.keys(stewardships ?? {}).forEach((mind) => {
const name = mx.getUser(`@${mind}:${domain}`)?.displayName;
if (name) mindLabels.set(mind, name);
});
return buildEstate({
rootId,
hierarchy: Array.from(rooms.values()),
stewardships,
membership,
directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)),
mindLabels,
});
}, [mx, rootId, rooms, stewardships, joined, invited, mDirects]);
return { fetching, error, tiers };
};

View file

@ -276,7 +276,8 @@ export type FetchSpaceHierarchyLevelData = {
};
export const useFetchSpaceHierarchyLevel = (
roomId: string,
enable: boolean
enable: boolean,
maxDepth = 1
): FetchSpaceHierarchyLevelData => {
const mx = useMatrixClient();
const pageNoRef = useRef(0);
@ -286,13 +287,13 @@ export const useFetchSpaceHierarchyLevel = (
string[],
string | undefined
> = useCallback(
({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam),
[roomId, mx]
({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, maxDepth, false, pageParam),
[roomId, maxDepth, mx]
);
const queryResponse = useInfiniteQuery({
refetchOnMount: enable,
queryKey: [roomId, 'hierarchy_level'],
queryKey: [roomId, 'hierarchy_level', maxDepth],
initialPageParam: undefined,
queryFn: fetchLevel,
getNextPageParam: (result) => {

View file

@ -40,19 +40,19 @@ import {
getSpaceLobbyPath,
} from './pathUtils';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { HomeRouteRoomProvider, HomeSearch } from './client/home';
import { DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { Notifications, Inbox, Invites } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { Room } from '../features/room';
import { Lobby } from '../features/lobby';
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
import { EstatePageNav } from './client/EstatePageNav';
import { PageRoot } from '../components/page';
import { ScreenSize } from '../hooks/useScreenSize';
import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly';
import { MobileFriendlyPageNav } from './MobileFriendly';
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
@ -127,13 +127,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<ClientBindAtoms>
<ClientNonUIFeatures>
<CallEmbedProvider>
<ClientLayout
nav={
<MobileFriendlyClientNav>
<SidebarNav />
</MobileFriendlyClientNav>
}
>
<ClientLayout>
<Outlet />
</ClientLayout>
<CallStatusRenderer />
@ -159,9 +153,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={HOME_PATH}>
<Home />
</MobileFriendlyPageNav>
<EstatePageNav path={HOME_PATH} />
}
>
<Outlet />
@ -186,9 +178,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={DIRECT_PATH}>
<Direct />
</MobileFriendlyPageNav>
<EstatePageNav path={DIRECT_PATH} />
}
>
<Outlet />
@ -212,9 +202,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<RouteSpaceProvider>
<PageRoot
nav={
<MobileFriendlyPageNav path={SPACE_PATH}>
<Space />
</MobileFriendlyPageNav>
<EstatePageNav path={SPACE_PATH} />
}
>
<Outlet />

View file

@ -0,0 +1,15 @@
import React from 'react';
import { MobileFriendlyPageNav } from '../MobileFriendly';
import { EstateNavigator } from '../../features/estate';
/**
* Where the navigator mounts in a route's nav slot. The wide-screen placement
* question (ratatoskr:question:desktop-navigator-placement) is answered here.
*/
export function EstatePageNav({ path }: { path: string }) {
return (
<MobileFriendlyPageNav path={path}>
<EstateNavigator />
</MobileFriendlyPageNav>
);
}