Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6411266092 |
|
|
@ -1,46 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
// 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);
|
||||
|
|
@ -20,10 +19,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
|
|||
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 tierOf = (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);
|
||||
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 tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); };
|
||||
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);
|
||||
|
|
@ -40,33 +38,23 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
|
|||
};
|
||||
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);
|
||||
const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry');
|
||||
} 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);
|
||||
await tap('Friend DM'); await pg.waitForTimeout(4000);
|
||||
const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message');
|
||||
await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload');
|
||||
} 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);
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate');
|
||||
} 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);
|
||||
await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox');
|
||||
} 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);
|
||||
await pg.waitForTimeout(30000); await tiers('inbox reload');
|
||||
} 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));
|
||||
const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count());
|
||||
}
|
||||
await quit(done());
|
||||
await quit(0);
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
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); });
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
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 });
|
||||
|
|
@ -15,14 +13,14 @@ let tierBefore;
|
|||
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 tierOf = (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);
|
||||
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);
|
||||
log('TIER before', await tierOf('Friend DM'));
|
||||
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);
|
||||
|
|
@ -37,12 +35,7 @@ let tierBefore;
|
|||
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();
|
||||
for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
|
||||
log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM'));
|
||||
await b.close();
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ http.createServer((req, res) => {
|
|||
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 });
|
||||
|
|
|
|||
|
|
@ -8,19 +8,19 @@ 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/
|
||||
cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.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"
|
||||
MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?"
|
||||
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"
|
||||
MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?"
|
||||
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
|
||||
|
|
@ -29,7 +29,4 @@ 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" ]
|
||||
echo DONE
|
||||
|
|
|
|||
BIN
public/poses/aqua/amused.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/poses/aqua/annoyed.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/poses/aqua/concerned.webp
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
public/poses/aqua/conduct.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/poses/aqua/determined.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/poses/aqua/flourish.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/poses/aqua/happy.webp
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
public/poses/aqua/listen.webp
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/poses/aqua/neutral.webp
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
public/poses/aqua/patch.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/poses/aqua/surprised.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/poses/aqua/thinking.webp
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
public/poses/eureka/amused.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/eureka/annoyed.webp
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/poses/eureka/concerned.webp
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/poses/eureka/determined.webp
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/poses/eureka/happy.webp
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/poses/eureka/hood.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/poses/eureka/inspect.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/poses/eureka/neutral.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/eureka/probe.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/poses/eureka/report.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/poses/eureka/surprised.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/eureka/thinking.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/poses/heimdall/amused.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/heimdall/annoyed.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/heimdall/badge.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/poses/heimdall/barrier.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/poses/heimdall/concerned.webp
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/poses/heimdall/determined.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/heimdall/happy.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/heimdall/neutral.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/heimdall/protect.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/poses/heimdall/revoke.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/poses/heimdall/surprised.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/poses/heimdall/thinking.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/kiko/amused.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/poses/kiko/annoyed.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/poses/kiko/concerned.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/poses/kiko/confetti.webp
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/poses/kiko/debug.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/poses/kiko/determined.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/poses/kiko/happy.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/poses/kiko/neutral.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/poses/kiko/sprint.webp
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
public/poses/kiko/surprised.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
public/poses/kiko/thinking.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
public/poses/kiko/tinker.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/poses/libby/amused.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/poses/libby/annoyed.webp
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/poses/libby/concerned.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/poses/libby/determined.webp
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/poses/libby/happy.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/poses/libby/neutral.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/poses/libby/research.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
public/poses/libby/schema.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/poses/libby/shush.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/poses/libby/stamp.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/poses/libby/surprised.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/poses/libby/thinking.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/poses/nibu/amused.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/poses/nibu/annoyed.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/poses/nibu/concerned.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/poses/nibu/console.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/poses/nibu/determined.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/poses/nibu/glitch.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
public/poses/nibu/happy.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/poses/nibu/lore.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/poses/nibu/neutral.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/poses/nibu/surprised.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/poses/nibu/thinking.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/nibu/triumph.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/poses/weksa/amused.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
public/poses/weksa/annoyed.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/poses/weksa/argue.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/poses/weksa/concerned.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/poses/weksa/determined.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/weksa/fieldwork.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/poses/weksa/happy.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/poses/weksa/interlingua.webp
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/poses/weksa/map.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/poses/weksa/neutral.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
public/poses/weksa/surprised.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/poses/weksa/thinking.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
|
|
@ -5,11 +5,12 @@ import * as css from './layout.css';
|
|||
|
||||
type BubbleArrowProps = {
|
||||
variant: ContainerColor;
|
||||
pose?: boolean;
|
||||
};
|
||||
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
|
||||
function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) {
|
||||
return (
|
||||
<svg
|
||||
className={css.BubbleLeftArrow}
|
||||
className={classNames(css.BubbleLeftArrow, pose && css.BubbleArrowPose)}
|
||||
width="9"
|
||||
height="8"
|
||||
viewBox="0 0 9 8"
|
||||
|
|
@ -28,14 +29,20 @@ function BubbleLeftArrow({ variant }: BubbleArrowProps) {
|
|||
|
||||
type BubbleLayoutProps = {
|
||||
hideBubble?: boolean;
|
||||
pose?: boolean;
|
||||
before?: ReactNode;
|
||||
header?: ReactNode;
|
||||
};
|
||||
|
||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||
({ hideBubble, before, header, children, ...props }, ref) => (
|
||||
<Box gap="300" {...props} ref={ref}>
|
||||
<Box className={css.BubbleBefore} shrink="No">
|
||||
({ hideBubble, pose, before, header, children, ...props }, ref) => (
|
||||
<Box
|
||||
gap="300"
|
||||
{...props}
|
||||
className={classNames(pose && css.BubblePose, props.className)}
|
||||
ref={ref}
|
||||
>
|
||||
<Box className={pose ? css.BubbleBeforePose : css.BubbleBefore} shrink="No">
|
||||
{before}
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -48,11 +55,15 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
|||
className={
|
||||
hideBubble
|
||||
? undefined
|
||||
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
|
||||
: classNames(
|
||||
css.BubbleContent,
|
||||
before ? css.BubbleContentArrowLeft : undefined,
|
||||
pose && css.BubbleContentPoseStacked
|
||||
)
|
||||
}
|
||||
direction="Column"
|
||||
>
|
||||
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
|
||||
{before ? <BubbleLeftArrow variant="SurfaceVariant" pose={pose} /> : null}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -138,6 +138,34 @@ export const BubbleBefore = style({
|
|||
minWidth: toRem(36),
|
||||
});
|
||||
|
||||
// Pose portraits: beside the bubble on wide screens; on phones the row stacks, so the
|
||||
// portrait costs a little height instead of a column of width, and the left arrow goes.
|
||||
export const BubblePose = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleBeforePose = style({
|
||||
minWidth: toRem(64),
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
minWidth: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleArrowPose = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleContent = style({
|
||||
maxWidth: toRem(800),
|
||||
padding: config.space.S200,
|
||||
|
|
@ -151,6 +179,34 @@ export const BubbleContentArrowLeft = style({
|
|||
borderTopLeftRadius: 0,
|
||||
});
|
||||
|
||||
export const BubbleContentPoseStacked = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
borderTopLeftRadius: config.radii.R500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PoseButton = style({
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 0,
|
||||
});
|
||||
|
||||
export const PosePortrait = style({
|
||||
width: toRem(64),
|
||||
height: toRem(64),
|
||||
objectFit: 'contain',
|
||||
'@media': {
|
||||
'screen and (max-width: 750px)': {
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BubbleLeftArrow = style({
|
||||
width: toRem(9),
|
||||
height: toRem(8),
|
||||
|
|
|
|||
30
src/app/components/message/pose/PoseAvatar.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import * as css from '../layout/layout.css';
|
||||
import { poseSrc } from './poses';
|
||||
|
||||
type PoseAvatarProps = {
|
||||
mind: string;
|
||||
pose: string;
|
||||
userId: string;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
export function PoseAvatar({ mind, pose, userId, onClick }: PoseAvatarProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.PoseButton}
|
||||
data-user-id={userId}
|
||||
data-pose={pose}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
className={css.PosePortrait}
|
||||
src={poseSrc(mind, pose, import.meta.env.BASE_URL)}
|
||||
alt={`${mind}, ${pose}`}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
35
src/app/components/message/pose/poses.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { mindOfSender, poseSrc, resolvePose } from './poses';
|
||||
|
||||
describe('resolvePose', () => {
|
||||
it('keeps shared and own-signature labels', () => {
|
||||
expect(resolvePose('aqua', 'amused')).toBe('amused');
|
||||
expect(resolvePose('aqua', 'conduct')).toBe('conduct');
|
||||
expect(resolvePose('kiko', ' Debug ')).toBe('debug');
|
||||
});
|
||||
it('falls back to neutral for missing, unknown, non-string or foreign-signature labels', () => {
|
||||
expect(resolvePose('aqua', undefined)).toBe('neutral');
|
||||
expect(resolvePose('aqua', 'smug')).toBe('neutral');
|
||||
expect(resolvePose('aqua', 7)).toBe('neutral');
|
||||
expect(resolvePose('aqua', 'probe')).toBe('neutral');
|
||||
expect(resolvePose('nobody', 'probe')).toBe('neutral');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mindOfSender', () => {
|
||||
it('matches localpart or first display-name word', () => {
|
||||
expect(mindOfSender('Eureka')).toBe('eureka');
|
||||
expect(mindOfSender('x', 'Libby the Archivist')).toBe('libby');
|
||||
});
|
||||
it('ignores humans and Object prototype names', () => {
|
||||
expect(mindOfSender('alice', 'Alice')).toBeUndefined();
|
||||
expect(mindOfSender('constructor')).toBeUndefined();
|
||||
expect(mindOfSender()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('poseSrc', () => {
|
||||
it('builds the asset path', () => {
|
||||
expect(poseSrc('nibu', 'glitch', '/app/')).toBe('/app/public/poses/nibu/glitch.webp');
|
||||
});
|
||||
});
|
||||
48
src/app/components/message/pose/poses.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Pose labels travel in the event content under POSE_KEY. Art exists per Mind;
|
||||
// every Mind has the shared set plus four signature poses of its own.
|
||||
export const POSE_KEY = 'org.gamecult.pose';
|
||||
|
||||
export const SHARED_POSES = [
|
||||
'neutral',
|
||||
'happy',
|
||||
'amused',
|
||||
'thinking',
|
||||
'concerned',
|
||||
'annoyed',
|
||||
'surprised',
|
||||
'determined',
|
||||
] as const;
|
||||
|
||||
export const MIND_POSES: Record<string, readonly string[]> = {
|
||||
aqua: ['conduct', 'listen', 'patch', 'flourish'],
|
||||
eureka: ['probe', 'inspect', 'report', 'hood'],
|
||||
heimdall: ['badge', 'barrier', 'protect', 'revoke'],
|
||||
kiko: ['tinker', 'confetti', 'sprint', 'debug'],
|
||||
libby: ['stamp', 'research', 'shush', 'schema'],
|
||||
nibu: ['console', 'glitch', 'lore', 'triumph'],
|
||||
weksa: ['map', 'fieldwork', 'argue', 'interlingua'],
|
||||
};
|
||||
|
||||
export const NEUTRAL = 'neutral';
|
||||
|
||||
// A sender is a known Mind when its Matrix localpart or the first word of its
|
||||
// display name is the Mind's name, case-insensitively.
|
||||
export const mindOfSender = (localpart?: string, displayName?: string): string | undefined => {
|
||||
const candidates = [localpart, displayName?.split(/\s+/)[0]];
|
||||
return candidates
|
||||
.map((c) => c?.toLowerCase())
|
||||
.find(
|
||||
(c): c is string => c !== undefined && Object.prototype.hasOwnProperty.call(MIND_POSES, c)
|
||||
);
|
||||
};
|
||||
|
||||
// Unknown, missing or non-string labels, and another Mind's signature, fall back to neutral.
|
||||
export const resolvePose = (mind: string, label: unknown): string => {
|
||||
if (typeof label !== 'string') return NEUTRAL;
|
||||
const l = label.trim().toLowerCase();
|
||||
const known = (SHARED_POSES as readonly string[]).includes(l) || MIND_POSES[mind]?.includes(l);
|
||||
return known ? l : NEUTRAL;
|
||||
};
|
||||
|
||||
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
|
||||
`${base}public/poses/${mind}/${pose}.webp`;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
|
|
@ -31,7 +31,6 @@ 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';
|
||||
|
||||
|
|
@ -198,10 +197,8 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||
)
|
||||
);
|
||||
|
||||
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers);
|
||||
}, [mx, roomIdOrAlias, viaServers])
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
|
||||
);
|
||||
const joining =
|
||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ 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;
|
||||
|
|
@ -44,7 +43,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||
const prevRoomId = createContent?.predecessor?.room_id;
|
||||
|
||||
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
|
||||
useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx])
|
||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
||||
);
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
activateEstateRoom,
|
||||
awaitMyJoin,
|
||||
joinEstateRoomIdOrAlias,
|
||||
joinEstateRoomOrThrow,
|
||||
runJoinCommand,
|
||||
} from './joinEstateRoom';
|
||||
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
|
||||
|
||||
const room = (state: 'joined' | 'invited' | 'joinable') => ({
|
||||
roomId: '!r:gamecult.org',
|
||||
|
|
@ -58,19 +52,13 @@ describe('activateEstateRoom', () => {
|
|||
|
||||
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' })
|
||||
)
|
||||
vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 }))
|
||||
);
|
||||
expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({
|
||||
kind: 'refused',
|
||||
message: 'Boom',
|
||||
});
|
||||
const bare = recordingClient(
|
||||
vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })
|
||||
);
|
||||
const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 }));
|
||||
expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({
|
||||
kind: 'refused',
|
||||
message: 'Could not join',
|
||||
|
|
@ -80,7 +68,6 @@ describe('activateEstateRoom', () => {
|
|||
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));
|
||||
|
|
@ -91,22 +78,13 @@ describe('activateEstateRoom', () => {
|
|||
});
|
||||
|
||||
/** 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>;
|
||||
}) => {
|
||||
const dmClient = (opts: { refuse?: boolean; drop?: 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: {
|
||||
|
|
@ -118,29 +96,21 @@ const dmClient = (opts: {
|
|||
}),
|
||||
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);
|
||||
joinRoom: async (roomId: string) => {
|
||||
// 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.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 });
|
||||
if (opts.drop) throw new Error('fetch failed');
|
||||
return { roomId };
|
||||
},
|
||||
};
|
||||
return { mx: mx as never, writes, joined, joinOpts, current: () => direct };
|
||||
return { mx: mx as never, writes, joined, current: () => direct };
|
||||
};
|
||||
const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] });
|
||||
|
||||
|
|
@ -179,122 +149,6 @@ describe('activateEstateRoom when the join gets no answer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
|
|
@ -303,12 +157,8 @@ describe('awaitMyJoin', () => {
|
|||
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);
|
||||
},
|
||||
on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h),
|
||||
off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h),
|
||||
},
|
||||
handlers,
|
||||
become: (roomId: string, m: string) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import { EstateRoom } from './buildEstate';
|
||||
import {
|
||||
addRoomIdToMDirect,
|
||||
isRoomAlias,
|
||||
isRoomId,
|
||||
removeRoomIdFromMDirect,
|
||||
} from '../../utils/matrix';
|
||||
import { splitWithSpace } from '../../utils/common';
|
||||
import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix';
|
||||
import { isDirectInvite } from '../../utils/room';
|
||||
|
||||
export type EstateActivation =
|
||||
|
|
@ -14,7 +8,7 @@ export type EstateActivation =
|
|||
| { kind: 'refused'; message: string };
|
||||
|
||||
const LOST_JOIN_RESPONSE =
|
||||
'The server did not answer the join. It may have gone through; tap again to retry.';
|
||||
'The connection dropped before the server answered. The join may have gone through; tap again to retry.';
|
||||
|
||||
type JoinClient = Parameters<typeof addRoomIdToMDirect>[0];
|
||||
|
||||
|
|
@ -27,31 +21,21 @@ type JoinClient = Parameters<typeof addRoomIdToMDirect>[0];
|
|||
* 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.
|
||||
* the join is refused by a server answer. A join that fails without one (lost
|
||||
* response, dropped connection) may still have been accepted, so the entry
|
||||
* stays and the tap is retryable. Nothing needs to recognise the DM after it
|
||||
* arrives. The inbox Accept goes through here too: one owner for joining.
|
||||
*/
|
||||
export const activateEstateRoom = async (
|
||||
mx: JoinClient,
|
||||
room: Pick<EstateRoom, 'roomId' | 'state'> & { via?: string[] }
|
||||
room: Pick<EstateRoom, 'roomId' | 'state' | 'via'>
|
||||
): 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 answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number';
|
||||
const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null;
|
||||
const inviter = isDirectInvite(invite, mx.getSafeUserId())
|
||||
? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender()
|
||||
|
|
@ -65,57 +49,12 @@ export const activateEstateRoom = async (
|
|||
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 (!answered(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;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { JoinRule, MatrixError, 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,7 +38,6 @@ 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;
|
||||
|
|
@ -47,8 +46,8 @@ type RoomJoinButtonProps = {
|
|||
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [joinState, join] = useAsyncCallback<string, Error, []>(
|
||||
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
|
||||
);
|
||||
|
||||
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
||||
|
|
@ -61,8 +60,9 @@ 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.message}
|
||||
{joinState.error.data?.error || 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.message}
|
||||
aria-label={joinState.error.data?.error || joinState.error.message}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
|
|
|||