Estate: stewards from the hierarchy's Space child content, joins wait for the store, room finder on search, Create room and Join with address, Elsewhere group, invited DMs

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>
This commit is contained in:
Meta Vens 2026-10-31 17:46:11 +01:00
commit 0b7ded9c99
10 changed files with 601 additions and 102 deletions

76
harness/estate/drive.js Normal file
View file

@ -0,0 +1,76 @@
// Drives the built client against fakehs.js on a phone viewport and asserts the estate navigator.
const { chromium } = require('playwright');
const fs = require('fs');
const out = (n) => `/out/${n}`;
const tl = [];
const fails = [];
const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails.push(what); };
(async () => {
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
const pg = await ctx.newPage();
await pg.goto('http://127.0.0.1:8080/');
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
await pg.goto('http://127.0.0.1:8080/#/home/');
await pg.getByText('Create Room').first().waitFor({ timeout: 30000 });
await pg.waitForTimeout(2000);
const text = () => pg.evaluate(() => document.body.innerText);
const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); };
const open = async (name) => { const h = pg.getByText(name, { exact: true }); if (await h.count()) { await h.first().click(); await pg.waitForTimeout(300); } };
// Entry points
const t0 = await text();
check(/Create Room/.test(t0) && /Join with Address/.test(t0), 'Create Room and Join with Address are navigator actions');
await pg.getByText('Join with Address').first().click();
await pg.waitForTimeout(400);
check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt');
await pg.keyboard.press('Escape');
await pg.waitForTimeout(300);
if (await pg.locator('input[name="addressInput"]').count()) await pg.getByText('Cancel').first().click();
// Header search is the room finder, on a phone
await pg.locator('[aria-label="Find a room"]').first().click();
await pg.waitForTimeout(500);
check((await pg.locator('input[placeholder="Search"]').count()) === 1 && !/#\/home\/search/.test(pg.url()), 'header search opens the room finder, not message search');
await snap('finder');
await pg.keyboard.press('Escape');
await pg.waitForTimeout(300);
// Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM
for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n);
await snap('expanded');
const t1 = (await text()).toLowerCase();
check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state');
check(t1.includes('elsewhere') && t1.includes('meeting'), 'a joined room outside the Space is listed under Elsewhere');
check(t1.includes('direct messages') && (await pg.locator('[aria-label="Join Void"]').count()) === 1, 'an invited DM is listed with Join');
// Join: no invite card at any frame, then the room
await pg.locator('[aria-label="Join Aetheria"]').first().click();
const t = Date.now();
let card = 0, room = null;
while (Date.now() - t < 6000) {
const body = await text();
if (/Summary card/.test(body)) card++;
if (room === null && /welcome to Aetheria/.test(body)) room = Date.now() - t;
await pg.waitForTimeout(40);
}
await snap('joined');
check(card === 0, `no invite card at any frame after the tap (saw ${card})`);
check(room !== null, `the room opened (after ${room} ms)`);
// Join the DM: lands in the room too
await pg.goto('http://127.0.0.1:8080/#/home/');
await pg.waitForTimeout(3000);
await open('Direct Messages');
if (await pg.locator('[aria-label="Join Void"]').count()) {
await pg.locator('[aria-label="Join Void"]').first().click();
let dmCard = 0; const t2 = Date.now();
while (Date.now() - t2 < 5000) { if (/Summary card/.test(await text())) dmCard++; await pg.waitForTimeout(40); }
check(dmCard === 0 && /\/direct\//.test(pg.url()), 'the invited DM opens in Direct with no invite card');
}
await b.close();
})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); fails.push('error'); }).finally(() => {
fs.writeFileSync(out('timeline.txt'), tl.join('\n'));
console.log(tl.join('\n'));
process.exitCode = fails.length ? 1 : 0;
});

108
harness/estate/fakehs.js Normal file
View file

@ -0,0 +1,108 @@
// Fake Matrix homeserver + static server for the built client (harness/estate/run.sh).
// Scenario: an invitee to the GameCult Space and 6 project rooms (never a member of the
// Space, so the client cannot read its state), joined to #general and to #meeting, a room
// outside the Space, and invited to a DM. The steward of each project room is carried only
// in the m.space.child content of the hierarchy, as the accounts script writes it.
const http = require('http'), fs = require('fs'), path = require('path');
const DIST = process.argv[2], PORT = 8080, ME = '@t:test';
const log = [];
const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
const DM = '!dm:test', MEETING = '!meeting:test';
const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) }));
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test';
const pendingJoin = [];
let ev = 0;
const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() });
const strip = (id, name, space, dm) => [
{ type: 'm.room.create', state_key: '', sender: '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
...(name ? [{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }] : []),
...(dm ? [{ type: 'm.room.member', state_key: '@void:test', sender: '@void:test', content: { membership: 'join', displayname: 'Void' } }] : []),
{ type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } },
{ type: 'm.room.member', state_key: ME, sender: dm ? '@void:test' : '@admin:test', content: dm ? { membership: 'invite', is_direct: true } : { membership: 'invite' } },
];
const joinedRoom = (id, name, extra = [], space = false) => ({
state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] },
timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false },
ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {},
});
const invites = () => {
const o = {};
o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } };
o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } };
o[OP] = { invite_state: { events: strip(OP, 'Operator') } };
PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } }));
o[DM] = { invite_state: { events: strip(DM, null, false, true) } };
return o;
};
const child = (id, alias) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(STEWARD[alias] ? { 'org.gamecult.steward': { repo: 'GameCult/' + alias, steward: '@' + STEWARD[alias] + ':test', mind: STEWARD[alias], source: 'x' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 });
const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra });
const hierarchy = () => [
hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PSP)] }),
hroom(GEN, 'General', '#general:test'),
hroom(OP, 'Operator', '#operator:test'),
hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, r.alias.slice(1).split(':')[0])) }),
...PROJ.map((r) => hroom(r.id, r.name, r.alias)),
];
let syncN = 1;
const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); };
const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n'));
http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname);
if (req.method === 'OPTIONS') return json(res, 200, {});
if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) {
let f = path.join(DIST, p === '/' ? 'index.html' : p);
if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html');
res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' });
return fs.createReadStream(f).pipe(res);
}
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
log.push(`${Date.now()} ${req.method} ${p}`);
if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
if (p.endsWith('/sync')) {
if (!u.searchParams.get('since')) log.push(`${Date.now()} INITIAL SYNC`);
const since = u.searchParams.get('since');
if (!since) {
const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[MEETING] = joinedRoom(MEETING, 'Meeting');
const inv = invites();
return json(res, 200, { next_batch: 's1', rooms: { join, invite: inv, leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
}
const wait = () => {
if (pendingJoin.length) {
const id = pendingJoin.shift(); const r = PROJ.find((x) => x.id === id) || { name: id === DM ? 'Void' : id };
const join = {}; join[id] = joinedRoom(id, r.name);
log.push(`${Date.now()} SYNC delivers join ${id}`);
return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } });
}
return json(res, 200, { next_batch: since, rooms: {} });
};
return setTimeout(wait, pendingJoin.length ? 2500 : 1500);
}
const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/);
if (req.method === 'POST' && jm) {
const id = jm[1];
log.push(`${Date.now()} JOIN ${id}`);
if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' });
pendingJoin.push(id);
return json(res, 200, { room_id: id });
}
if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() });
if (p.includes('/directory/room/')) return json(res, 200, { room_id: ROOT, servers: ['test'] });
if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } });
if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} });
if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } });
if (p.includes('/filter')) return json(res, 200, { filter_id: '1' });
if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {});
if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' });
if (p.includes('/members')) return json(res, 200, { chunk: [] });
if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' });
if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} });
if (p.includes('/devices')) return json(res, 200, { devices: [] });
if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' });
if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] });
return json(res, 200, {});
});
}).listen(PORT, () => console.log('fakehs up'));
setInterval(flush, 500);

15
harness/estate/run.sh Normal file
View file

@ -0,0 +1,15 @@
# Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable:
# bash /src/harness/estate/run.sh
set -e
cd /src
mkdir -p /out
npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1
npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log
printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1
cp /src/harness/estate/drive.js /tmp/pw/
HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.js /src/dist > /out/hs.out 2>&1 &
HS=$!
sleep 2
node /tmp/pw/drive.js; echo DRIVE_RC=$?
kill $HS

View file

@ -1,18 +1,35 @@
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 { 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 { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav';
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,
getHomeSearchPath,
getSpaceRoomPath,
withSearchParam,
} from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
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';
@ -26,12 +43,15 @@ 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 { 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.parentId === null) return getDirectRoomPath(alias);
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);
@ -44,8 +64,19 @@ function JoinRow({ room }: { room: EstateRoom }) {
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])
);
@ -91,7 +122,7 @@ function EstateRow({ room }: { room: EstateRoom }) {
<RoomNavItem
room={mxRoom}
selected={selectedRoomId === room.roomId}
direct={room.parentId === null}
direct={room.direct}
linkPath={estateRoomPath(mx, room)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
@ -157,6 +188,64 @@ function Tier({ tier }: { tier: EstateTier }) {
);
}
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);
@ -164,6 +253,7 @@ function EstateTiers({ rootId }: { rootId: string }) {
return (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<EntryPoints />
{tiers.map((tier) => (
<Tier key={tier.id} tier={tier} />
))}
@ -184,7 +274,7 @@ function EstateTiers({ rootId }: { rootId: string }) {
/** The single navigator: every nav slot that lists rooms mounts this. */
export function EstateNavigator() {
const navigate = useNavigate();
const openRoomFinder = useSetAtom(searchModalAtom);
const { rootId, error } = useEstateRootId();
const mx = useMatrixClient();
const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult';
@ -200,8 +290,8 @@ export function EstateNavigator() {
</Box>
<IconButton
variant="Background"
aria-label="Search"
onClick={() => navigate(getHomeSearchPath())}
aria-label="Find a room"
onClick={() => openRoomFinder(true)}
>
<Icon src={Icons.Search} size="200" />
</IconButton>

View file

@ -1,14 +1,26 @@
import { describe, expect, it } from 'vitest';
import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfRoom } from './buildEstate';
import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate';
const ROOT = '!root:gamecult.org';
const PROJECTS = '!projects:gamecult.org';
const space = (id: string, name: string, children: string[]): EstateHierarchyRoom => ({
/** A Space whose child entries may carry the steward Mind, as the accounts script writes it. */
const space = (
id: string,
name: string,
children: string[],
stewards: Record<string, string> = {}
): EstateHierarchyRoom => ({
room_id: id,
name,
room_type: 'm.space',
children_state: children.map((c) => ({ state_key: c, content: { via: ['gamecult.org'] } })),
children_state: children.map((c) => ({
state_key: c,
content: {
via: ['gamecult.org'],
...(stewards[c] ? { 'org.gamecult.steward': { mind: stewards[c], repo: 'x' } } : {}),
},
})),
});
const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({
room_id: id,
@ -19,22 +31,20 @@ const room = (id: string, name: string, alias?: string): EstateHierarchyRoom =>
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',
]),
space(
PROJECTS,
'Projects',
['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org'],
{ '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'void' }
),
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,
@ -80,7 +90,7 @@ describe('buildEstate', () => {
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', () => {
it('groups rooms by steward Mind from the steward on each room's Space child entry', () => {
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']],
@ -105,12 +115,35 @@ describe('buildEstate', () => {
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('groups an invited-only user by the same child-entry stewards, with no root state', () => {
const invitee = new Map<string, 'join' | 'invite'>([
[ROOT, 'invite'],
[PROJECTS, 'invite'],
['!cultlib:gamecult.org', 'invite'],
['!aeth:gamecult.org', 'invite'],
]);
const projects = buildEstate(input({ membership: invitee })).find((t) => t.id === PROJECTS)!;
expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.state)])).toEqual([
['eureka', ['invited']],
['void', ['invited']],
]);
});
it('ignores a steward that is not a non-empty string', () => {
const odd = (steward: unknown): EstateHierarchyRoom[] => [
{
...space(ROOT, 'GameCult', ['!a:gamecult.org']),
children_state: [
{ state_key: '!a:gamecult.org', content: { 'org.gamecult.steward': { mind: steward } } },
],
},
room('!a:gamecult.org', 'A'),
];
[42, '', undefined, null].forEach((m) => {
const t = buildEstate(input({ hierarchy: odd(m) }))[0];
expect(t.groups).toEqual([]);
expect(t.loose).toHaveLength(1);
});
});
it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => {
@ -133,6 +166,7 @@ describe('buildEstate', () => {
'Workshop',
'Direct Messages',
]);
expect(tiers[3].loose[0].direct).toBe(true);
expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']);
expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null });
});
@ -158,7 +192,20 @@ describe('buildEstate', () => {
});
it('puts every room of one Mind in a single group', () => {
const tiers = buildEstate(input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } }));
const tiers = buildEstate(
input({
hierarchy: [
...hierarchy.slice(0, 2),
space(
PROJECTS,
'Projects',
['!cultlib:gamecult.org', '!aeth:gamecult.org'],
{ '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'eureka' }
),
...hierarchy.slice(3, 5),
],
})
);
const projects = tiers.find((t) => t.id === PROJECTS)!;
expect(projects.groups).toHaveLength(1);
expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([
@ -202,15 +249,68 @@ describe('buildEstate', () => {
});
});
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');
describe('stewardOfChild', () => {
it('reads the Mind from the org.gamecult.steward key of the child content', () => {
expect(stewardOfChild({ via: ['x'], 'org.gamecult.steward': { mind: 'void' } })).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();
it('has no steward without the key, content or a string Mind', () => {
expect(stewardOfChild(undefined)).toBeUndefined();
expect(stewardOfChild({ via: ['x'] })).toBeUndefined();
expect(stewardOfChild({ 'org.gamecult.steward': {} })).toBeUndefined();
});
});
describe('Elsewhere', () => {
const strays = (over: Partial<EstateInput>) =>
buildEstate(input({ membership: new Map([['!adhoc:x', 'join']]), ...over })).find(
(t) => t.id === 'elsewhere'
);
it('lists a joined non-DM room the hierarchy does not return, named from the store', () => {
const t = strays({ strayRoomIds: ['!adhoc:x'], roomNames: new Map([['!adhoc:x', 'Meeting']]) })!;
expect(t.label).toBe('Elsewhere');
expect(t.loose).toEqual([
{ roomId: '!adhoc:x', name: 'Meeting', state: 'joined', via: [], parentId: null, direct: false },
]);
});
it('does not repeat a room the hierarchy lists or a DM, and is last before Direct', () => {
const tiers = buildEstate(
input({
membership: new Map([
['!general:gamecult.org', 'join'],
['!dm:x', 'join'],
['!adhoc:x', 'join'],
]),
strayRoomIds: ['!general:gamecult.org', '!dm:x', '!adhoc:x'],
directRoomIds: ['!dm:x'],
})
);
expect(tiers.map((t) => t.id)).toEqual([ROOT, PROJECTS, 'elsewhere', 'direct']);
expect(tiers[2].loose.map((r) => r.roomId)).toEqual(['!adhoc:x']);
});
it('never lists a room the user has not joined', () => {
expect(strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] })).toBeUndefined();
});
it('is absent when there are no strays', () => {
expect(strays({})).toBeUndefined();
});
});
describe('direct invites', () => {
it('lists an invited DM in the Direct tier as invited, named from the store', () => {
const tiers = buildEstate(
input({
membership: new Map([['!dm:x', 'invite']]),
directRoomIds: ['!dm:x'],
roomNames: new Map([['!dm:x', 'Void']]),
})
);
expect(tiers[tiers.length - 1].loose).toEqual([
{ roomId: '!dm:x', name: 'Void', state: 'invited', via: [], parentId: null, direct: true },
]);
});
});

View file

@ -4,7 +4,12 @@
*
* 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.
* a room it returns is never hidden by the client. The one licensed addition is
* the user's own joined rooms that sit outside the Space (the Elsewhere tier).
*
* A room's steward is read from the hierarchy too: the content of the
* m.space.child entry that lists the room carries org.gamecult.steward, which an
* invited-only member can read (she cannot read the Space's state).
*/
export type EstateHierarchyRoom = {
@ -12,11 +17,16 @@ export type EstateHierarchyRoom = {
name?: string;
canonical_alias?: string;
room_type?: string;
children_state?: { state_key: string; content?: { via?: string[] } }[];
children_state?: { state_key: string; content?: SpaceChildContent }[];
};
/** Mind localpart to the repos she stewards, as projected on the root Space. */
export type Stewardships = Record<string, unknown>;
export const STEWARD_KEY = 'org.gamecult.steward';
export type SpaceChildContent = {
via?: string[];
/** The Mind who stewards the room, as the accounts script writes it. */
[STEWARD_KEY]?: { mind?: unknown };
};
export type EstateMembership = 'joined' | 'invited' | 'joinable';
@ -26,8 +36,9 @@ export type EstateRoom = {
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. */
/** The Space tier the room sits in; null for direct messages and Elsewhere rooms. */
parentId: string | null;
direct: boolean;
};
export type EstateGroup = {
@ -48,38 +59,27 @@ 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'>;
/** Joined and invited direct messages. */
directRoomIds: readonly string[];
/** Joined non-DM, non-Space rooms, whether or not the hierarchy lists them. */
strayRoomIds?: readonly string[];
/** Names for rooms the hierarchy does not name (direct and stray rooms). */
roomNames?: ReadonlyMap<string, string>;
/** Display names for Minds, keyed by localpart. */
mindLabels?: ReadonlyMap<string, string>;
};
export const DIRECT_TIER_ID = 'direct';
export const ELSEWHERE_TIER_ID = 'elsewhere';
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;
/** The Mind a Space child entry names as the room's steward, if any. */
export const stewardOfChild = (content: SpaceChildContent | undefined): string | undefined => {
const mind = content?.[STEWARD_KEY]?.mind;
return typeof mind === 'string' && mind !== '' ? mind : undefined;
};
const stateOf = (
@ -97,9 +97,10 @@ const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alia
export const buildEstate = ({
rootId,
hierarchy,
stewardships,
membership,
directRoomIds,
strayRoomIds = [],
roomNames,
mindLabels,
}: EstateInput): EstateTier[] => {
const direct = new Set(directRoomIds);
@ -117,15 +118,16 @@ export const buildEstate = ({
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 content = space.children_state?.find((c) => c.state_key === r.room_id)?.content;
const room: EstateRoom = {
roomId: r.room_id,
name: roomLabel(r),
state: stateOf(r.room_id, membership),
via,
via: content?.via ?? [],
parentId: space.room_id,
direct: false,
};
const mind = stewardOfRoom(r.canonical_alias, stewardships);
const mind = stewardOfChild(content);
if (!mind) {
tier.loose.push(room);
return;
@ -149,14 +151,36 @@ export const buildEstate = ({
label: 'Direct Messages',
loose: directRoomIds.map((roomId) => ({
roomId,
name: roomId,
name: roomNames?.get(roomId) ?? roomId,
state: stateOf(roomId, membership),
via: [],
parentId: null,
direct: true,
})),
groups: [],
};
const listedIds = new Set(hierarchy.map((r) => r.room_id));
const elsewhereTier: EstateTier = {
id: ELSEWHERE_TIER_ID,
label: 'Elsewhere',
loose: strayRoomIds
.filter((id) => !listedIds.has(id) && !direct.has(id) && membership.get(id) === 'join')
.map((roomId) => ({
roomId,
name: roomNames?.get(roomId) ?? roomId,
state: 'joined' as const,
via: [],
parentId: null,
direct: false,
})),
groups: [],
};
const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0);
return directTier.loose.length > 0 ? [...listed, directTier] : listed;
return [
...listed,
...(elsewhereTier.loose.length > 0 ? [elsewhereTier] : []),
...(directTier.loose.length > 0 ? [directTier] : []),
];
};

View file

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { activateEstateRoom } from './joinEstateRoom';
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
const room = (state: 'joined' | 'invited' | 'joinable') => ({
roomId: '!r:gamecult.org',
@ -71,3 +71,55 @@ describe('activateEstateRoom', () => {
expect(joinRoom).toHaveBeenCalledTimes(1);
});
});
describe('awaitMyJoin', () => {
/** A store that gains the room when the test says so, emitting like the SDK. */
const store = () => {
let membership: string | null = null;
const handlers = new Set<(room: { roomId: string }, m: string) => void>();
return {
mx: {
getRoom: () => (membership ? { getMyMembership: () => membership! } : null),
on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h),
off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h),
},
handlers,
become: (roomId: string, m: string) => {
membership = m;
handlers.forEach((h) => h({ roomId }, m));
},
};
};
it('resolves at once when the store already holds the joined room', async () => {
const s = store();
s.become('!r:x', 'join');
expect(await awaitMyJoin(s.mx, '!r:x', 1000)).toBe(true);
});
it('stays pending while the store still shows an invite, then resolves on join', async () => {
const s = store();
let settled = false;
const p = awaitMyJoin(s.mx, '!r:x', 1000).then((v) => {
settled = true;
return v;
});
s.become('!r:x', 'invite');
s.become('!other:x', 'join');
await Promise.resolve();
expect(settled).toBe(false);
s.become('!r:x', 'join');
expect(await p).toBe(true);
expect(s.handlers.size).toBe(0);
});
it('gives up after the timeout and stops listening', async () => {
vi.useFakeTimers();
const s = store();
const p = awaitMyJoin(s.mx, '!r:x', 500);
await vi.advanceTimersByTimeAsync(500);
expect(await p).toBe(false);
expect(s.handlers.size).toBe(0);
vi.useRealTimers();
});
});

View file

@ -28,3 +28,36 @@ export const activateEstateRoom = async (
return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' };
}
};
type MembershipSource = {
getRoom(roomId: string): { getMyMembership(): string } | null;
on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown;
off(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown;
};
/**
* joinRoom resolves when the server accepts the join, before the client store
* holds the room; navigating then lands on the invite card. Resolves true once
* the store says the user's membership is join, false after timeoutMs.
*/
export const awaitMyJoin = (
mx: MembershipSource,
roomId: string,
timeoutMs: number
): Promise<boolean> =>
new Promise((resolve) => {
if (mx.getRoom(roomId)?.getMyMembership() === 'join') {
resolve(true);
return;
}
const finish = (joined: boolean) => {
clearTimeout(timer);
mx.off('Room.myMembership', onMembership);
resolve(joined);
};
const onMembership = (room: { roomId: string }, membership: string) => {
if (room.roomId === roomId && membership === 'join') finish(true);
};
const timer = setTimeout(() => finish(false), timeoutMs);
mx.on('Room.myMembership', onMembership);
});

View file

@ -4,14 +4,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
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 { isDirectInvite, isRoom, isUnsupportedRoom } 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, EstateHierarchyRoom, EstateTier, Stewardships } from './buildEstate';
import { buildEstate, EstateHierarchyRoom, EstateTier, stewardOfChild } from './buildEstate';
const HIERARCHY_DEPTH = 10;
@ -50,9 +49,9 @@ export type Estate = {
};
/**
* 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.
* Feeds buildEstate: the server hierarchy of the root Space (which also carries
* each room's steward), the user's membership, m.direct and direct invites, and
* her joined rooms outside the Space. Recomputes on membership and Space child changes.
*/
export const useEstate = (rootId: string): Estate => {
const mx = useMatrixClient();
@ -62,8 +61,6 @@ export const useEstate = (rootId: string): Estate => {
const invited = useAtomValue(allInvitesAtom);
const mDirects = useAtomValue(mDirectAtom);
const [stewardshipsVersion, bumpStewardships] = useForceUpdate();
const refetchHierarchy = useCallback(
() => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }),
[queryClient, rootId]
@ -73,9 +70,8 @@ export const useEstate = (rootId: string): Estate => {
useCallback(
(event) => {
if (event.getType() === StateEvent.SpaceChild) refetchHierarchy();
if (event.getType() === StateEvent.GameCultStewardships) bumpStewardships();
},
[refetchHierarchy, bumpStewardships]
[refetchHierarchy]
)
);
// What the server lets this user see changes with invitations.
@ -83,38 +79,44 @@ export const useEstate = (rootId: string): Estate => {
refetchHierarchy();
}, [invited.length, refetchHierarchy]);
const stewardships = useMemo(
() => {
const rootRoom = mx.getRoom(rootId);
return rootRoom
? (getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() as
| Stewardships
| undefined)
: 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 hierarchy = Array.from(rooms.values()) as unknown as EstateHierarchyRoom[];
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);
hierarchy.forEach((r) =>
r.children_state?.forEach((c) => {
const mind = stewardOfChild(c.content);
const name = mind && mx.getUser(`@${mind}:${domain}`)?.displayName;
if (mind && name) mindLabels.set(mind, name);
})
);
const userId = mx.getUserId();
const directInvites = invited.filter((id) => isDirectInvite(mx.getRoom(id), userId));
const directRoomIds = [
...new Set([...Array.from(mDirects).filter((id) => joined.includes(id)), ...directInvites]),
];
const strayRoomIds = joined.filter((id) => {
const r = mx.getRoom(id);
return !mDirects.has(id) && isRoom(r) && !isUnsupportedRoom(r);
});
const roomNames = new Map<string, string>();
[...directInvites, ...strayRoomIds].forEach((id) => {
const name = mx.getRoom(id)?.name;
if (name) roomNames.set(id, name);
});
return buildEstate({
rootId,
hierarchy: Array.from(rooms.values()) as unknown as EstateHierarchyRoom[],
stewardships,
hierarchy,
membership,
directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)),
directRoomIds,
strayRoomIds,
roomNames,
mindLabels,
});
}, [mx, rootId, rooms, stewardships, joined, invited, mDirects]);
}, [mx, rootId, rooms, joined, invited, mDirects]);
return { fetching, error, tiers };
};

View file

@ -36,7 +36,6 @@ export enum StateEvent {
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
SpaceChild = 'm.space.child',
GameCultStewardships = 'org.gamecult.stewardships',
SpaceParent = 'm.space.parent',
PoniesRoomEmotes = 'im.ponies.room_emotes',