Merge PR #6: Soundscape audio socket over Aqua activity frame (flag off, muted)

This commit is contained in:
Void 2026-11-02 18:28:16 +00:00
commit f9cf99faff
21 changed files with 777 additions and 66 deletions

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

@ -1,6 +1,7 @@
// 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);
@ -19,9 +20,10 @@ 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) => 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 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) => { 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 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);
@ -38,23 +40,33 @@ 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|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');
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'); 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');
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); await tiers('admin estate');
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); await tiers('admin inbox');
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); await tiers('inbox reload');
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 l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count());
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(0);
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); });

View file

@ -1,8 +1,10 @@
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 });
@ -13,14 +15,14 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El
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) => 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 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);
log('TIER before', await tierOf('Friend DM'));
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);
@ -35,7 +37,12 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El
log('RELOAD', t()); await open();
await pg.waitForTimeout(30000);
}
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();
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; });

View file

@ -91,6 +91,7 @@ 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 });

View file

@ -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/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/
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
MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?"
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
MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?"
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
@ -29,4 +29,7 @@ failure jdrop JOIN_DROP=1
failure admin INVITER_ADMIN=1
failure inboxadmin INVITER_ADMIN=1
failure inboxreload JOIN_DELAY=1
echo DONE
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

@ -1,5 +1,11 @@
import { describe, expect, it, vi } from 'vitest';
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
import {
activateEstateRoom,
awaitMyJoin,
joinEstateRoomIdOrAlias,
joinEstateRoomOrThrow,
runJoinCommand,
} from './joinEstateRoom';
const room = (state: 'joined' | 'invited' | 'joinable') => ({
roomId: '!r:gamecult.org',
@ -52,13 +58,19 @@ 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: 500 }))
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: 500 }));
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',
@ -68,6 +80,7 @@ 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));
@ -78,13 +91,22 @@ describe('activateEstateRoom', () => {
});
/** A client holding DM invites as the server sends them: the mark is in the invite event only. */
const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record<string, string> }) => {
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: {
@ -96,21 +118,29 @@ const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record<stri
}),
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) => {
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 });
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, current: () => direct };
return { mx: mx as never, writes, joined, joinOpts, current: () => direct };
};
const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] });
@ -149,6 +179,122 @@ 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 = () => {
@ -157,8 +303,12 @@ describe('awaitMyJoin', () => {
return {
mx: {
getRoom: () => (membership ? { getMyMembership: () => membership! } : null),
on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h),
off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h),
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) => {

View file

@ -1,5 +1,11 @@
import { EstateRoom } from './buildEstate';
import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix';
import {
addRoomIdToMDirect,
isRoomAlias,
isRoomId,
removeRoomIdFromMDirect,
} from '../../utils/matrix';
import { splitWithSpace } from '../../utils/common';
import { isDirectInvite } from '../../utils/room';
export type EstateActivation =
@ -8,7 +14,7 @@ export type EstateActivation =
| { kind: 'refused'; message: string };
const LOST_JOIN_RESPONSE =
'The connection dropped before the server answered. The join may have gone through; tap again to retry.';
'The server did not answer the join. It may have gone through; tap again to retry.';
type JoinClient = Parameters<typeof addRoomIdToMDirect>[0];
@ -21,21 +27,31 @@ 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 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.
* 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'>
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 answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number';
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()
@ -49,12 +65,57 @@ export const activateEstateRoom = async (
const joined = await mx.joinRoom(room.roomId, { viaServers: room.via });
return { kind: 'joined', roomId: joined.roomId };
} catch (e) {
if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE };
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;

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

@ -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

@ -0,0 +1,12 @@
export interface DeviceHints {
deviceMemory?: number;
hardwareConcurrency?: number;
}
/**
* Off on weak phones. The thresholds are placeholders: no phone has been measured (grammar
* section 7, item 4). Unknown hints (Safari reports neither) do not count as weak.
*/
export const isWeakDevice = ({ deviceMemory, hardwareConcurrency }: DeviceHints): boolean =>
(deviceMemory !== undefined && deviceMemory < 4) ||
(hardwareConcurrency !== undefined && hardwareConcurrency < 4);

View file

@ -0,0 +1,95 @@
// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510,
// section 2). The Heimdall session produces it; this file only reads it. Shape follows
// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it.
export type Estate = 'human' | 'mind' | 'persona';
export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert';
export type Energy = 'Low' | 'Mid' | 'High' | 'Peak';
export type Size = 'S' | 'M' | 'L';
export type Override = 'None' | 'Muted';
export interface ActivityFrame {
mind: string;
estate: Estate;
kind: Kind;
energy: Energy;
size: Size;
neutral: boolean;
override: Override;
seq: number;
/** Producer observation time, whole seconds (grammar: 1 s resolution). */
at: number;
}
const ESTATES: readonly Estate[] = ['human', 'mind', 'persona'];
const KINDS: readonly Kind[] = [
'Idle',
'Reading',
'Thinking',
'Working',
'Speaking',
'Waiting',
'Alert',
];
const ENERGIES: readonly Energy[] = ['Low', 'Mid', 'High', 'Peak'];
const SIZES: readonly Size[] = ['S', 'M', 'L'];
/** Grammar S2 (proposed, unmeasured): a frame older than this is absent. */
export const STALE_AFTER_S = 10;
const oneOf = <T extends string>(set: readonly T[], v: unknown): T | undefined =>
set.find((s) => s === v);
/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */
export const clampEnergy = (estate: Estate, energy: Energy): Energy =>
estate === 'human' && energy === 'Peak' ? 'High' : energy;
/**
* Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a
* room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3).
* Returns undefined for anything malformed; the caller treats that as no frame (A4).
*/
export const readFrame = (raw: unknown): ActivityFrame | undefined => {
if (typeof raw !== 'object' || raw === null) return undefined;
const r = raw as Record<string, unknown>;
const estate = oneOf(ESTATES, r.estate);
const kind = oneOf(KINDS, r.kind);
const energy = oneOf(ENERGIES, r.energy);
const size = oneOf(SIZES, r.size);
const override =
r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override);
if (!estate || !kind || !energy || !size || !override) return undefined;
if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean')
return undefined;
if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined;
return {
mind: r.mind,
estate,
kind,
energy: clampEnergy(estate, energy),
size,
neutral: r.neutral,
override,
seq: r.seq as number,
at: r.at as number,
};
};
/** Latest frame per mind (S1: consumers drop seq <= last). */
export class FrameTable {
private frames = new Map<string, ActivityFrame>();
offer(raw: unknown): boolean {
const f = readFrame(raw);
if (!f) return false;
const last = this.frames.get(f.mind);
if (last && f.seq <= last.seq) return false;
this.frames.set(f.mind, f);
return true;
}
/** Frames not stale at `nowS`; a missing or stale frame is simply absent (A4), not neutral. */
fresh(nowS: number): ActivityFrame[] {
return [...this.frames.values()].filter((f) => nowS - f.at <= STALE_AFTER_S);
}
}

View file

@ -0,0 +1,8 @@
import type { ActivityFrame, Size } from './frame';
/** Placeholders owned by the orbit renderer, not the frame (grammar, Estate section). Unmeasured. */
export const PLACEHOLDER_SIZE_SCALE: Record<Size, number> = { S: 0.7, M: 1, L: 1.4 };
/** O1: a neutral participant gets no orbit. There is no neutral pose; it is not in the list. */
export const drawnCreatures = (frames: readonly ActivityFrame[]): ActivityFrame[] =>
frames.filter((f) => !f.neutral);

View file

@ -0,0 +1,47 @@
import { FrameTable } from './frame';
import { drawnCreatures } from './scene';
import { planVoices, type SoundscapePrefs, type VoiceCommand } from './voices';
import { isWeakDevice, type DeviceHints } from './device';
/**
* Where frames come from is `ratatoskr:question:activity-feed-source`, still open. Until the
* Heimdall session feed exists the only source is `silentSource`. A source never reads a room's
* member list (A1): being in a room says nothing about being active.
*/
export interface ActivitySource {
subscribe(onFrame: (raw: unknown) => void): () => void;
}
const noop = () => undefined;
export const silentSource: ActivitySource = { subscribe: () => noop };
/** Receives the plan. No audio assets exist yet, so nothing in this repo sounds. */
export interface VoiceSink {
play(commands: readonly VoiceCommand[]): void;
}
export interface AudioSocketDeps {
source: ActivitySource;
sink: VoiceSink;
prefs: () => SoundscapePrefs;
device: () => DeviceHints;
/** Whole seconds. */
nowS: () => number;
}
export const createAudioSocket = (deps: AudioSocketDeps) => {
const table = new FrameTable();
const unsubscribe = deps.source.subscribe((raw) => table.offer(raw));
return {
/** Called by the scheduler tick; a hidden page simply stops calling it (S5). */
tick(): VoiceCommand[] {
const prefs = deps.prefs();
const effective = { ...prefs, enabled: prefs.enabled && !isWeakDevice(deps.device()) };
const commands = planVoices(table.fresh(deps.nowS()), effective);
deps.sink.play(commands);
return commands;
},
/** What the orbit renderer draws: the same table, neutral removed. */
drawn: () => drawnCreatures(table.fresh(deps.nowS())),
close: unsubscribe,
};
};

View file

@ -0,0 +1,171 @@
import { describe, expect, it, vi } from 'vitest';
import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame';
import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene';
import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices';
import { isWeakDevice } from './device';
import { createAudioSocket, silentSource, type ActivitySource } from './socket';
const frame = (o: Partial<ActivityFrame> = {}): ActivityFrame => ({
mind: 'void',
estate: 'mind',
kind: 'Working',
energy: 'Mid',
size: 'M',
neutral: false,
override: 'None',
seq: 1,
at: 100,
...o,
});
const noop = () => undefined;
const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 };
describe('neutral overrides everything', () => {
it('draws nothing and sounds nothing for any kind, energy, size or estate', () => {
const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) =>
(['Speaking', 'Alert', 'Working'] as const).flatMap((kind) =>
(['High', 'Peak'] as const).flatMap((energy) =>
(['S', 'M', 'L'] as const).map((size) =>
frame({ neutral: true, estate, kind, energy, size })
)
)
)
);
expect(cases).toHaveLength(54);
cases.forEach((f) => {
expect(drawnCreatures([f])).toEqual([]);
expect(planVoices([f], on)).toEqual([]);
});
});
it('is not the same as an absent frame: absent reads as idle, neutral as hidden', () => {
const t = new FrameTable();
t.offer(frame({ at: 100 }));
expect(drawnCreatures(t.fresh(100 + STALE_AFTER_S + 1))).toEqual([]);
expect(drawnCreatures(t.fresh(100)).length).toBe(1);
});
});
describe('human energy is clamped to three buckets by the consumer', () => {
it('turns Peak into High for humans only', () => {
expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High');
expect(readFrame(frame({ estate: 'mind', energy: 'Peak' }))?.energy).toBe('Peak');
expect(readFrame(frame({ estate: 'persona', energy: 'Peak' }))?.energy).toBe('Peak');
});
it('keeps the clamp through the voice plan', () => {
const t = new FrameTable();
t.offer(frame({ estate: 'human', energy: 'Peak' }));
expect(planVoices(t.fresh(100), on)[0].energy).toBe('High');
});
});
describe('activity is a stub, never the room member list', () => {
it('the silent source and the socket yield nothing, however many members a room has', () => {
const members = ['@a:x', '@b:x', '@c:x'];
const sock = createAudioSocket({
source: silentSource,
sink: { play: noop },
prefs: () => on,
device: () => ({}),
nowS: () => 100,
});
expect(members.length).toBeGreaterThan(0);
expect(sock.tick()).toEqual([]);
expect(sock.drawn()).toEqual([]);
});
it('only frames the source pushes produce voices', () => {
const pushes: ((raw: unknown) => void)[] = [];
const source: ActivitySource = {
subscribe: (cb) => {
pushes.push(cb);
return noop;
},
};
const sock = createAudioSocket({
source,
sink: { play: noop },
prefs: () => on,
device: () => ({}),
nowS: () => 100,
});
pushes[0](frame({ mind: 'aqua' }));
expect(sock.tick().map((v) => v.mind)).toEqual(['aqua']);
});
});
describe('no vote weights', () => {
it('drops fields the grammar does not define, and the plan carries only user volume as gain', () => {
const f = readFrame({ ...frame(), weight: 9, votes: 40, roomId: '!r:x', brief: 'secret' });
expect(Object.keys(f ?? {}).sort()).toEqual(
['at', 'energy', 'estate', 'kind', 'mind', 'neutral', 'override', 'seq', 'size'].sort()
);
const t = new FrameTable();
t.offer({ ...frame(), weight: 9 });
expect(planVoices(t.fresh(100), on)[0].gain).toBe(0.5);
});
});
describe('size scales are placeholders', () => {
it('live in the renderer table, not in the frame, and change nothing about voices', () => {
expect(PLACEHOLDER_SIZE_SCALE).toEqual({ S: 0.7, M: 1, L: 1.4 });
const s = planVoices([frame({ size: 'S' })], on);
const l = planVoices([frame({ size: 'L' })], on);
expect(s).toEqual(l);
});
});
describe('socket defaults and limits', () => {
it('ships off, muted, with the proposed cap', async () => {
vi.stubGlobal('localStorage', { getItem: () => null });
const { getSettings } = await import('../../state/settings');
const s = getSettings();
expect(s.soundscapeEnabled).toBe(false);
expect(s.soundscapeMuted).toBe(true);
expect(s.soundscapeVoiceCap).toBe(DEFAULT_VOICE_CAP);
});
it('master mute, disabled flag and zero volume all silence', () => {
const f = [frame()];
expect(planVoices(f, { ...on, muted: true })).toEqual([]);
expect(planVoices(f, { ...on, enabled: false })).toEqual([]);
expect(planVoices(f, { ...on, volume: 0 })).toEqual([]);
});
it('caps voices, dropping the lowest energy first', () => {
const f = ['a', 'b', 'c', 'd', 'e'].map((mind, i) =>
frame({ mind, energy: (['Low', 'Mid', 'High', 'Peak', 'Mid'] as const)[i] })
);
const voices = planVoices(f, { ...on, voiceCap: 4 }).map((v) => v.mind);
expect(voices).toHaveLength(4);
expect(voices).not.toContain('a');
});
it('Muted silences the voice but the creature is still drawn; Idle/Waiting are silent', () => {
const f = [frame({ override: 'Muted' }), frame({ mind: 'x', kind: 'Idle' })];
expect(planVoices(f, on)).toEqual([]);
expect(drawnCreatures(f)).toHaveLength(2);
});
it('is off on weak phones even when the flag is on, and unknown hints are not weak', () => {
expect(isWeakDevice({ deviceMemory: 2 })).toBe(true);
expect(isWeakDevice({ hardwareConcurrency: 2 })).toBe(true);
expect(isWeakDevice({})).toBe(false);
const played: number[] = [];
const sock = createAudioSocket({
source: {
subscribe: (cb) => {
cb(frame());
return noop;
},
},
sink: { play: (c) => played.push(c.length) },
prefs: () => on,
device: () => ({ deviceMemory: 1 }),
nowS: () => 100,
});
expect(sock.tick()).toEqual([]);
expect(played).toEqual([0]);
});
it('drops frames with seq <= last, and malformed frames', () => {
const t = new FrameTable();
expect(t.offer(frame({ seq: 5 }))).toBe(true);
expect(t.offer(frame({ seq: 5 }))).toBe(false);
expect(t.offer(frame({ seq: 4 }))).toBe(false);
expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false);
});
});

View file

@ -0,0 +1,49 @@
import type { ActivityFrame, Energy, Kind } from './frame';
/** Grammar V3 (proposed, unmeasured): voices audible at once. */
export const DEFAULT_VOICE_CAP = 4;
/** Local listener preferences. Never written back into a frame (O3). */
export interface SoundscapePrefs {
/** The feature flag. Default off. */
enabled: boolean;
/** Master mute. Default on. */
muted: boolean;
/** 0..1 */
volume: number;
voiceCap: number;
}
export interface VoiceCommand {
mind: string;
kind: Kind;
energy: Energy;
gain: number;
}
const ENERGY_RANK: Record<Energy, number> = { Low: 0, Mid: 1, High: 2, Peak: 3 };
/** Grammar section 3: Idle and Waiting default silent. */
const SILENT_KINDS: ReadonlySet<Kind> = new Set<Kind>(['Idle', 'Waiting']);
const clamp01 = (v: number) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0);
/**
* Which voices speak now. Neutral overrides everything (O1); `Muted` silences one Mind (O2);
* over the cap the lowest energy drops first (V3, ties by mind id so the result is stable).
* Identity is the mind id only (V1); `gain` is the user's volume, not a vote weight.
* Lead exemption in V3 is not implemented: the grammar does not say how a lead is chosen.
*/
export const planVoices = (
frames: readonly ActivityFrame[],
prefs: SoundscapePrefs
): VoiceCommand[] => {
if (!prefs.enabled || prefs.muted) return [];
const gain = clamp01(prefs.volume);
if (gain === 0) return [];
const cap = Math.max(0, Math.floor(prefs.voiceCap));
return frames
.filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind))
.sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind))
.slice(0, cap)
.map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain }));
};

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

@ -47,6 +47,12 @@ export interface Settings {
dateFormatString: string;
developerTools: boolean;
/** Soundscape flag: default off, and muted by default even when on. */
soundscapeEnabled: boolean;
soundscapeMuted: boolean;
soundscapeVolume: number;
soundscapeVoiceCap: number;
}
const defaultSettings: Settings = {
@ -81,6 +87,11 @@ const defaultSettings: Settings = {
dateFormatString: 'D MMM YYYY',
developerTools: false,
soundscapeEnabled: false,
soundscapeMuted: true,
soundscapeVolume: 0.5,
soundscapeVoiceCap: 4,
};
export const getSettings = () => {