diff --git a/harness/estate/drive.js b/harness/estate/drive.js new file mode 100644 index 00000000..674cb15e --- /dev/null +++ b/harness/estate/drive.js @@ -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; +}); diff --git a/harness/estate/fakehs.js b/harness/estate/fakehs.js new file mode 100644 index 00000000..641a21f4 --- /dev/null +++ b/harness/estate/fakehs.js @@ -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); diff --git a/harness/estate/run.sh b/harness/estate/run.sh new file mode 100644 index 00000000..085b17dc --- /dev/null +++ b/harness/estate/run.sh @@ -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 diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 811f531b..90b3e529 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -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, 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 }) { @@ -157,6 +188,64 @@ function Tier({ tier }: { tier: EstateTier }) { ); } +function EntryPoints() { + const navigate = useNavigate(); + return ( + + + navigate(getHomeCreatePath())}> + + + + + + + Create Room + + + + + + + {(open, setOpen) => ( + <> + + setOpen(true)}> + + + + + + + Join with Address + + + + + + {open && ( + setOpen(false)} + onOpen={(roomIdOrAlias, viaServers, eventId) => { + setOpen(false); + const path = getHomeRoomPath(roomIdOrAlias, eventId); + navigate( + viaServers + ? withSearchParam<_RoomSearchParams>(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + ); +} + function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); const scrollRef = useRef(null); @@ -164,6 +253,7 @@ function EstateTiers({ rootId }: { rootId: string }) { return ( + {tiers.map((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() { navigate(getHomeSearchPath())} + aria-label="Find a room" + onClick={() => openRoomFinder(true)} > diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 20129cbf..bdc47e5c 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -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 = {} +): 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 => ({ 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([ + [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) => + 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 }, + ]); }); }); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 9ca9048c..9edbf548 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -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; +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; + /** 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; /** Display names for Minds, keyed by localpart. */ mindLabels?: ReadonlyMap; }; 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] : []), + ]; }; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index fae21e5b..9bfddf1a 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -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(); + }); +}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 2a5ed0d8..bec13126 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -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 => + 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); + }); diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 99927ba8..d27d4710 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -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(); 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(); - 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(); + [...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 }; }; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 81d35bd0..c73c0f0b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -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',