diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx
new file mode 100644
index 00000000..211f0c3a
--- /dev/null
+++ b/src/app/features/estate/EstateNavigator.tsx
@@ -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 (
+
+
+
+
+ {room.name}
+
+
+ {state.status === AsyncStatus.Error && (
+
+ {state.error.message}
+
+ )}
+ : }
+ onClick={join}
+ disabled={busy}
+ aria-label={`Join ${room.name}`}
+ >
+ Join
+
+
+
+ );
+}
+
+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 ;
+ return (
+
+ );
+}
+
+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 = () =>
+ setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key });
+
+ return (
+
+
+
+ {label}
+
+
+ {!closed && children}
+
+ );
+}
+
+function Tier({ tier }: { tier: EstateTier }) {
+ const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]);
+ return (
+
+ {tier.loose.map((r) => (
+
+ ))}
+ {tier.groups.map((g) => (
+
+ {g.rooms.map((r) => (
+
+ ))}
+
+ ))}
+
+ );
+}
+
+function EstateTiers({ rootId }: { rootId: string }) {
+ const { fetching, error, tiers } = useEstate(rootId);
+ const scrollRef = useRef(null);
+
+ return (
+
+
+ {tiers.map((tier) => (
+
+ ))}
+ {fetching && (
+
+
+
+ )}
+ {error && (
+
+ {error.message}
+
+ )}
+
+
+ );
+}
+
+/** 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 (
+
+
+
+
+
+ {title}
+
+
+ navigate(getHomeSearchPath())}
+ >
+
+
+
+
+ {rootId ? (
+
+ ) : (
+
+ {error ? {error.message} : }
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts
new file mode 100644
index 00000000..0593e195
--- /dev/null
+++ b/src/app/features/estate/buildEstate.test.ts
@@ -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 => ({
+ rootId: ROOT,
+ hierarchy,
+ stewardships,
+ membership: new Map(),
+ directRoomIds: [],
+ ...over,
+});
+
+const find = (tiers: ReturnType, 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();
+ });
+});
diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts
new file mode 100644
index 00000000..096ea9ad
--- /dev/null
+++ b/src/app/features/estate/buildEstate.ts
@@ -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;
+
+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;
+ directRoomIds: readonly string[];
+ /** Display names for Minds, keyed by localpart. */
+ mindLabels?: ReadonlyMap;
+};
+
+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
+): 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();
+
+ 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;
+};
diff --git a/src/app/features/estate/index.ts b/src/app/features/estate/index.ts
new file mode 100644
index 00000000..761586c4
--- /dev/null
+++ b/src/app/features/estate/index.ts
@@ -0,0 +1 @@
+export * from './EstateNavigator';
diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts
new file mode 100644
index 00000000..e063b337
--- /dev/null
+++ b/src/app/features/estate/joinEstateRoom.test.ts
@@ -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, {
+ 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);
+ });
+});
diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts
new file mode 100644
index 00000000..aedfc70b
--- /dev/null
+++ b/src/app/features/estate/joinEstateRoom.ts
@@ -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;
+
+/**
+ * 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
+): Promise => {
+ 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' };
+ }
+};
diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts
new file mode 100644
index 00000000..3968d974
--- /dev/null
+++ b/src/app/features/estate/useEstate.ts
@@ -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()
+ : undefined;
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [mx, rootId, stewardshipsVersion, joined, invited]
+ );
+
+ const tiers = useMemo(() => {
+ const membership = new Map();
+ invited.forEach((id) => membership.set(id, 'invite'));
+ joined.forEach((id) => membership.set(id, 'join'));
+ const domain = mx.getDomain();
+ const mindLabels = new Map();
+ 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 };
+};
diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts
index ad34e3f4..59889c1c 100644
--- a/src/app/hooks/useSpaceHierarchy.ts
+++ b/src/app/hooks/useSpaceHierarchy.ts
@@ -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) => {
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 4de42081..ae5eac70 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -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)
-
-
-
- }
- >
+
@@ -159,9 +153,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={
-
-
+
}
>
@@ -186,9 +178,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={
-
-
+
}
>
@@ -212,9 +202,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
-
-
+
}
>
diff --git a/src/app/pages/client/EstatePageNav.tsx b/src/app/pages/client/EstatePageNav.tsx
new file mode 100644
index 00000000..470cbdd7
--- /dev/null
+++ b/src/app/pages/client/EstatePageNav.tsx
@@ -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 (
+
+
+
+ );
+}