Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
b33d8de956 estate tests: pin the failed-filing refusal, a non-DM refusal leaving m.direct alone, and awaitMyJoin's event name
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:04:14 +01:00
Meta Vens
a4da78edc5 estate tests: an unfiled room leaves its inviter's list empty, not the map
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:00:38 +01:00
Meta Vens
9b66b2b250 estate harness: read a row's tier from a fresh visit without toggling tiers closed; catch the retryable message before sync replaces the row
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:52:45 +01:00
Meta Vens
6950da865e estate harness: every drive asserts the outcome it shows and run.sh fails when one does not
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:37:35 +01:00
Meta Vens
8f1bb54b4b estate: every client join goes through joinEstateRoomIdOrAlias; /join reports refusals
RoomTombstone, RoomIntro and the lobby RoomItem/SpaceItem joined with mx.joinRoom
beside the pre-join filing; they now call joinEstateRoomOrThrow, which delegates
to the one path. /join hands each refusal to the user instead of discarding it.
Tests pin the via pass-through and the 400/429/499 refusal bounds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:35:35 +01:00
Meta Vens
b20b958762 estate: prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:34:49 +01:00
Meta Vens
00ec686b1b estate tests: the server-refusal and invited-room fixtures carry an errcode and a membership
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:33:06 +01:00
Meta Vens
873c0e30a2 estate harness: run the s6 route drives last 2026-10-31 22:20:42 +01:00
Meta Vens
7453a0bd32 estate: every join of a direct invite goes through the pre-join filing; only a Matrix 4xx with an errcode is a refusal
The room card's Join (deep links, permalinks, history) and /join joined
through mx.joinRoom and never filed the DM. They now go through
activateEstateRoom. A gateway 5xx, or a status with no errcode, has an unknown
outcome like a dropped connection: the entry stays and the tap is retryable.
The harness gains Soul's s6 deeplink and gw504 drives.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:20:28 +01:00
Meta Vens
2c04bbefa3 estate harness: the failure driver never waits on a hung browser close
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:51:32 +01:00
Meta Vens
cdfd43fe9f estate harness: the failure driver closes its browser on timeout; the run waits for each fake server to exit; prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:29:03 +01:00
Meta Vens
f6244a4781 estate harness: the join-failure and inbox Accept scenarios run with the committed estate run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:10:50 +01:00
Meta Vens
719f062ef1 estate: the inbox Accept joins through activateEstateRoom; a join with no server answer keeps the DM listed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:10:06 +01:00
Meta Vens
44395328e9 estate harness fake: account data is answered by the client's sync token, as a server does
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:20:37 +01:00
Meta Vens
e59cc93a2a estate harness: Soul's fake homeserver with member-state echo, and the twodm, reload and convert direct-message scenarios
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:14:19 +01:00
Meta Vens
2827699439 direct messages are listed in m.direct before the join is sent, from the invite event's is_direct; the arrival-time filer, its hook and the member-flag rewrite are deleted (Soul s4 converttoroom-refiled-by-filer, dm-mark-lost-after-member-rewrite)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:11:45 +01:00
Meta Vens
3d4fb161c3 one filer for direct messages: any joined room whose invite was marked is_direct is written to m.direct in one serialized write, on startup and on every sync; the per-tap late filer is gone (Soul s3 late-dm-filings-overwrite-each-other)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:41:38 +01:00
Meta Vens
c91d0339b5 harness: read the timeout message later in the pre-arrival window 2026-10-31 18:44:39 +01:00
Meta Vens
bfb753e3e5 harness: print the unresolved-root message 2026-10-31 18:38:43 +01:00
Meta Vens
eeb9de8f40 estate navigator: the late DM filing returns void 2026-10-31 18:36:44 +01:00
Meta Vens
98d74d564d estate navigator: define EstateError before its use 2026-10-31 18:35:36 +01:00
Meta Vens
d7afdc5208 estate navigator: 44 px tap targets on phones from one stylesheet rule over the navigator 2026-10-31 18:30:54 +01:00
Meta Vens
2e179a7768 harness: print the verdict lines 2026-10-31 18:26:52 +01:00
Meta Vens
44774a3ba1 estate navigator: entry points belong to the navigator, not the tiers; short wrapping errors; a DM joined after the timeout is filed on arrival; 44 px join targets on phones 2026-10-31 18:26:45 +01:00
Meta Vens
0091e5e90d harness: Soul's s2 scenarios (unresolved root, slow DM and room joins, tap-target inventory) 2026-10-31 18:26:18 +01:00
Meta Vens
a058d2a2c6 harness: the fake server echoes account data in the next sync
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:01:32 +01:00
Meta Vens
d07c7f2327 harness: expand Elsewhere and Direct only when still collapsed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:59:53 +01:00
Meta Vens
11f0a4d325 harness: reload instead of dismissing the address prompt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:58:01 +01:00
Meta Vens
1cb18a2795 harness: CommonJS scripts under a type-module package
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:55:38 +01:00
Meta Vens
6eae929cd9 Estate: narrow parentId before the Space route lookup; prettier; ignore harness
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:50:52 +01:00
Meta Vens
1dfe38a33d Estate: lint order fixes, test title, harness out of prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:48:31 +01:00
Meta Vens
0b7ded9c99 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>
2026-10-31 17:46:11 +01:00
Meta Vens
41527f6ba3 config.json: the GameCult Space is the featured root, so a wholesale deploy resolves the estate
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:46:10 +01:00
Meta Vens
e8efd3ce76 Delete SpaceTombstone: nothing renders it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:46:10 +01:00
Meta Vens
067cb10a90 Estate: prettier on the new tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:13:32 +01:00
Meta Vens
c848626caa Estate: prettier, and tests that kill the surviving mutants
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:11:35 +01:00
Meta Vens
4d0ee78974 Estate: lint and type fixes in the new files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:10:41 +01:00
Meta Vens
f810a83a28 Estate: keep the new files free of unresolved-SDK type errors
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:08:47 +01:00
Meta Vens
e90b70e966 Estate navigator: one structure-first room list from the Space hierarchy, with one-tap Join
buildEstate is the only code that decides which rooms the navigator lists, in
which tier and steward group, and whether each is joined, invited or joinable.
The server hierarchy is the access rule. The Home, Direct and Space nav slots
mount it through EstatePageNav.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:06:44 +01:00
Meta Vens
f2e3223c5f Delete the rail, its tabs, useSidebarItems, steward-groups and the Home, Direct and Space list panes
The navigator that replaces them lands next. SpaceTombstone moves to its own
file; the Lobby pin-to-sidebar state and the m.cinny.spaces writer go with
the rail; ClientLayout loses its nav slot.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:06:12 +01:00
53 changed files with 2029 additions and 3234 deletions

View file

@ -2,4 +2,5 @@ dist
node_modules
package.json
package-lock.json
LICENSE
LICENSE
harness

View file

@ -5,17 +5,7 @@
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
"#cinny:matrix.org",
"#community:matrix.org",
"#space:unredacted.org",
"#librewolf-community:matrix.org",
"#stickers-and-emojis:tastytea.de",
"#videogames:waywardinn.com",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"spaces": ["#gamecult:gamecult.org"],
"rooms": [
"#tuwunel:grin.hu",
"#freesoftware:matrix.org",

46
harness/estate/check.cjs Normal file
View file

@ -0,0 +1,46 @@
// Assertions for the estate drives: every drive states the outcome it exists to show and exits
// non-zero when the page or the fake homeserver's log says otherwise.
const fs = require('fs');
const fails = [];
exports.expect = (label, ok, detail = '') => {
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
if (!ok) fails.push(label);
};
/** Exit code for the drive: 4 when any assertion failed. */
exports.done = () => (fails.length ? 4 : 0);
/** The fake homeserver's request log (HSLOG is shared with the server process). */
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
/** Bodies of the account-data PUTs for m.direct, in order. */
exports.mDirectPuts = () =>
exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]);
/** Join requests the server saw for a room id. */
exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length;
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */
const tierOf = (pg, name) =>
pg.evaluate((n) => {
const s = document.body.innerText.split('\n').map((x) => x.trim());
const i = s.indexOf(n);
if (i < 0) return 'absent';
for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j];
return 'none-above';
}, name);
/**
* The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is
* opened only if the row is not already showing, so reading never closes what is open.
*/
exports.revealedTier = async (pg, name) => {
await pg.goto('http://127.0.0.1:8080/#/home/');
await pg.waitForTimeout(2500);
let t = await tierOf(pg, name);
for (const h of HEADERS) {
if (t !== 'absent') break;
const hl = pg.getByText(h, { exact: true });
if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); }
}
return t;
};
/** True when the page text matches re within ms of now (a message can be replaced by a row change). */
exports.sawText = (pg, re, ms) =>
pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false);
exports.isDm = (tier) => /^direct messages$/i.test(tier);

View file

@ -0,0 +1,72 @@
// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh
// against fakehs.cjs with the env flag each mode needs.
const { chromium } = require('playwright');
const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs');
const MODE = process.env.MODE;
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let b;
// Close the browser, but never wait on it: a hung close must not hang the run.
const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code));
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
(async () => {
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(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); });
const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); };
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 open();
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
const tierOf = (name) => revealedTier(pg, name);
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; };
const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test'));
const acceptInInbox = async (name) => {
// reach the inbox through the navigator's own Inbox tab
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000);
const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count());
if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); }
const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); }
log('URL', pg.url());
if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); }
{ const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } }
for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); }
const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120)));
const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last();
await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t());
};
if (MODE === 'pjfail') {
await tap('Friend DM'); await pg.waitForTimeout(3000);
const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200));
expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test')));
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry');
expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test')));
expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend);
} else if (MODE === 'jdrop') {
await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw);
expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200));
await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload');
expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend);
expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend);
} else if (MODE === 'admin') {
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate');
expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend);
} else if (MODE === 'inboxadmin') {
await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox');
expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend);
} else if (MODE === 'inboxreload') {
await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/');
await pg.waitForTimeout(30000); const r = await tiers('inbox reload');
expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow);
} else if (MODE === 'pjreload') {
await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open();
await pg.waitForTimeout(8000); await tiers('pjreload');
const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n);
expect('a join cut off by a reload stays joinable', n === 1, String(n));
}
await quit(done());
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });

View file

@ -0,0 +1,42 @@
const { chromium } = require('playwright');
const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs');
const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c));
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000);
(async () => {
b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
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.reload(); await pg.waitForTimeout(9000);
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; };
let joined = false;
if (MODE === 'deeplink') {
for (const route of ['direct', 'home']) {
await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000);
const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300)));
const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count());
if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; }
}
const f = await tiers('after deeplink join');
const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0]));
const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0];
expect('the deep link offers one Join button', joined, 'no Join button');
expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`);
expect('the DM is filed under Direct Messages', isDm(f), f);
} else if (MODE === 'gw504') {
const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]');
for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t());
const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw);
expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200));
await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)');
expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts()));
expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f);
}
await quit(done());
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });

48
harness/estate/drive.cjs Normal file
View file

@ -0,0 +1,48 @@
const { chromium } = require('playwright');
const { expect, done, isDm, revealedTier } = require('./check.cjs');
const MODE = process.env.MODE; // twodm | reload
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let tierBefore;
(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(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); });
const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); };
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 open();
const text = () => pg.evaluate(() => document.body.innerText);
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
const tierOf = (name) => revealedTier(pg, name);
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
if (MODE === 'twodm') {
await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM');
await pg.waitForTimeout(45000);
} else if (MODE === 'convert') {
await tap('Friend DM'); await pg.waitForTimeout(6000);
tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore);
await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000);
const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url());
await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500);
log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600)));
await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300));
await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>'')));
if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); }
await pg.waitForTimeout(8000);
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000);
} else {
await tap('Slow DM'); await pg.waitForTimeout(3000);
log('RELOAD', t()); await open();
await pg.waitForTimeout(30000);
}
const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM');
log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend);
if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); }
else if (MODE === 'convert') {
expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore);
expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend);
} else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow);
await b.close(); process.exitCode = done();
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });

127
harness/estate/fakehs.cjs Normal file
View file

@ -0,0 +1,127 @@
// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content.
// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s);
// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast);
// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve.
const http = require('http'), fs = require('fs'), path = require('path');
const DIST = process.argv[2], PORT = 8080, ME = '@t:test';
const NOROOT = process.env.NOROOT === '1';
const log = [];
const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p }));
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test';
const SLOW = new Set([PROJ[5].id, DM2, DM3]);
const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' };
PROJ.forEach((r) => (NAMES[r.id] = r.name));
const held = []; const pendingState = []; const pending = []; const adLog = []; 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, direct) => [
{ type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } },
{ type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } },
...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []),
{ type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { 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[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } };
o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } };
o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } };
return o;
};
const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, 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(PUB), child(PSP)] }),
hroom(GEN, 'General', '#general:test'),
hroom(OP, 'Operator', '#operator:test'),
hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }),
hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }),
...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', () => {
if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body);
log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`);
if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
if (p.endsWith('/sync')) {
const since = u.searchParams.get('since');
if (!since) {
const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc');
return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
}
const respond = () => {
const now = Date.now(); const join = {};
for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) {
const id = pending[i].id; pending.splice(i, 1);
const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : [];
join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id);
}
for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); }
const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks
if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } });
return json(res, 200, { next_batch: since, rooms: {} });
};
return setTimeout(respond, 300);
}
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.' });
const prior = pending.find((q) => q.id === DM2 || q.id === DM3);
pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) });
if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('<html>504 Gateway Time-out</html>'); }
if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); }
if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); }
return json(res, 200, { room_id: id });
}
const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/);
if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); }
if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); }
if (p.includes('/account_data/') && req.method === 'PUT') {
const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1';
const type = decodeURIComponent(p.split('/account_data/')[1]);
try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {}
if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); }
return json(res, 200, {});
}
if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() });
if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : 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 json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
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, 300);

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

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src:
# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs.
set -e
cd /src
npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; }
npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; }
echo BUILD_OK $(git rev-parse HEAD)
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 >/tmp/pw.log 2>&1
cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/
set +e
for m in twodm reload convert; do
HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
HS=$!; sleep 2
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
kill $HS; wait $HS 2>/dev/null; sleep 1
echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt
done
failure() { m=$1; shift
env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
HS=$!; sleep 2
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
kill $HS; wait $HS 2>/dev/null; sleep 1
echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; }
failure pjfail FAIL_MDIRECT=1
failure pjreload DELAY_MDIRECT=1
failure jdrop JOIN_DROP=1
failure admin INVITER_ADMIN=1
failure inboxadmin INVITER_ADMIN=1
failure inboxreload JOIN_DELAY=1
DRIVER=drive-routes failure deeplink
DRIVER=drive-routes failure gw504 JOIN_504=1
echo "FAILED_DRIVES:${FAILED:- none}"
[ -z "$FAILED" ]

View file

@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import { MatrixError } from 'matrix-js-sdk';
import {
Avatar,
Badge,
@ -31,6 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room';
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -197,8 +198,10 @@ export const RoomCard = as<'div', RoomCardProps>(
)
);
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers);
}, [mx, roomIdOrAlias, viaServers])
);
const joining =
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;

View file

@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { InviteUserPrompt } from '../invite-user-prompt';
import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom';
export type RoomIntroProps = {
room: Room;
@ -43,7 +44,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
const prevRoomId = createContent?.predecessor?.room_id;
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx])
);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');

View file

@ -0,0 +1,14 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
/** On phones every control inside the navigator is at least 44 px square to tap. */
export const Targets = style({});
globalStyle(`${Targets} button, ${Targets} a`, {
'@media': {
[`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: {
minHeight: 44,
minWidth: 44,
},
},
});

View file

@ -0,0 +1,326 @@
import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds';
import { PageNav, PageNavContent, PageNavHeader } from '../../components/page';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavItem,
NavItemContent,
} from '../../components/nav';
import { UseStateProvider } from '../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../components/join-address-prompt';
import { RoomNavCategoryButton, RoomNavItem } from '../room-nav';
import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar';
import {
encodeSearchParamValueArray,
getDirectRoomPath,
getHomeCreatePath,
getHomeRoomPath,
getSpaceRoomPath,
withSearchParam,
} from '../../pages/pathUtils';
import { _RoomSearchParams } from '../../pages/paths';
import { searchModalAtom } from '../../state/searchModal';
import { 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, awaitMyJoin } from './joinEstateRoom';
import { useEstate, useEstateRootId } from './useEstate';
import * as css from './EstateNavigator.css';
/** How long a tap waits for the client store to hold the room it just joined. */
const JOIN_ARRIVAL_TIMEOUT_MS = 20000;
const estateRoomPath = (mx: ReturnType<typeof useMatrixClient>, room: EstateRoom): string => {
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
if (room.direct) return getDirectRoomPath(alias);
// A Space the user has not joined has no Space route; the room still resolves from Home.
if (room.parentId && 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);
// 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))) {
// The join is accepted; when the room arrives the row becomes a joined row by itself.
throw new Error('Joined. The room is still on its way and will open from this list.');
}
navigate(estateRoomPath(mx, room));
}, [mx, navigate, room])
);
const busy = state.status === AsyncStatus.Loading;
return (
<NavItem variant="Background" radii="400">
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px', minHeight: 38 }}>
<NavItemContent>
<Text as="span" size="Inherit" truncate>
{room.name}
</Text>
</NavItemContent>
{state.status === AsyncStatus.Error && (
<Text
as="span"
size="T200"
style={{ color: color.Critical.Main, overflowWrap: 'anywhere' }}
>
{(state.error as Error).message}
</Text>
)}
<Chip
variant={room.state === 'invited' ? 'Primary' : 'Secondary'}
fill="Soft"
radii="Pill"
before={busy ? <Spinner size="100" /> : <Icon src={Icons.Plus} size="50" />}
onClick={join}
disabled={busy}
aria-label={`Join ${room.name}`}
>
<Text size="B300">Join</Text>
</Chip>
</Box>
</NavItem>
);
}
function EstateRow({ room }: { room: EstateRoom }) {
const mx = useMatrixClient();
const selectedRoomId = useSelectedRoom();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const mxRoom = room.state === 'joined' ? mx.getRoom(room.roomId) : null;
if (!mxRoom) return <JoinRow room={room} />;
return (
<RoomNavItem
room={mxRoom}
selected={selectedRoomId === room.roomId}
direct={room.direct}
linkPath={estateRoomPath(mx, room)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
);
}
type CollapsibleProps = {
categoryId: string;
label: string;
rooms: EstateRoom[];
children: ReactNode;
};
function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
const selectedRoomId = useSelectedRoom();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const callRoomId = useCallEmbed()?.roomId;
const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom());
const active = rooms.some(
(r) => r.roomId === selectedRoomId || r.roomId === callRoomId || roomToUnread.has(r.roomId)
);
// Phones start collapsed unless the section holds something live; wide screens start open.
// A tap flips that default; the flip is remembered per device and per screen class.
const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', categoryId);
const closed = (mobile && !active) !== toggled.has(key);
const handleClick: MouseEventHandler<HTMLButtonElement> = () =>
setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key });
return (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
{label}
</RoomNavCategoryButton>
</NavCategoryHeader>
{!closed && children}
</NavCategory>
);
}
function Tier({ tier }: { tier: EstateTier }) {
const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]);
return (
<Collapsible categoryId={tier.id} label={tier.label} rooms={allRooms}>
{tier.loose.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}
{tier.groups.map((g) => (
<Collapsible
key={g.mind}
categoryId={`${tier.id}|${g.mind}`}
label={g.label}
rooms={g.rooms}
>
{g.rooms.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}
</Collapsible>
))}
</Collapsible>
);
}
function EntryPoints() {
const navigate = useNavigate();
return (
<NavCategory>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => navigate(getHomeCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Text as="span" size="Inherit" truncate>
Create Room
</Text>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
<UseStateProvider initial={false}>
{(open, setOpen) => (
<>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => setOpen(true)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Link} size="100" />
</Avatar>
<Text as="span" size="Inherit" truncate>
Join with Address
</Text>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
{open && (
<JoinAddressPrompt
onCancel={() => setOpen(false)}
onOpen={(roomIdOrAlias, viaServers, eventId) => {
setOpen(false);
const path = getHomeRoomPath(roomIdOrAlias, eventId);
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path
);
}}
/>
)}
</>
)}
</UseStateProvider>
</NavCategory>
);
}
/** A short message that wraps inside the navigator; never a raw request error. */
function EstateError({ message }: { message: string }) {
return (
<Text size="T300" style={{ color: color.Critical.Main, padding: 16, overflowWrap: 'anywhere' }}>
{message}
</Text>
);
}
function EstateTiers({ rootId }: { rootId: string }) {
const { fetching, error, tiers } = useEstate(rootId);
return (
<>
{tiers.map((tier) => (
<Tier key={tier.id} tier={tier} />
))}
{fetching && (
<Box justifyContent="Center" style={{ padding: 16 }}>
<Spinner />
</Box>
)}
{error && (
<EstateError message="Could not load the room list. Check your connection and reload." />
)}
</>
);
}
/** The single navigator: every nav slot that lists rooms mounts this. */
export function EstateNavigator() {
const openRoomFinder = useSetAtom(searchModalAtom);
const { rootId, error } = useEstateRootId();
const scrollRef = useRef<HTMLDivElement>(null);
const mx = useMatrixClient();
const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult';
return (
<PageNav>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300" className={css.Targets}>
<Box grow="Yes">
<Text size="H4" truncate>
{title}
</Text>
</Box>
<IconButton
variant="Background"
aria-label="Find a room"
onClick={() => openRoomFinder(true)}
>
<Icon src={Icons.Search} size="200" />
</IconButton>
</Box>
</PageNavHeader>
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300" className={css.Targets}>
<EntryPoints />
{rootId && <EstateTiers rootId={rootId} />}
{!rootId && error && (
<EstateError message="Could not find the GameCult space. Check your connection and reload." />
)}
{!rootId && !error && (
<Box justifyContent="Center" style={{ padding: 16 }}>
<Spinner />
</Box>
)}
</Box>
</PageNavContent>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
gap="200"
style={{ padding: 8 }}
className={css.Targets}
>
<UnverifiedTab />
<InboxTab />
<SettingsTab />
</Box>
</PageNav>
);
}

View file

@ -0,0 +1,326 @@
import { describe, expect, it } from 'vitest';
import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate';
const ROOT = '!root:gamecult.org';
const PROJECTS = '!projects:gamecult.org';
/** 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'],
...(stewards[c] ? { 'org.gamecult.steward': { mind: stewards[c], repo: 'x' } } : {}),
},
})),
});
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'],
{ '!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 input = (over: Partial<EstateInput> = {}): EstateInput => ({
rootId: ROOT,
hierarchy,
membership: new Map(),
directRoomIds: [],
...over,
});
const find = (tiers: ReturnType<typeof buildEstate>, roomId: string) =>
tiers
.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])
.find((r) => r.roomId === roomId);
describe('buildEstate', () => {
it('lists an invited project room as invited', () => {
const tiers = buildEstate(
input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) })
);
expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited');
});
it('lists a hierarchy room the user has not joined as joinable, beside joined ones', () => {
const tiers = buildEstate(input({ membership: new Map([['!general:gamecult.org', 'join']]) }));
expect(find(tiers, '!general:gamecult.org')?.state).toBe('joined');
expect(find(tiers, '!aeth:gamecult.org')?.state).toBe('joinable');
expect(find(tiers, '!aeth:gamecult.org')?.via).toEqual(['gamecult.org']);
});
it('omits a room absent from the hierarchy even when the client store knows it', () => {
const hidden = '!estate-secret:gamecult.org';
const tiers = buildEstate(
input({
membership: new Map([[hidden, 'join']]),
// The root names the room as a child, but the server did not return it.
hierarchy: [
space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, hidden]),
...hierarchy.slice(1),
],
})
);
expect(find(tiers, hidden)).toBeUndefined();
});
it('lists every room the server returned, whatever the user may join', () => {
const tiers = buildEstate(input());
expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4);
});
it('groups rooms by steward Mind from the steward on each 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']],
['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('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', () => {
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[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 });
});
it('takes join servers from the matching children_state entry', () => {
const tiers = buildEstate(
input({
hierarchy: [
{
...space(ROOT, 'GameCult', ['!a:gamecult.org', '!b:gamecult.org']),
children_state: [
{ state_key: '!a:gamecult.org', content: { via: ['one.example'] } },
{ state_key: '!b:gamecult.org', content: { via: ['two.example'] } },
],
},
room('!a:gamecult.org', 'A'),
room('!b:gamecult.org', 'B'),
],
})
);
expect(find(tiers, '!a:gamecult.org')?.via).toEqual(['one.example']);
expect(find(tiers, '!b:gamecult.org')?.via).toEqual(['two.example']);
});
it('puts every room of one Mind in a single group', () => {
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([
'!cultlib:gamecult.org',
'!aeth:gamecult.org',
]);
});
it('sorts group labels without regard to case', () => {
const projects = buildEstate(
input({
mindLabels: new Map([
['eureka', 'beta'],
['void', 'Alpha'],
]),
})
).find((t) => t.id === PROJECTS)!;
expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']);
});
it('omits a tier whose Space returned no rooms and the Direct tier when there are no DMs', () => {
const tiers = buildEstate(
input({
hierarchy: [
space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]),
room('!general:gamecult.org', 'General'),
space(PROJECTS, 'Projects', ['!hidden:gamecult.org']),
],
})
);
expect(tiers.map((t) => t.id)).toEqual([ROOT]);
});
it('puts direct messages in the final tier, off any Space', () => {
const tiers = buildEstate(input({ directRoomIds: ['!dm:gamecult.org'] }));
expect(tiers[tiers.length - 1]).toMatchObject({ id: 'direct', groups: [] });
});
it('is identical for identical inputs', () => {
expect(buildEstate(input())).toEqual(buildEstate(input()));
});
});
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 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

@ -0,0 +1,186 @@
/**
* 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. 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 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 EstateHierarchyRoom = {
room_id: string;
name?: string;
canonical_alias?: string;
room_type?: string;
children_state?: { state_key: string; content?: SpaceChildContent }[];
};
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 and Elsewhere rooms. */
parentId: string | null;
direct: boolean;
};
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[];
/** 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';
/** 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 = (
roomId: string,
membership: ReadonlyMap<string, 'join' | 'invite'>
): EstateMembership => {
const m = membership.get(roomId);
if (m === 'join') return 'joined';
if (m === 'invite') return 'invited';
return 'joinable';
};
const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alias || r.room_id;
export const buildEstate = ({
rootId,
hierarchy,
membership,
directRoomIds,
strayRoomIds = [],
roomNames,
mindLabels,
}: EstateInput): EstateTier[] => {
const direct = new Set(directRoomIds);
const byId = new Map(hierarchy.map((r) => [r.room_id, r]));
const spaces = hierarchy.filter((r) => r.room_type === SPACE_TYPE);
const root = byId.get(rootId);
// Root first, then every sub-Space in hierarchy order.
const tierSpaces = root ? [root, ...spaces.filter((s) => s.room_id !== rootId)] : spaces;
const tiers: EstateTier[] = tierSpaces.map((space) => {
const childIds = new Set((space.children_state ?? []).map((c) => c.state_key));
const tier: EstateTier = { id: space.room_id, label: roomLabel(space), loose: [], groups: [] };
const groups = new Map<string, EstateGroup>();
hierarchy.forEach((r) => {
if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return;
const 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: content?.via ?? [],
parentId: space.room_id,
direct: false,
};
const mind = stewardOfChild(content);
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: 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 [
...listed,
...(elsewhereTier.loose.length > 0 ? [elsewhereTier] : []),
...(directTier.loose.length > 0 ? [directTier] : []),
];
};

View file

@ -0,0 +1,9 @@
import { readFileSync } from 'fs';
import { describe, expect, it } from 'vitest';
describe('repo config.json', () => {
it('names the GameCult Space first, so a wholesale deploy resolves the estate root', () => {
const config = JSON.parse(readFileSync('config.json', 'utf8'));
expect(config.featuredCommunities.spaces[0]).toBe('#gamecult:gamecult.org');
});
});

View file

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

View file

@ -0,0 +1,352 @@
import { describe, expect, it, vi } from 'vitest';
import {
activateEstateRoom,
awaitMyJoin,
joinEstateRoomIdOrAlias,
joinEstateRoomOrThrow,
runJoinCommand,
} from './joinEstateRoom';
const room = (state: 'joined' | 'invited' | 'joinable') => ({
roomId: '!r:gamecult.org',
state,
via: ['gamecult.org'],
});
/** A client that records every call; anything but joinRoom is a peek or a side door. */
const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:gamecult.org' })) => {
const calls: string[] = [];
const mx = new Proxy({} as Record<string, unknown>, {
get: (_t, prop: string) => {
if (prop === 'joinRoom') {
return (...args: unknown[]) => {
calls.push('joinRoom');
return joinRoom(...args);
};
}
if (prop === 'getRoom') return () => null;
if (prop === 'getSafeUserId') return () => '@me:test';
return () => {
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('falls back to the error message, then to a generic one', async () => {
const named = recordingClient(
vi
.fn()
.mockRejectedValue(
Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })
)
);
expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({
kind: 'refused',
message: 'Boom',
});
const bare = recordingClient(
vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })
);
expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({
kind: 'refused',
message: 'Could not join',
});
});
it('reports the server refusal and does not decide access itself', async () => {
const refusal = Object.assign(new Error('M_FORBIDDEN'), {
httpStatus: 403,
errcode: '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);
});
});
/** A client holding DM invites as the server sends them: the mark is in the invite event only. */
const dmClient = (opts: {
status?: number;
errcode?: string;
refuse?: boolean;
drop?: boolean;
failWrite?: boolean;
invites: Record<string, string>;
}) => {
let direct: Record<string, string[]> | undefined;
const writes: Record<string, string[]>[] = [];
const joined: string[] = [];
const joinOpts: unknown[] = [];
const mx = {
getSafeUserId: () => '@me:test',
getRoom: (roomId: string) => ({
getMyMembership: () => 'invite',
getMember: () => ({
events: {
member: {
getContent: () => ({ membership: 'invite', is_direct: true }),
getSender: () => opts.invites[roomId],
},
},
}),
}),
getAccountData: () => (direct ? { getContent: () => direct } : undefined),
setAccountData: async (_t: string, content: Record<string, string[]>) => {
if (opts.failWrite) throw new Error('account data write failed');
await new Promise((r) => {
setTimeout(r, 5);
});
writes.push(structuredClone(content));
direct = structuredClone(content);
},
joinRoom: async (roomId: string, joinArgs?: unknown) => {
joinOpts.push(joinArgs);
// the join lands only after m.direct has been written: record what it held
joined.push(JSON.stringify(direct ?? {}));
if (opts.refuse)
throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' });
if (opts.status)
throw Object.assign(new Error('Gateway Time-out'), {
httpStatus: opts.status,
errcode: opts.errcode,
});
if (opts.drop) throw new Error('fetch failed');
return { roomId };
},
};
return { mx: mx as never, writes, joined, joinOpts, current: () => direct };
};
const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] });
describe('activateEstateRoom for a direct message invite', () => {
it('lists the room under the inviter before the join is sent', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]);
});
it('keeps both rooms when two DM joins are tapped together', async () => {
const c = dmClient({ invites: { '!dm2:test': '@friend:test', '!dm3:test': '@friend:test' } });
const results = await Promise.all([
activateEstateRoom(c.mx, tap('!dm2:test')),
activateEstateRoom(c.mx, tap('!dm3:test')),
]);
expect(results.map((r) => r.kind)).toEqual(['joined', 'joined']);
expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] });
});
it('takes the room out again when the join is refused', async () => {
const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } });
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(result).toEqual({ kind: 'refused', message: 'M_FORBIDDEN' });
expect(c.current()).toEqual({ '@friend:test': [] });
});
});
describe('activateEstateRoom when the join gets no answer', () => {
it('keeps the room listed and shows a retryable message', async () => {
const c = dmClient({ drop: true, invites: { '!dm1:test': '@friend:test' } });
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(result.kind).toBe('refused');
expect((result as { message: string }).message).toMatch(/tap again to retry/);
expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] });
});
});
describe('activateEstateRoom when the filing cannot be written', () => {
it('reports the failure and sends no join', async () => {
const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } });
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(result).toEqual({ kind: 'refused', message: 'account data write failed' });
expect(c.joined).toEqual([]);
});
});
describe('activateEstateRoom when a room that is not a direct message refuses', () => {
it('reports the refusal and leaves m.direct alone', async () => {
const joinRoom = vi
.fn()
.mockRejectedValue(
Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })
);
const { mx, calls } = recordingClient(joinRoom);
const result = await activateEstateRoom(mx, room('joinable'));
expect(result.kind).toBe('refused');
expect(calls).toEqual(['joinRoom']);
});
});
describe('activateEstateRoom refusal bounds', () => {
it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => {
const c = dmClient({
status,
errcode: 'M_LIMIT_EXCEEDED',
invites: { '!dm1:test': '@friend:test' },
});
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(result.kind).toBe('refused');
expect((result as { message: string }).message).not.toMatch(/tap again to retry/);
expect(c.current()).toEqual({ '@friend:test': [] });
});
it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => {
const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } });
await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] });
});
});
describe('activateEstateRoom when a gateway answers instead of the server', () => {
it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => {
const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } });
const result = await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(result.kind).toBe('refused');
expect((result as { message: string }).message).toMatch(/tap again to retry/);
expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] });
});
it('keeps the room listed on a 5xx even when it carries an errcode', async () => {
const c = dmClient({
status: 500,
errcode: 'M_UNKNOWN',
invites: { '!dm1:test': '@friend:test' },
});
await activateEstateRoom(c.mx, tap('!dm1:test'));
expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] });
});
});
describe('joinEstateRoomIdOrAlias, the room card and /join', () => {
it('files a direct invite opened by its link before the join is sent', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']);
expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' });
expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]);
});
it('hands the via servers to the join', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']);
expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]);
});
it('joins an alias or an unheld room without touching m.direct', async () => {
const { mx, calls } = recordingClient();
await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org');
expect(calls).toEqual(['joinRoom']);
});
});
describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => {
it('files a direct invite and answers the joined room id', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test');
expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]);
});
it('throws the refusal and unfiles the room', async () => {
const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } });
await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN');
expect(c.current()).toEqual({ '@friend:test': [] });
});
});
describe('runJoinCommand, /join', () => {
it('reports each refusal and joins the others', async () => {
const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } });
const reported: string[] = [];
await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m));
expect(reported).toEqual(['M_FORBIDDEN']);
expect(c.joined).toHaveLength(1);
});
it('reports nothing when the join is accepted', async () => {
const c = dmClient({ invites: { '!dm1:test': '@friend:test' } });
const reported: string[] = [];
await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m));
expect(reported).toEqual([]);
expect(c.joined).toHaveLength(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) => {
if (e === 'Room.myMembership') handlers.add(h);
},
off: (e: string, h: (room: { roomId: string }, m: string) => void) => {
if (e === 'Room.myMembership') 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

@ -0,0 +1,151 @@
import { EstateRoom } from './buildEstate';
import {
addRoomIdToMDirect,
isRoomAlias,
isRoomId,
removeRoomIdFromMDirect,
} from '../../utils/matrix';
import { splitWithSpace } from '../../utils/common';
import { isDirectInvite } from '../../utils/room';
export type EstateActivation =
| { kind: 'open'; roomId: string }
| { kind: 'joined'; roomId: string }
| { kind: 'refused'; message: string };
const LOST_JOIN_RESPONSE =
'The server did not answer the join. It may have gone through; tap again to retry.';
type JoinClient = Parameters<typeof addRoomIdToMDirect>[0];
/**
* 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.
*
* An invite marked is_direct carries the mark in the invite event the client
* already holds, and the join event does not repeat it. So the room is listed
* in m.direct under the inviter before the join is sent, and taken out again if
* the join is refused by a Matrix error answer (a 4xx carrying an errcode). A
* join that fails any other way (lost response, dropped connection, a gateway's
* 5xx or a status with no errcode) may still have been accepted upstream, so the
* entry stays and the tap is retryable. Nothing needs to recognise the DM after it
* arrives. Every client join goes through here: the estate tap, the inbox Accept,
* the room card, /join, the lobby and space Join buttons, the tombstone and
* predecessor links. One owner for joining.
*/
export const activateEstateRoom = async (
mx: JoinClient,
room: Pick<EstateRoom, 'roomId' | 'state'> & { via?: string[] }
): Promise<EstateActivation> => {
if (room.state === 'joined') return { kind: 'open', roomId: room.roomId };
const refusal = (e: unknown): EstateActivation => {
const err = e as { data?: { error?: string }; message?: string };
return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' };
};
const refusedByServer = (e: unknown) => {
const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } };
const status = err.httpStatus;
const errcode = err.errcode ?? err.data?.errcode;
return (
typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'
);
};
const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null;
const inviter = isDirectInvite(invite, mx.getSafeUserId())
? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender()
: undefined;
try {
if (inviter) await addRoomIdToMDirect(mx, room.roomId, inviter);
} catch (e) {
return refusal(e);
}
try {
const joined = await mx.joinRoom(room.roomId, { viaServers: room.via });
return { kind: 'joined', roomId: joined.roomId };
} catch (e) {
if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE };
if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined);
return refusal(e);
}
};
/**
* activateEstateRoom for a caller that holds only an id or alias (room card,
* /join): a room the client has an invite for is an invited room.
*/
export const joinEstateRoomIdOrAlias = (
mx: JoinClient,
roomIdOrAlias: string,
via?: string[]
): Promise<EstateActivation> =>
activateEstateRoom(mx, {
roomId: roomIdOrAlias,
state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable',
via,
});
/**
* joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a
* useAsyncCallback button): the joined room id, or an Error carrying the refusal.
*/
export const joinEstateRoomOrThrow = async (
mx: JoinClient,
roomIdOrAlias: string,
via?: string[]
): Promise<string> => {
const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via);
if (result.kind === 'refused') throw new Error(result.message);
return result.roomId;
};
/**
* The /join command: every room id or alias in the payload is joined, and each
* refusal is handed to report, never dropped.
*/
export const runJoinCommand = async (
mx: JoinClient,
payload: string,
report: (message: string) => void
): Promise<void> => {
const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a));
const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a)));
results.forEach((r) => {
if (r.kind === 'refused') report(r.message);
});
};
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;
}
let finish: (joined: boolean) => void = () => undefined;
const onMembership = (room: { roomId: string }, membership: string) => {
if (room.roomId === roomId && membership === 'join') finish(true);
};
const timer = setTimeout(() => finish(false), timeoutMs);
finish = (joined) => {
clearTimeout(timer);
mx.off('Room.myMembership', onMembership);
resolve(joined);
};
mx.on('Room.myMembership', onMembership);
});

View file

@ -0,0 +1,122 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy';
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, stewardOfChild } from './buildEstate';
const HIERARCHY_DEPTH = 10;
type AliasedRoom = {
roomId: string;
getCanonicalAlias(): string | null;
getAltAliases(): string[];
};
const findLoadedRoom = (
mx: ReturnType<typeof useMatrixClient>,
alias: string
): string | undefined =>
(mx.getRooms() as AliasedRoom[]).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 (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();
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 refetchHierarchy = useCallback(
() => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }),
[queryClient, rootId]
);
useStateEventCallback(
mx,
useCallback(
(event) => {
if (event.getType() === StateEvent.SpaceChild) refetchHierarchy();
},
[refetchHierarchy]
)
);
// What the server lets this user see changes with invitations.
useEffect(() => {
refetchHierarchy();
}, [invited.length, refetchHierarchy]);
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>();
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,
membership,
directRoomIds,
strayRoomIds,
roomNames,
mindLabels,
});
}, [mx, rootId, rooms, joined, invited, mDirects]);
return { fetching, error, tiers };
};

View file

@ -200,16 +200,12 @@ type HierarchyItemMenuProps = {
joined: boolean;
powerLevels?: IPowerLevels;
canEditChild: boolean;
pinned?: boolean;
onTogglePin?: (roomId: string) => void;
};
export function HierarchyItemMenu({
item,
joined,
powerLevels,
canEditChild,
pinned,
onTogglePin,
}: HierarchyItemMenuProps) {
const mx = useMatrixClient();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
@ -264,20 +260,6 @@ export function HierarchyItemMenu({
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
{joined && (
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{onTogglePin && (
<MenuItem
size="300"
radii="300"
onClick={() => {
onTogglePin(item.roomId);
handleRequestClose();
}}
>
<Text as="span" size="T300" truncate>
{pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
</Text>
</MenuItem>
)}
<InviteMenuItem
item={item}
requestClose={handleRequestClose}

View file

@ -42,14 +42,6 @@ import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import {
makeCinnySpacesContent,
sidebarItemWithout,
useSidebarItems,
} from '../../hooks/useSidebarItems';
import { useOrphanSpaces } from '../../state/hooks/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { SpaceHierarchy } from './SpaceHierarchy';
import { useGetRoom } from '../../hooks/useGetRoom';
@ -167,18 +159,6 @@ export function Lobby() {
const screenSize = useScreenSizeContext();
const [onTop, setOnTop] = useState(true);
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
const [sidebarItems] = useSidebarItems(
useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
);
const sidebarSpaces = useMemo(() => {
const sideSpaces = sidebarItems.flatMap((item) => {
if (typeof item === 'string') return item;
return item.content;
});
return new Set(sideSpaces);
}, [sidebarItems]);
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
useElementSizeObserver(
@ -415,18 +395,6 @@ export function Lobby() {
navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
};
const togglePinToSidebar = useCallback(
(rId: string) => {
const newItems = sidebarItemWithout(sidebarItems, rId);
if (!sidebarSpaces.has(rId)) {
newItems.push(rId);
}
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any);
},
[mx, sidebarItems, sidebarSpaces]
);
return (
<PowerLevelsContextProvider value={spacePowerLevels}>
<Box grow="Yes">
@ -499,8 +467,6 @@ export function Lobby() {
disabledReorder={reordering}
nextSpaceId={nextSpaceId}
getRoom={getRoom}
pinned={sidebarSpaces.has(item.space.roomId)}
togglePinToSidebar={togglePinToSidebar}
onSpacesFound={handleSpacesFound}
onOpenRoom={handleOpenRoom}
/>

View file

@ -19,7 +19,7 @@ import {
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
import { JoinRule, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { SequenceCard } from '../../components/sequence-card';
@ -38,6 +38,7 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { ItemDraggableTarget, useDraggableItem } from './DnD';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom';
type RoomJoinButtonProps = {
roomId: string;
@ -46,8 +47,8 @@ type RoomJoinButtonProps = {
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
const [joinState, join] = useAsyncCallback<string, Error, []>(
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
);
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
@ -60,9 +61,8 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
<Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
<Box direction="Column" gap="100">
<Text style={{ wordBreak: 'break-word' }} size="T400">
{joinState.error.data?.error || joinState.error.message}
{joinState.error.message}
</Text>
<Text size="T200">{joinState.error.name}</Text>
</Box>
</Tooltip>
}
@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
size="400"
filled
tabIndex={0}
aria-label={joinState.error.data?.error || joinState.error.message}
aria-label={joinState.error.message}
/>
)}
</TooltipProvider>

View file

@ -35,8 +35,6 @@ type SpaceHierarchyProps = {
disabledReorder?: boolean;
nextSpaceId?: string;
getRoom: (roomId: string) => Room | undefined;
pinned: boolean;
togglePinToSidebar: (roomId: string) => void;
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
onOpenRoom: MouseEventHandler<HTMLButtonElement>;
};
@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
disabledReorder,
nextSpaceId,
getRoom,
pinned,
togglePinToSidebar,
onOpenRoom,
onSpacesFound,
},
@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
canEditChild={
!!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
}
pinned={pinned}
onTogglePin={togglePinToSidebar}
/>
)
}

View file

@ -18,7 +18,7 @@ import {
} from 'folds';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { MatrixError, Room } from 'matrix-js-sdk';
import { Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -38,6 +38,7 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing';
import { CreateRoomType } from '../../components/create-room/types';
import { BetaNoticeBadge } from '../../components/BetaNoticeBadge';
import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom';
function SpaceProfileLoading() {
return (
@ -112,8 +113,8 @@ function UnjoinedSpaceProfile({
}: UnjoinedSpaceProfileProps) {
const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
const [joinState, join] = useAsyncCallback<string, Error, []>(
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
);
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;

View file

@ -8,6 +8,7 @@ import { Membership } from '../../../types/matrix/room';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { getViaServers } from '../../plugins/via-servers';
import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom';
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
@ -18,16 +19,14 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
useCallback(() => {
const currentRoom = mx.getRoom(roomId);
const via = currentRoom ? getViaServers(currentRoom) : [];
return mx.joinRoom(replacementRoomId, {
viaServers: via,
});
return joinEstateRoomOrThrow(mx, replacementRoomId, via);
}, [mx, roomId, replacementRoomId])
);
const replacementRoom = mx.getRoom(replacementRoomId);
const handleOpen = () => {
if (replacementRoom) navigateRoom(replacementRoom.roomId);
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data);
};
return (

View file

@ -1,63 +0,0 @@
import { describe, expect, it } from 'vitest';
import { groupByStewards, parseSteward, StewardEntry } from './stewardGroups';
const room = (roomId: string, parentId = '!projects') => ({ roomId, parentId });
const space = (roomId: string) => ({ roomId, space: true as const });
const base = {
spaceId: '!top',
isGroupOpen: () => true,
keepVisible: () => false,
};
const names = (out: StewardEntry<{ roomId: string }>[]) =>
out.map((e) => (e.type === 'group' ? `G:${e.label}:${e.count}` : e.item.roomId));
describe('groupByStewards', () => {
const stewards: Record<string, string> = { '!a': 'Void', '!b': 'Eureka', '!c': 'Void' };
const stewardOf = (id: string) => stewards[id];
it('groups by steward, sorted by name, with unstewarded rooms first', () => {
const out = groupByStewards([room('!a'), room('!x'), room('!b'), room('!c')], {
...base,
stewardOf,
});
expect(names(out)).toEqual(['!x', 'G:Eureka:1', '!b', 'G:Void:2', '!a', '!c']);
});
it('collapsed groups hide rooms except kept-visible ones', () => {
const out = groupByStewards([room('!a'), room('!b'), room('!c')], {
...base,
stewardOf,
isGroupOpen: () => false,
keepVisible: (id) => id === '!c',
});
expect(names(out)).toEqual(['G:Eureka:1', 'G:Void:2', '!c']);
});
it('degrades to the original order when no room has steward state', () => {
const out = groupByStewards([room('!1'), space('!s'), room('!2', '!s')], {
...base,
stewardOf: () => undefined,
});
expect(names(out)).toEqual(['!1', '!s', '!2']);
});
it('does not merge runs separated by a space header', () => {
const out = groupByStewards([room('!a'), space('!s'), room('!c', '!s')], {
...base,
stewardOf,
});
expect(out.filter((e) => e.type === 'group')).toHaveLength(2);
});
});
describe('parseSteward', () => {
it('accepts a string and rejects junk', () => {
expect(parseSteward({ steward: ' Void ' })).toBe('Void');
expect(parseSteward({ steward: 3 })).toBeUndefined();
expect(parseSteward({ steward: ' ' })).toBeUndefined();
expect(parseSteward(undefined)).toBeUndefined();
expect(parseSteward(null)).toBeUndefined();
});
});

View file

@ -1,91 +0,0 @@
/**
* Splits runs of rooms that carry an `org.gamecult.steward` state event into
* collapsible per-steward groups. Pure: the caller supplies the lookups, so the
* grouping is testable without a Matrix client.
*/
export const STEWARD_EVENT_TYPE = 'org.gamecult.steward';
export type HierarchyLike = { roomId: string; parentId?: string; space?: true };
export type StewardEntry<T extends HierarchyLike> =
| { type: 'item'; item: T }
| { type: 'group'; id: string; label: string; open: boolean; count: number };
export const makeStewardGroupId = (spaceId: string, parentId: string, steward: string) =>
`${spaceId}|${parentId}|steward:${steward}`;
export type StewardGroupOptions = {
spaceId: string;
/** steward name of a room, or undefined when the room has no (valid) steward state */
stewardOf: (roomId: string) => string | undefined;
isGroupOpen: (groupId: string) => boolean;
/** rooms that stay visible inside a collapsed group (selected, unread, in a call) */
keepVisible: (roomId: string) => boolean;
};
const byLabel = (a: string, b: string) =>
a.localeCompare(b, undefined, { sensitivity: 'base' }) || a.localeCompare(b);
export const groupByStewards = <T extends HierarchyLike>(
items: T[],
{ spaceId, stewardOf, isGroupOpen, keepVisible }: StewardGroupOptions
): StewardEntry<T>[] => {
const out: StewardEntry<T>[] = [];
const flush = (run: T[]) => {
if (run.length === 0) return;
const groups = new Map<string, T[]>();
const loose: T[] = [];
run.forEach((item) => {
const steward = stewardOf(item.roomId);
if (steward === undefined) {
loose.push(item);
return;
}
const list = groups.get(steward);
if (list) list.push(item);
else groups.set(steward, [item]);
});
// No steward state anywhere in this run: leave the list exactly as Cinny built it.
if (groups.size === 0) {
run.forEach((item) => out.push({ type: 'item', item }));
return;
}
loose.forEach((item) => out.push({ type: 'item', item }));
Array.from(groups.keys())
.sort(byLabel)
.forEach((steward) => {
const rooms = groups.get(steward) ?? [];
const id = makeStewardGroupId(spaceId, rooms[0].parentId ?? spaceId, steward);
const open = isGroupOpen(id);
out.push({ type: 'group', id, label: steward, open, count: rooms.length });
rooms
.filter((item) => open || keepVisible(item.roomId))
.forEach((item) => out.push({ type: 'item', item }));
});
};
let run: T[] = [];
items.forEach((item) => {
if (item.space) {
flush(run);
run = [];
out.push({ type: 'item', item });
return;
}
run.push(item);
});
flush(run);
return out;
};
/** Reads `steward` from raw state event content; anything unusable is "no steward". */
export const parseSteward = (content: unknown): string | undefined => {
if (typeof content !== 'object' || content === null) return undefined;
const { steward } = content as { steward?: unknown };
if (typeof steward !== 'string') return undefined;
const trimmed = steward.trim();
return trimmed === '' ? undefined : trimmed;
};

View file

@ -1,71 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { MatrixClient, MatrixEvent, RoomStateEvent } from 'matrix-js-sdk';
import { atomWithStorage } from 'jotai/utils';
import { useAtom } from 'jotai';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import {
groupByStewards,
HierarchyLike,
parseSteward,
StewardEntry,
STEWARD_EVENT_TYPE,
} from './stewardGroups';
// Explicit open/closed choices by group id. Absent = default: closed on phones, open elsewhere.
const stewardGroupChoicesAtom = atomWithStorage<Record<string, boolean>>(
'gamecultStewardGroupOpen',
{}
);
type Options<T> = {
mx: MatrixClient;
spaceId: string;
items: T[];
keepVisible: (roomId: string) => boolean;
};
export const useStewardGroups = <T extends HierarchyLike>({
mx,
spaceId,
items,
keepVisible,
}: Options<T>) => {
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
const [choices, setChoices] = useAtom(stewardGroupChoicesAtom);
const [stateVersion, setStateVersion] = useState(0);
useEffect(() => {
const onState = (mEvent: MatrixEvent) => {
if (mEvent.getType() === STEWARD_EVENT_TYPE) setStateVersion((v) => v + 1);
};
mx.on(RoomStateEvent.Events, onState);
return () => {
mx.removeListener(RoomStateEvent.Events, onState);
};
}, [mx]);
const stewardOf = useCallback(
(roomId: string) =>
// stateVersion is read so a late-arriving steward event re-derives this lookup
stateVersion >= 0
? parseSteward(
mx.getRoom(roomId)?.currentState.getStateEvents(STEWARD_EVENT_TYPE, '')?.getContent()
)
: undefined,
[mx, stateVersion]
);
const isGroupOpen = useCallback((id: string) => choices[id] ?? !mobile, [choices, mobile]);
const entries: StewardEntry<T>[] = useMemo(
() => groupByStewards(items, { spaceId, stewardOf, isGroupOpen, keepVisible }),
[items, spaceId, stewardOf, isGroupOpen, keepVisible]
);
const toggleGroup = useCallback(
(id: string) => setChoices((prev) => ({ ...prev, [id]: !(prev[id] ?? !mobile) })),
[setChoices, mobile]
);
return { entries, toggleGroup };
};

View file

@ -15,7 +15,6 @@ import {
addRoomIdToMDirect,
getDMRoomFor,
guessDmRoomUserId,
isRoomAlias,
isRoomId,
isServerName,
isUserId,
@ -26,6 +25,7 @@ import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room';
import { splitWithSpace } from '../utils/common';
import { runJoinCommand } from '../features/estate/joinEstateRoom';
import { createRoomEncryptionState } from '../components/create-room';
export const SHRUG = '¯\\_(ツ)_/¯';
@ -227,15 +227,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
[Command.Join]: {
name: Command.Join,
description: 'Join room with address. Example: /join address1 address2',
exe: async (payload) => {
const rawIds = splitWithSpace(payload);
const roomIdOrAliases = rawIds.filter(
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
);
roomIdOrAliases.forEach(async (idOrAlias) => {
await mx.joinRoom(idOrAlias);
});
},
exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)),
},
[Command.Leave]: {
name: Command.Leave,

View file

@ -1,138 +0,0 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { useMatrixClient } from './useMatrixClient';
import { getAccountData, isSpace } from '../utils/room';
import { Membership } from '../../types/matrix/room';
import { useAccountDataCallback } from './useAccountDataCallback';
export type ISidebarFolder = {
name?: string;
id: string;
content: string[];
};
export type TSidebarItem = string | ISidebarFolder;
export type SidebarItems = Array<TSidebarItem>;
export type InCinnySpacesContent = {
shortcut?: string[];
sidebar?: SidebarItems;
};
export const parseSidebar = (
mx: MatrixClient,
orphanSpaces: string[],
content?: InCinnySpacesContent
) => {
const sidebar = content?.sidebar ?? content?.shortcut ?? [];
const orphans = new Set(orphanSpaces);
const items: SidebarItems = [];
const safeToAdd = (spaceId: string): boolean => {
if (typeof spaceId !== 'string') return false;
const space = mx.getRoom(spaceId);
if (space?.getMyMembership() !== Membership.Join) return false;
return isSpace(space);
};
sidebar.forEach((item) => {
if (typeof item === 'string') {
if (safeToAdd(item) && !items.includes(item)) {
orphans.delete(item);
items.push(item);
}
return;
}
if (
typeof item === 'object' &&
typeof item.id === 'string' &&
Array.isArray(item.content) &&
!items.find((i) => (typeof i === 'string' ? false : i.id === item.id))
) {
const safeContent = item.content.filter(safeToAdd);
safeContent.forEach((i) => orphans.delete(i));
items.push({
...item,
content: Array.from(new Set(safeContent)),
});
}
});
orphans.forEach((spaceId) => items.push(spaceId));
return items;
};
export const useSidebarItems = (
orphanSpaces: string[]
): [SidebarItems, Dispatch<SetStateAction<SidebarItems>>] => {
const mx = useMatrixClient();
const [sidebarItems, setSidebarItems] = useState(() => {
const inCinnySpacesContent = getAccountData(
mx,
AccountDataEvent.CinnySpaces
)?.getContent<InCinnySpacesContent>();
return parseSidebar(mx, orphanSpaces, inCinnySpacesContent);
});
useEffect(() => {
const inCinnySpacesContent = getAccountData(
mx,
AccountDataEvent.CinnySpaces
)?.getContent<InCinnySpacesContent>();
setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent));
}, [mx, orphanSpaces]);
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.CinnySpaces) {
const newContent = mEvent.getContent<InCinnySpacesContent>();
setSidebarItems(parseSidebar(mx, orphanSpaces, newContent));
}
},
[mx, orphanSpaces]
)
);
return [sidebarItems, setSidebarItems];
};
export const sidebarItemWithout = (items: SidebarItems, roomId: string) => {
const newItems: SidebarItems = items
.map((item) => {
if (typeof item === 'string') {
if (item === roomId) return null;
return item;
}
if (item.content.includes(roomId)) {
const newContent = item.content.filter((id) => id !== roomId);
if (newContent.length === 0) return null;
return {
...item,
content: newContent,
};
}
return item;
})
.filter((item) => item !== null) as SidebarItems;
return newItems;
};
export const makeCinnySpacesContent = (
mx: MatrixClient,
items: SidebarItems
): InCinnySpacesContent => {
const currentInSpaces =
getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent<InCinnySpacesContent>() ?? {};
const newSpacesContent: InCinnySpacesContent = {
...currentInSpaces,
sidebar: items,
};
return newSpacesContent;
};

View file

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

View file

@ -1,28 +1,6 @@
import { ReactNode } from 'react';
import { useMatch } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
type MobileFriendlyClientNavProps = {
children: ReactNode;
};
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
const screenSize = useScreenSizeContext();
const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true });
if (
screenSize === ScreenSize.Mobile &&
!(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)
) {
return null;
}
return children;
}
type MobileFriendlyPageNavProps = {
path: string;

View file

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

View file

@ -1,15 +1,6 @@
import React, { ReactNode } from 'react';
import { Box } from 'folds';
type ClientLayoutProps = {
nav: ReactNode;
children: ReactNode;
};
export function ClientLayout({ nav, children }: ClientLayoutProps) {
return (
<Box grow="Yes">
<Box shrink="No">{nav}</Box>
<Box grow="Yes">{children}</Box>
</Box>
);
export function ClientLayout({ children }: { children: ReactNode }) {
return <Box grow="Yes">{children}</Box>;
}

View file

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

View file

@ -1,56 +0,0 @@
import React, { useRef } from 'react';
import { Scroll } from 'folds';
import {
Sidebar,
SidebarContent,
SidebarStackSeparator,
SidebarStack,
} from '../../components/sidebar';
import {
DirectTab,
HomeTab,
SpaceTabs,
InboxTab,
ExploreTab,
SettingsTab,
UnverifiedTab,
SearchTab,
} from './sidebar';
import { CreateTab } from './sidebar/CreateTab';
export function SidebarNav() {
const scrollRef = useRef<HTMLDivElement>(null);
return (
<Sidebar>
<SidebarContent
scrollable={
<Scroll ref={scrollRef} variant="Background" size="0">
<SidebarStack>
<HomeTab />
<DirectTab />
</SidebarStack>
<SpaceTabs scrollRef={scrollRef} />
<SidebarStackSeparator />
<SidebarStack>
<ExploreTab />
<CreateTab />
</SidebarStack>
</Scroll>
}
sticky={
<>
<SidebarStackSeparator />
<SidebarStack>
<SearchTab />
<UnverifiedTab />
<InboxTab />
<SettingsTab />
</SidebarStack>
</>
}
/>
</Sidebar>
);
}

View file

@ -1,280 +0,0 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
} from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useDirectRooms } from './useDirectRooms';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../utils/notifications';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
type DirectMenuProps = {
requestClose: () => void;
};
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const orphanRooms = useDirectRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
function DirectHeader() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
Direct Messages
</Text>
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
function DirectEmpty() {
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Mention} />}
title={
<Text size="H5" align="Center">
No Direct Messages
</Text>
}
content={
<Text size="T300" align="Center">
You do not have any direct messages yet.
</Text>
}
options={
<Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
<Text size="B300" truncate>
Direct Message
</Text>
</Button>
}
/>
</NavEmptyCenter>
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
export function Direct() {
const mx = useMatrixClient();
useNavToActivePathMapper('direct');
const scrollRef = useRef<HTMLDivElement>(null);
const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const createDirectSelected = useDirectCreateSelected();
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const sortedDirects = useMemo(() => {
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
const virtualizer = useVirtualizer({
count: sortedDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
return (
<PageNav>
<DirectHeader />
{noRoomToDisplay ? (
<DirectEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createDirectSelected}>
<NavButton onClick={() => navigate(getDirectCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Create Chat
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Chats
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
showAvatar
direct
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</PageNav>
);
}

View file

@ -1,3 +1,2 @@
export * from './Direct';
export * from './RoomProvider';
export * from './DirectCreate';

View file

@ -1,362 +0,0 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
NavLink,
} from '../../../components/nav';
import {
encodeSearchParamValueArray,
getExplorePath,
getHomeCreatePath,
getHomeRoomPath,
getHomeSearchPath,
withSearchParam,
} from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
useHomeCreateSelected,
useHomeSearchSelected,
} from '../../../hooks/router/useHomeSelected';
import { useHomeRooms } from './useHomeRooms';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../utils/notifications';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
type HomeMenuProps = {
requestClose: () => void;
};
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useHomeRooms();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient();
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
function HomeHeader() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
Home
</Text>
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
function HomeEmpty() {
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Hash} />}
title={
<Text size="H5" align="Center">
No Rooms
</Text>
}
content={
<Text size="T300" align="Center">
You do not have any rooms yet.
</Text>
}
options={
<>
<Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
<Text size="B300" truncate>
Create Room
</Text>
</Button>
<Button
onClick={() => navigate(getExplorePath())}
variant="Secondary"
fill="Soft"
size="300"
>
<Text size="B300" truncate>
Explore Community Rooms
</Text>
</Button>
</>
}
/>
</NavEmptyCenter>
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
export function Home() {
const mx = useMatrixClient();
useNavToActivePathMapper('home');
const scrollRef = useRef<HTMLDivElement>(null);
const rooms = useHomeRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const selectedRoomId = useSelectedRoom();
const createRoomSelected = useHomeCreateSelected();
const searchSelected = useHomeSearchSelected();
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const sortedRooms = useMemo(() => {
const items = Array.from(rooms).sort(
closedCategories.has(DEFAULT_CATEGORY_ID)
? factoryRoomIdByActivity(mx)
: factoryRoomIdByAtoZ(mx)
);
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
const virtualizer = useVirtualizer({
count: sortedRooms.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
return (
<PageNav>
<HomeHeader />
{noRoomToDisplay ? (
<HomeEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createRoomSelected}>
<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>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Create Room
</Text>
</Box>
</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>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Join with Address
</Text>
</Box>
</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>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getHomeSearchPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Rooms
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</PageNav>
);
}

View file

@ -1,3 +1,2 @@
export * from './Home';
export * from './Search';
export * from './RoomProvider';

View file

@ -45,12 +45,8 @@ import {
} from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import { RoomAvatar } from '../../../components/room-avatar';
import {
addRoomIdToMDirect,
getMxIdLocalPart,
guessDmRoomUserId,
rateLimitedActions,
} from '../../../utils/matrix';
import { getMxIdLocalPart, rateLimitedActions } from '../../../utils/matrix';
import { activateEstateRoom } from '../../../features/estate/joinEstateRoom';
import { Time } from '../../../components/message';
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
@ -160,24 +156,20 @@ function InviteCard({
hideAvatar,
}: InviteCardProps) {
const mx = useMatrixClient();
const userId = mx.getSafeUserId();
const [viewTopic, setViewTopic] = useState(false);
const closeTopic = () => setViewTopic(false);
const openTopic = () => setViewTopic(true);
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
const dmUserId = isDirectInvite(invite.room, userId)
? guessDmRoomUserId(invite.room, userId)
: undefined;
await mx.joinRoom(invite.roomId);
if (dmUserId) {
await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
}
const result = await activateEstateRoom(mx, {
roomId: invite.roomId,
state: 'invited',
via: [],
});
if (result.kind === 'refused') throw new Error(result.message);
onNavigate(invite.roomId, invite.isSpace);
}, [mx, invite, userId, onNavigate])
}, [mx, invite, onNavigate])
);
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
useCallback(() => mx.leave(invite.roomId), [mx, invite])

View file

@ -1,134 +0,0 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { stopPropagation } from '../../../utils/keyboard';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import {
encodeSearchParamValueArray,
getCreatePath,
getSpacePath,
withSearchParam,
} from '../../pathUtils';
import { useCreateSelected } from '../../../hooks/router/useCreateSelected';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
export function CreateTab() {
const createSelected = useCreateSelected();
const navigate = useNavigate();
const [menuCords, setMenuCords] = useState<RectCords>();
const [joinAddress, setJoinAddress] = useState(false);
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
navigate(getCreatePath());
setMenuCords(undefined);
};
const handleJoinWithAddress = () => {
setJoinAddress(true);
setMenuCords(undefined);
};
return (
<SidebarItem active={createSelected}>
<SidebarItemTooltip tooltip="Add Space">
{(triggerRef) => (
<PopOut
anchor={menuCords}
position="Right"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
returnFocusOnDeactivate: false,
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column">
<SequenceCard
style={{ padding: config.space.S300 }}
variant="Surface"
direction="Column"
gap="100"
radii="0"
as="button"
type="button"
onClick={handleCreateSpace}
>
<SettingTile before={<Icon size="400" src={Icons.Space} />}>
<Text size="H6">Create Space</Text>
<Text size="T300" priority="300">
Build a space for your community.
</Text>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="Surface"
direction="Column"
gap="100"
radii="0"
as="button"
type="button"
onClick={handleJoinWithAddress}
>
<SettingTile before={<Icon size="400" src={Icons.Link} />}>
<Text size="H6">Join with Address</Text>
<Text size="T300" priority="300">
Become a part of existing community.
</Text>
</SettingTile>
</SequenceCard>
</Box>
</Menu>
</FocusTrap>
}
>
<SidebarAvatar
className={menuCords ? ContainerColor({ variant: 'Surface' }) : undefined}
as="button"
ref={triggerRef}
outlined
onClick={handleMenu}
>
<Icon src={Icons.Plus} />
</SidebarAvatar>
{joinAddress && (
<JoinAddressPrompt
onCancel={() => setJoinAddress(false)}
onOpen={(roomIdOrAlias, viaServers) => {
setJoinAddress(false);
const path = getSpacePath(roomIdOrAlias);
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path
);
}}
/>
)}
</PopOut>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -1,138 +0,0 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { useDirects } from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getDirectPath, joinPathComponent } from '../../pathUtils';
import { useRoomsUnread } from '../../../state/hooks/unread';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '../../../components/sidebar';
import { useDirectSelected } from '../../../hooks/router/useDirectSelected';
import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useDirectRooms } from '../direct/useDirectRooms';
import { markAsRead } from '../../../utils/notifications';
import { stopPropagation } from '../../../utils/keyboard';
import { settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/settings';
type DirectMenuProps = {
requestClose: () => void;
};
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useDirectRooms();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient();
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
export function DirectTab() {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const mDirects = useAtomValue(mDirectAtom);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const directUnread = useRoomsUnread(directs, roomToUnreadAtom);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const directSelected = useDirectSelected();
const handleDirectClick = () => {
const activePath = navToActivePath.get('direct');
if (activePath && screenSize !== ScreenSize.Mobile) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getDirectPath());
};
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<SidebarItem active={directSelected}>
<SidebarItemTooltip tooltip="Direct Messages">
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={handleDirectClick}
onContextMenu={handleContextMenu}
>
<Icon src={Icons.User} filled={directSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{directUnread && (
<SidebarItemBadge hasCount={directUnread.total > 0}>
<UnreadBadge highlight={directUnread.highlight > 0} count={directUnread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</SidebarItem>
);
}

View file

@ -1,64 +0,0 @@
import React from 'react';
import { Icon, Icons } from 'folds';
import { useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { useExploreSelected } from '../../../hooks/router/useExploreSelected';
import {
getExploreFeaturedPath,
getExplorePath,
getExploreServerPath,
joinPathComponent,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdServer } from '../../../utils/matrix';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
export function ExploreTab() {
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const clientConfig = useClientConfig();
const navigate = useNavigate();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const exploreSelected = useExploreSelected();
const handleExploreClick = () => {
if (screenSize === ScreenSize.Mobile) {
navigate(getExplorePath());
return;
}
const activePath = navToActivePath.get('explore');
if (activePath) {
navigate(joinPathComponent(activePath));
return;
}
if (clientConfig.featuredCommunities?.openAsDefault) {
navigate(getExploreFeaturedPath());
return;
}
const userId = mx.getUserId();
const userServer = userId ? getMxIdServer(userId) : undefined;
if (userServer) {
navigate(getExploreServerPath(userServer));
return;
}
navigate(getExplorePath());
};
return (
<SidebarItem active={exploreSelected}>
<SidebarItemTooltip tooltip="Explore Community">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleExploreClick}>
<Icon src={Icons.Explore} filled={exploreSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -1,140 +0,0 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
import { useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { useOrphanRooms } from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getHomePath, joinPathComponent } from '../../pathUtils';
import { useRoomsUnread } from '../../../state/hooks/unread';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '../../../components/sidebar';
import { useHomeSelected } from '../../../hooks/router/useHomeSelected';
import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useHomeRooms } from '../home/useHomeRooms';
import { markAsRead } from '../../../utils/notifications';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
type HomeMenuProps = {
requestClose: () => void;
};
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useHomeRooms();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient();
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
export function HomeTab() {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const homeSelected = useHomeSelected();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleHomeClick = () => {
const activePath = navToActivePath.get('home');
if (activePath && screenSize !== ScreenSize.Mobile) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getHomePath());
};
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<SidebarItem active={homeSelected}>
<SidebarItemTooltip tooltip="Home">
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={handleHomeClick}
onContextMenu={handleContextMenu}
>
<Icon src={Icons.Home} filled={homeSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{homeUnread && (
<SidebarItemBadge hasCount={homeUnread.total > 0}>
<UnreadBadge highlight={homeUnread.highlight > 0} count={homeUnread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</SidebarItem>
);
}

View file

@ -1,23 +0,0 @@
import React from 'react';
import { Icon, Icons } from 'folds';
import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { searchModalAtom } from '../../../state/searchModal';
export function SearchTab() {
const [opened, setOpen] = useAtom(searchModalAtom);
const open = () => setOpen(true);
return (
<SidebarItem active={opened}>
<SidebarItemTooltip tooltip="Search">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}>
<Icon src={Icons.Search} filled={opened} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -1,866 +0,0 @@
import React, {
MouseEventHandler,
ReactNode,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useAtom, useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import {
draggable,
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import {
attachInstruction,
extractInstruction,
Instruction,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import FocusTrap from 'focus-trap-react';
import {
useOrphanSpaces,
useRecursiveChildScopeFactory,
useSpaceChildren,
} from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
SidebarStack,
SidebarStackSeparator,
SidebarFolder,
SidebarFolderDropTarget,
} from '../../../components/sidebar';
import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider';
import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
import { UnreadBadge } from '../../../components/unread-badge';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { RoomAvatar } from '../../../components/room-avatar';
import { nameInitials, randomStr } from '../../../utils/common';
import {
ISidebarFolder,
SidebarItems,
TSidebarItem,
makeCinnySpacesContent,
parseSidebar,
sidebarItemWithout,
useSidebarItems,
} from '../../../hooks/useSidebarItems';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { markAsRead } from '../../../utils/notifications';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { getRoomAvatarUrl } from '../../../utils/room';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
type SpaceMenuProps = {
room: Room;
requestClose: () => void;
onUnpin?: (roomId: string) => void;
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
({ room, requestClose, onUnpin }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const [invitePrompt, setInvitePrompt] = useState(false);
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity));
requestClose();
};
const handleUnpin = () => {
onUnpin?.(room.roomId);
requestClose();
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
const handleRoomSettings = () => {
openSpaceSettings(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
{onUnpin && (
<MenuItem
size="300"
radii="300"
onClick={handleUnpin}
after={<Icon size="100" src={Icons.Pin} />}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unpin
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Space Settings
</Text>
</MenuItem>
</Box>
</Menu>
);
}
);
type InstructionType = Instruction['type'];
type FolderDraggable = {
folder: ISidebarFolder;
spaceId?: string;
open?: boolean;
};
type SidebarDraggable = string | FolderDraggable;
const useDraggableItem = (
item: SidebarDraggable,
targetRef: RefObject<HTMLElement>,
onDragging: (item?: SidebarDraggable) => void,
dragHandleRef?: RefObject<HTMLElement>
): boolean => {
const [dragging, setDragging] = useState(false);
useEffect(() => {
const target = targetRef.current;
const dragHandle = dragHandleRef?.current ?? undefined;
return !target
? undefined
: draggable({
element: target,
dragHandle,
getInitialData: () => ({ item }),
onDragStart: () => {
setDragging(true);
onDragging?.(item);
},
onDrop: () => {
setDragging(false);
onDragging?.(undefined);
},
});
}, [targetRef, dragHandleRef, item, onDragging]);
return dragging;
};
const useDropTarget = (
item: SidebarDraggable,
targetRef: RefObject<HTMLElement>
): Instruction | undefined => {
const [dropState, setDropState] = useState<Instruction>();
useEffect(() => {
const target = targetRef.current;
if (!target) return undefined;
return dropTargetForElements({
element: target,
canDrop: ({ source }) => {
const dragItem = source.data.item as SidebarDraggable;
return dragItem !== item;
},
getData: ({ input, element }) => {
const block: Instruction['type'][] = ['reparent'];
if (typeof item === 'object' && item.spaceId) block.push('make-child');
const insData = attachInstruction(
{},
{
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: 'standard',
block,
}
);
const instruction: Instruction | null = extractInstruction(insData);
setDropState(instruction ?? undefined);
return {
item,
instructionType: instruction ? instruction.type : undefined,
};
},
onDragLeave: () => setDropState(undefined),
onDrop: () => setDropState(undefined),
});
}, [item, targetRef]);
return dropState;
};
function useDropTargetInstruction<T extends InstructionType>(
item: SidebarDraggable,
targetRef: RefObject<HTMLElement>,
instructionType: T
): T | undefined {
const [dropState, setDropState] = useState<T>();
useEffect(() => {
const target = targetRef.current;
if (!target) return undefined;
return dropTargetForElements({
element: target,
canDrop: ({ source }) => {
const dragItem = source.data.item as SidebarDraggable;
return dragItem !== item;
},
getData: () => {
setDropState(instructionType);
return {
item,
instructionType,
};
},
onDragLeave: () => setDropState(undefined),
onDrop: () => setDropState(undefined),
});
}, [item, targetRef, instructionType]);
return dropState;
}
const useDnDMonitor = (
scrollRef: RefObject<HTMLElement>,
onDragging: (dragItem?: SidebarDraggable) => void,
onReorder: (
draggable: SidebarDraggable,
container: SidebarDraggable,
instruction: InstructionType
) => void
) => {
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) {
throw Error('Scroll element ref not configured');
}
return combine(
monitorForElements({
onDrop: ({ source, location }) => {
onDragging(undefined);
const { dropTargets } = location.current;
if (dropTargets.length === 0) return;
const item = source.data.item as SidebarDraggable;
const containerItem = dropTargets[0].data.item as SidebarDraggable;
const instructionType = dropTargets[0].data.instructionType as
| InstructionType
| undefined;
if (!instructionType) return;
onReorder(item, containerItem, instructionType);
},
}),
autoScrollForElements({
element: scrollElement,
})
);
}, [scrollRef, onDragging, onReorder]);
};
type SpaceTabProps = {
space: Room;
selected: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
folder?: ISidebarFolder;
onDragging: (dragItem?: SidebarDraggable) => void;
disabled?: boolean;
onUnpin?: (roomId: string) => void;
};
function SpaceTab({
space,
selected,
onClick,
folder,
onDragging,
disabled,
onUnpin,
}: SpaceTabProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const targetRef = useRef<HTMLDivElement>(null);
const spaceDraggable: SidebarDraggable = useMemo(
() =>
folder
? {
folder,
spaceId: space.roomId,
}
: space.roomId,
[folder, space]
);
useDraggableItem(spaceDraggable, targetRef, onDragging);
const dropState = useDropTarget(spaceDraggable, targetRef);
const dropType = dropState?.type;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<RoomUnreadProvider roomId={space.roomId}>
{(unread) => (
<SidebarItem
active={selected}
ref={targetRef}
aria-disabled={disabled}
data-drop-child={dropType === 'make-child'}
data-drop-above={dropType === 'reorder-above'}
data-drop-below={dropType === 'reorder-below'}
data-inside-folder={!!folder}
>
<SidebarItemTooltip tooltip={disabled ? undefined : space.name}>
{(triggerRef) => (
<SidebarAvatar
as="button"
data-id={space.roomId}
ref={triggerRef}
size={folder ? '300' : '400'}
onClick={onClick}
onContextMenu={handleContextMenu}
>
<RoomAvatar
roomId={space.roomId}
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
alt={space.name}
renderFallback={() => (
<Text size={folder ? 'H6' : 'H4'}>{nameInitials(space.name, 2)}</Text>
)}
/>
</SidebarAvatar>
)}
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<SpaceMenu
room={space}
requestClose={() => setMenuAnchor(undefined)}
onUnpin={onUnpin}
/>
</FocusTrap>
}
/>
)}
</SidebarItem>
)}
</RoomUnreadProvider>
);
}
type OpenedSpaceFolderProps = {
folder: ISidebarFolder;
onClose: MouseEventHandler<HTMLButtonElement>;
children?: ReactNode;
};
function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) {
const aboveTargetRef = useRef<HTMLDivElement>(null);
const belowTargetRef = useRef<HTMLDivElement>(null);
const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]);
const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above');
const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below');
return (
<SidebarFolder
state="Open"
data-drop-above={orderAbove === 'reorder-above'}
data-drop-below={orderBelow === 'reorder-below'}
>
<SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
<SidebarAvatar size="300">
<IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose}>
<Icon size="400" src={Icons.ChevronTop} filled />
</IconButton>
</SidebarAvatar>
{children}
<SidebarFolderDropTarget ref={belowTargetRef} position="Bottom" />
</SidebarFolder>
);
}
type ClosedSpaceFolderProps = {
folder: ISidebarFolder;
selected: boolean;
onOpen: MouseEventHandler<HTMLButtonElement>;
onDragging: (dragItem?: SidebarDraggable) => void;
disabled?: boolean;
};
function ClosedSpaceFolder({
folder,
selected,
onOpen,
onDragging,
disabled,
}: ClosedSpaceFolderProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const handlerRef = useRef<HTMLDivElement>(null);
const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]);
useDraggableItem(spaceDraggable, handlerRef, onDragging);
const dropState = useDropTarget(spaceDraggable, handlerRef);
const dropType = dropState?.type;
const tooltipName =
folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed';
return (
<RoomsUnreadProvider rooms={folder.content}>
{(unread) => (
<SidebarItem
active={selected}
ref={handlerRef}
aria-disabled={disabled}
data-drop-child={dropType === 'make-child'}
data-drop-above={dropType === 'reorder-above'}
data-drop-below={dropType === 'reorder-below'}
>
<SidebarItemTooltip tooltip={disabled ? undefined : tooltipName}>
{(tooltipRef) => (
<SidebarFolder data-id={folder.id} as="button" ref={tooltipRef} onClick={onOpen}>
{folder.content.map((sId) => {
const space = mx.getRoom(sId);
if (!space) return null;
return (
<SidebarAvatar key={sId} size="200" radii="300">
<RoomAvatar
roomId={space.roomId}
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
alt={space.name}
renderFallback={() => (
<Text size="Inherit">
<b>{nameInitials(space.name, 2)}</b>
</Text>
)}
/>
</SidebarAvatar>
);
})}
</SidebarFolder>
)}
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</SidebarItemBadge>
)}
</SidebarItem>
)}
</RoomsUnreadProvider>
);
}
type SpaceTabsProps = {
scrollRef: RefObject<HTMLDivElement>;
};
export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces);
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom());
const [draggingItem, setDraggingItem] = useState<SidebarDraggable>();
useDnDMonitor(
scrollRef,
setDraggingItem,
useCallback(
(item, containerItem, instructionType) => {
const newItems: SidebarItems = [];
const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => {
if (typeof sI === 'string' && typeof dI === 'string') {
return sI === dI;
}
if (typeof sI === 'object' && typeof dI === 'object') {
return sI.id === dI.folder.id;
}
return false;
};
const itemAsFolderContent = (i: SidebarDraggable): string[] => {
if (typeof i === 'string') {
return [i];
}
if (i.spaceId) {
return [i.spaceId];
}
return [...i.folder.content];
};
sidebarItems.forEach((i) => {
const sameFolders =
typeof item === 'object' &&
typeof containerItem === 'object' &&
item.folder.id === containerItem.folder.id;
// remove draggable space from current position or folder
if (!sameFolders && matchDest(i, item)) {
if (typeof item === 'object' && item.spaceId) {
const folderContent = item.folder.content.filter((s) => s !== item.spaceId);
if (folderContent.length === 0) {
// remove open state from local storage
setOpenedFolder({ type: 'DELETE', id: item.folder.id });
return;
}
newItems.push({
...item.folder,
content: folderContent,
});
}
return;
}
if (matchDest(i, containerItem)) {
// we can make child only if
// container item is space or closed folder
if (instructionType === 'make-child') {
const child: string[] = itemAsFolderContent(item);
if (typeof containerItem === 'string') {
const folder: ISidebarFolder = {
id: randomStr(),
content: [containerItem].concat(child),
};
newItems.push(folder);
return;
}
newItems.push({
...containerItem.folder,
content: containerItem.folder.content.concat(child),
});
return;
}
// drop inside opened folder
// or reordering inside same folder
if (typeof containerItem === 'object' && containerItem.spaceId) {
const child = itemAsFolderContent(item);
const newContent: string[] = [];
containerItem.folder.content
.filter((sId) => !child.includes(sId))
.forEach((sId) => {
if (sId === containerItem.spaceId) {
if (instructionType === 'reorder-below') {
newContent.push(sId, ...child);
}
if (instructionType === 'reorder-above') {
newContent.push(...child, sId);
}
return;
}
newContent.push(sId);
});
const folder = {
...containerItem.folder,
content: newContent,
};
newItems.push(folder);
return;
}
// drop above or below space or closed/opened folder
if (typeof item === 'string') {
if (instructionType === 'reorder-below') newItems.push(i);
newItems.push(item);
if (instructionType === 'reorder-above') newItems.push(i);
} else if (item.spaceId) {
if (instructionType === 'reorder-above') {
newItems.push(item.spaceId);
}
if (sameFolders && typeof i === 'object') {
// remove from folder if placing around itself
const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) };
if (newI.content.length > 0) newItems.push(newI);
} else {
newItems.push(i);
}
if (instructionType === 'reorder-below') {
newItems.push(item.spaceId);
}
} else {
if (instructionType === 'reorder-below') newItems.push(i);
newItems.push(item.folder);
if (instructionType === 'reorder-above') newItems.push(i);
}
return;
}
newItems.push(i);
});
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces]
)
);
const selectedSpaceId = useSelectedSpace();
const handleSpaceClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const targetSpaceId = target.getAttribute('data-id');
if (!targetSpaceId) return;
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId));
if (screenSize === ScreenSize.Mobile) {
navigate(spacePath);
return;
}
const activePath = navToActivePath.get(targetSpaceId);
if (activePath && activePath.pathname.startsWith(spacePath)) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
};
const handleFolderToggle: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const targetFolderId = target.getAttribute('data-id');
if (!targetFolderId) return;
setOpenedFolder({
type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT',
id: targetFolderId,
});
};
const handleUnpin = useCallback(
(roomId: string) => {
if (orphanSpaces.includes(roomId)) return;
const newItems = sidebarItemWithout(sidebarItems, roomId);
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, orphanSpaces, localEchoSidebarItem]
);
if (sidebarItems.length === 0) return null;
return (
<>
<SidebarStackSeparator />
<SidebarStack>
{sidebarItems.map((item) => {
if (typeof item === 'object') {
if (openedFolder.has(item.id)) {
return (
<OpenedSpaceFolder key={item.id} folder={item} onClose={handleFolderToggle}>
{item.content.map((sId) => {
const space = mx.getRoom(sId);
if (!space) return null;
return (
<SpaceTab
key={space.roomId}
space={space}
selected={space.roomId === selectedSpaceId}
onClick={handleSpaceClick}
folder={item}
onDragging={setDraggingItem}
disabled={
typeof draggingItem === 'object'
? draggingItem.spaceId === space.roomId
: false
}
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
/>
);
})}
</OpenedSpaceFolder>
);
}
return (
<ClosedSpaceFolder
key={item.id}
folder={item}
selected={!!selectedSpaceId && item.content.includes(selectedSpaceId)}
onOpen={handleFolderToggle}
onDragging={setDraggingItem}
disabled={
typeof draggingItem === 'object' ? draggingItem.folder.id === item.id : false
}
/>
);
}
const space = mx.getRoom(item);
if (!space) return null;
return (
<SpaceTab
key={space.roomId}
space={space}
selected={space.roomId === selectedSpaceId}
onClick={handleSpaceClick}
onDragging={setDraggingItem}
disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
/>
);
})}
</SidebarStack>
</>
);
}

View file

@ -1,8 +1,3 @@
export * from './HomeTab';
export * from './DirectTab';
export * from './SpaceTabs';
export * from './InboxTab';
export * from './ExploreTab';
export * from './SettingsTab';
export * from './UnverifiedTab';
export * from './SearchTab';

View file

@ -1,571 +0,0 @@
import React, {
MouseEventHandler,
forwardRef,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
color,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { JoinRule, Room } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import {
NavCategory,
NavCategoryHeader,
NavItem,
NavItemContent,
NavLink,
} from '../../../components/nav';
import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
useSpaceLobbySelected,
useSpaceSearchSelected,
} from '../../../hooks/router/useSelectedSpace';
import { useSpace } from '../../../hooks/useSpace';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useStewardGroups } from '../../../features/steward-groups/useStewardGroups';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useRoomName } from '../../../hooks/useRoomMeta';
import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { markAsRead } from '../../../utils/notifications';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
import { copyToClipboard } from '../../../utils/dom';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { Membership, StateEvent } from '../../../../types/matrix/room';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { BreakWord } from '../../../styles/Text.css';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
import { useCallEmbed } from '../../../hooks/useCallEmbed';
type SpaceMenuProps = {
room: Room;
requestClose: () => void;
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate();
const [invitePrompt, setInvitePrompt] = useState(false);
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity));
requestClose();
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
const handleRoomSettings = () => {
openSpaceSettings(room.roomId);
requestClose();
};
const handleOpenTimeline = () => {
navigateRoom(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Space Settings
</Text>
</MenuItem>
{developerTools && (
<MenuItem
onClick={handleOpenTimeline}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Event Timeline
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Space
</Text>
</MenuItem>
{promptLeave && (
<LeaveSpacePrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
});
function SpaceHeader() {
const space = useSpace();
const spaceName = useRoomName(space);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const joinRules = useStateEvent(
space,
StateEvent.RoomJoinRules
)?.getContent<RoomJoinRulesEventContent>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="H4" truncate>
{spaceName}
</Text>
{joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
</Box>
<Box shrink="No">
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</>
);
}
type SpaceTombstoneProps = { roomId: string; replacementRoomId: string };
export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) {
const mx = useMatrixClient();
const { navigateSpace } = useRoomNavigate();
const [joinState, handleJoin] = useAsyncCallback(
useCallback(() => {
const currentRoom = mx.getRoom(roomId);
const via = currentRoom ? getViaServers(currentRoom) : [];
return mx.joinRoom(replacementRoomId, {
viaServers: via,
});
}, [mx, roomId, replacementRoomId])
);
const replacementRoom = mx.getRoom(replacementRoomId);
const handleOpen = () => {
if (replacementRoom) navigateSpace(replacementRoom.roomId);
if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId);
};
return (
<Box
style={{
padding: config.space.S200,
borderRadius: config.radii.R400,
borderWidth: config.borderWidth.B300,
}}
className={ContainerColor({ variant: 'Surface' })}
direction="Column"
gap="300"
>
<Box direction="Column" grow="Yes" gap="100">
<Text size="L400">Space Upgraded</Text>
<Text size="T200">This space has been replaced and is no longer active.</Text>
{joinState.status === AsyncStatus.Error && (
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement space!'}
</Text>
)}
</Box>
<Box direction="Column" shrink="No">
{replacementRoom?.getMyMembership() === Membership.Join ||
joinState.status === AsyncStatus.Success ? (
<Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
<Text size="B300">Open New Space</Text>
</Button>
) : (
<Button
onClick={handleJoin}
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={
joinState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Primary" fill="Solid" />
)
}
disabled={joinState.status === AsyncStatus.Loading}
>
<Text size="B300">Join New Space</Text>
</Button>
)}
</Box>
</Box>
);
}
export function Space() {
const mx = useMatrixClient();
const space = useSpace();
useNavToActivePathMapper(space.roomId);
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
const scrollRef = useRef<HTMLDivElement>(null);
const mDirects = useAtomValue(mDirectAtom);
const roomToUnread = useAtomValue(roomToUnreadAtom);
const allRooms = useAtomValue(allRoomsAtom);
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
const notificationPreferences = useRoomsNotificationPreferencesContext();
const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone);
const selectedRoomId = useSelectedRoom();
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
const callEmbed = useCallEmbed();
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const getRoom = useCallback(
(rId: string): Room | undefined => {
if (allJoinedRooms.has(rId)) {
return mx.getRoom(rId) ?? undefined;
}
return undefined;
},
[mx, allJoinedRooms]
);
const hierarchy = useSpaceJoinedHierarchy(
space.roomId,
getRoom,
useCallback(
(parentId, roomId) => {
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
return false;
}
const showRoomAnyway =
roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId;
return !showRoomAnyway;
},
[space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed]
),
useCallback(
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
[closedCategories, space.roomId]
)
);
const { entries, toggleGroup } = useStewardGroups({
mx,
spaceId: space.roomId,
items: hierarchy,
keepVisible: useCallback(
(rId: string) => roomToUnread.has(rId) || rId === selectedRoomId || callEmbed?.roomId === rId,
[roomToUnread, selectedRoomId, callEmbed]
),
});
const virtualizer = useVirtualizer({
count: entries.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 0,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
const getToLink = (roomId: string) =>
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
return (
<PageNav>
<SpaceHeader />
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
{tombstoneEvent && (
<SpaceTombstone
roomId={space.roomId}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
)}
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={lobbySelected}>
<NavLink to={getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Flag} size="100" filled={lobbySelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Lobby
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const entry = entries[vItem.index];
if (!entry) return null;
if (entry.type === 'group') {
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<NavCategoryHeader>
<RoomNavCategoryButton
onClick={() => toggleGroup(entry.id)}
closed={!entry.open}
>
{entry.label} · {entry.count}
</RoomNavCategoryButton>
</NavCategoryHeader>
</VirtualTile>
);
}
const { roomId } = entry.item;
const room = mx.getRoom(roomId);
if (!room) return null;
if (room.isSpaceRoom()) {
const categoryId = makeNavCategoryId(space.roomId, roomId);
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={categoryId}
onClick={handleCategoryClick}
closed={closedCategories.has(categoryId)}
>
{roomId === space.roomId ? 'Rooms' : room?.name}
</RoomNavCategoryButton>
</NavCategoryHeader>
</div>
</VirtualTile>
);
}
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selectedRoomId === roomId}
showAvatar={mDirects.has(roomId)}
direct={mDirects.has(roomId)}
linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
</VirtualTile>
);
})}
</NavCategory>
</Box>
</PageNavContent>
</PageNav>
);
}

View file

@ -1,4 +1,3 @@
export * from './SpaceProvider';
export * from './Space';
export * from './Search';
export * from './RoomProvider';

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { addRoomIdToMDirect, removeRoomIdFromMDirect } from './matrix';
type Content = Record<string, string[]>;
/** A client whose account-data write takes a while and lands only when it completes. */
const fakeClient = (initial?: Content) => {
let direct: Content | undefined = initial;
const writes: Content[] = [];
const mx = {
getAccountData: () => (direct ? { getContent: () => direct } : undefined),
setAccountData: async (_type: string, content: Content) => {
await new Promise((r) => {
setTimeout(r, 5);
});
writes.push(structuredClone(content));
direct = structuredClone(content);
},
};
return { mx: mx as never, writes, current: () => direct };
};
describe('m.direct edits', () => {
it('moves a room between users and removes it, one write at a time', async () => {
const c = fakeClient({ '@a:test': ['!r:test'] });
await Promise.all([
addRoomIdToMDirect(c.mx, '!r:test', '@b:test'),
removeRoomIdFromMDirect(c.mx, '!gone:test'),
]);
expect(c.current()).toEqual({ '@a:test': [], '@b:test': ['!r:test'] });
await removeRoomIdFromMDirect(c.mx, '!r:test');
expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] });
});
it('serializes overlapping edits so each builds on the content the last one wrote', async () => {
const c = fakeClient();
await Promise.all([
addRoomIdToMDirect(c.mx, '!dm2:test', '@friend:test'),
addRoomIdToMDirect(c.mx, '!dm3:test', '@friend:test'),
addRoomIdToMDirect(c.mx, '!dm4:test', '@other:test'),
]);
expect(c.current()).toEqual({
'@friend:test': ['!dm2:test', '!dm3:test'],
'@other:test': ['!dm4:test'],
});
expect(c.writes).toHaveLength(3);
});
it('keeps going after a failed write', async () => {
const c = fakeClient();
const raw = c.mx as unknown as { setAccountData: (t: string, x: Content) => Promise<void> };
const original = raw.setAccountData;
raw.setAccountData = async () => {
raw.setAccountData = original;
throw new Error('offline');
};
await expect(addRoomIdToMDirect(c.mx, '!a:test', '@f:test')).rejects.toThrow('offline');
await addRoomIdToMDirect(c.mx, '!a:test', '@f:test');
expect(c.current()).toEqual({ '@f:test': ['!a:test'] });
});
});

View file

@ -225,57 +225,59 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => {
return member1?.userId ?? myUserId;
};
export const addRoomIdToMDirect = async (
type MDirectContent = Record<string, string[]>;
const mDirectWrites = new WeakMap<MatrixClient, Promise<unknown>>();
/**
* The only writer of m.direct. m.direct is one account-data event that every
* edit replaces whole, so edits run one at a time, each reading the content the
* previous one wrote. `edit` returns the new content, or undefined for no write.
*/
export const updateMDirect = (
mx: MatrixClient,
edit: (current: MDirectContent) => MDirectContent | undefined
): Promise<void> => {
const run = (mDirectWrites.get(mx) ?? Promise.resolve()).then(async () => {
const event = mx.getAccountData(AccountDataEvent.Direct as any);
const next = edit(event ? structuredClone(event.getContent()) : {});
if (next) await mx.setAccountData(AccountDataEvent.Direct as any, next as any);
});
mDirectWrites.set(
mx,
run.catch(() => undefined)
);
return run;
};
/** A room can be a DM for one person only: it leaves every other user's list. */
export const addRoomIdToMDirect = (
mx: MatrixClient,
roomId: string,
userId: string
): Promise<void> => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {};
if (typeof mDirectsEvent !== 'undefined')
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
// remove it from the lists of any others users
// (it can only be a DM room for one person)
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId];
if (targetUserId !== userId) {
const indexOfRoomId = roomIds.indexOf(roomId);
if (indexOfRoomId > -1) {
roomIds.splice(indexOfRoomId, 1);
): Promise<void> =>
updateMDirect(mx, (current) => {
const next = structuredClone(current);
Object.keys(next).forEach((targetUserId) => {
if (targetUserId !== userId) {
next[targetUserId] = next[targetUserId].filter((id) => id !== roomId);
}
}
});
const roomIds = next[userId] ?? [];
if (!roomIds.includes(roomId)) roomIds.push(roomId);
next[userId] = roomIds;
return next;
});
const roomIds = userIdToRoomIds[userId] || [];
if (roomIds.indexOf(roomId) === -1) {
roomIds.push(roomId);
}
userIdToRoomIds[userId] = roomIds;
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
};
export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {};
if (typeof mDirectsEvent !== 'undefined')
userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId];
const indexOfRoomId = roomIds.indexOf(roomId);
if (indexOfRoomId > -1) {
roomIds.splice(indexOfRoomId, 1);
}
export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise<void> =>
updateMDirect(mx, (current) => {
const next: MDirectContent = {};
Object.keys(current).forEach((userId) => {
next[userId] = current[userId].filter((id) => id !== roomId);
});
return next;
});
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
};
export const mxcUrlToHttp = (
mx: MatrixClient,
mxcUrl: string,

View file

@ -3,8 +3,6 @@ export enum AccountDataEvent {
Direct = 'm.direct',
IgnoredUserList = 'm.ignored_user_list',
CinnySpaces = 'in.cinny.spaces',
ElementRecentEmoji = 'io.element.recent_emoji',
PoniesUserEmotes = 'im.ponies.user_emotes',