From 664a57d4ebd178d435cd15b1098c74961517e1d1 Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 5 Aug 2026 14:39:01 -0400 Subject: [PATCH 01/21] feat: adaptive video grid sizing (PR-C1) Add computeGridSize helper in call.ts that computes optimal row/column counts for a video tile grid given tile count and container dimensions, maximizing tile size while maintaining ~16:9 cells. Apply via computed grid-template-columns/rows on the multi-grid path (no-spotlight) in VideoCallContent.svelte, replacing the fixed grid-cols-1 / grid-cols-1 sm:grid-cols-2 heuristic. The multi-grid now handles all tile counts from 1 to 12+ smoothly. - computeGridSize(tileCount, containerWidth, containerHeight) -> GridSize - Container dimensions bound via clientWidth/clientHeight - useMultiGrid: dropped the > 2 check, now !useSpotlightLayout - gridStyle: computed grid-template-columns/rows string from gridSize - Scope: multi-grid only, no spotlight/strip/breakpoint changes --- src/app/call.ts | 39 ++++++++++++++++++++++ src/app/components/VideoCallContent.svelte | 23 ++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/app/call.ts b/src/app/call.ts index e81c442e..6b4a04f7 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -31,6 +31,45 @@ export const LIVEKIT_PARTICIPANTS = 39004 export {supportsAudioOutputSelection} +export type GridSize = {rows: number; cols: number} + +/** + * Given a tile count and available container dimensions, returns the optimal + * number of rows and columns to maximize tile size while maintaining ~16:9 + * cells. Handles 0 to 12+ tiles smoothly. + */ +export const computeGridSize = ( + tileCount: number, + containerWidth: number, + containerHeight: number, +): GridSize => { + if (tileCount <= 0) return {rows: 0, cols: 0} + if (tileCount === 1) return {rows: 1, cols: 1} + + const TILE_ASPECT = 16 / 9 + let best: GridSize = {rows: 1, cols: tileCount} + let bestArea = 0 + + // Guard against unmeasured container — fall back to a landscape-ish default + const w = containerWidth > 0 ? containerWidth : 800 + const h = containerHeight > 0 ? containerHeight : 600 + + for (let cols = 1; cols <= tileCount; cols++) { + const rows = Math.ceil(tileCount / cols) + // Tile width constrained by both axes while maintaining 16:9 + const tileWidth = Math.min(w / cols, (h / rows) * TILE_ASPECT) + const tileHeight = tileWidth / TILE_ASPECT + const area = tileWidth * tileHeight + + if (area > bestArea) { + bestArea = area + best = {rows, cols} + } + } + + return best +} + const LIVEKIT_DEFAULT_DEVICE_ID = "default" const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index c5db244b..e6966856 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -24,6 +24,8 @@ participantMediaState, pubkeyFromLiveKitIdentity, videoTrackRevision, + computeGridSize, + type GridSize, } from "@app/call" import {profiles} from "@app/core" @@ -173,10 +175,19 @@ return videoTiles.filter(t => tileKey(t) !== pk) }) + let gridWidth = $state(0) + let gridHeight = $state(0) + const useSpotlightLayout = $derived(primaryTile !== undefined) - const useMultiGrid = $derived(!useSpotlightLayout && videoTiles.length > 2) - const multiGridClass = $derived( - layout === VideoCallLayout.Split ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2", + const useMultiGrid = $derived(!useSpotlightLayout) + + const gridSize = $derived( + useMultiGrid ? computeGridSize(videoTiles.length, gridWidth, gridHeight) : {rows: 0, cols: 0}, + ) + const gridStyle = $derived( + gridSize.rows > 0 && gridSize.cols > 0 + ? `grid-template-columns: repeat(${gridSize.cols}, 1fr); grid-template-rows: repeat(${gridSize.rows}, 1fr)` + : "", ) $effect(() => { @@ -283,7 +294,11 @@ {/if} {:else if useMultiGrid} -
+
{#each videoTiles as tile (tileKey(tile))} {@render videoTile(tile, "default")} {/each} -- 2.45.2 From 85a4ba127acc3b998933e838a885f5390634c163 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 6 Aug 2026 08:43:57 -0700 Subject: [PATCH 02/21] Add e2e tests --- e2e/ARCHITECTURE.md | 303 ++++++++++++++++++++ e2e/harness/app/boot.ts | 96 +++++++ e2e/harness/app/session.ts | 30 ++ e2e/harness/index.ts | 165 +++++++++++ e2e/harness/keys.ts | 26 ++ e2e/harness/net/http.ts | 315 +++++++++++++++++++++ e2e/harness/net/websocket.ts | 130 +++++++++ e2e/harness/seed/scenario.ts | 113 ++++++++ e2e/harness/seed/space.ts | 191 +++++++++++++ e2e/harness/zooid/config.ts | 27 ++ e2e/harness/zooid/docker/compose.yaml | 37 +++ e2e/harness/zooid/docker/config/other.toml | 44 +++ e2e/harness/zooid/docker/config/space.toml | 43 +++ e2e/harness/zooid/relay.ts | 264 +++++++++++++++++ e2e/harness/zooid/testRelay.ts | 72 +++++ e2e/harness/zooid/transport.ts | 144 ++++++++++ e2e/harness/zooid/types.ts | 34 +++ e2e/smoke.spec.ts | 12 - e2e/specs/auth.spec.ts | 43 +++ e2e/specs/multiuser.spec.ts | 27 ++ e2e/specs/routing.spec.ts | 57 ++++ e2e/specs/space.spec.ts | 26 ++ e2e/support/fixtures.ts | 29 -- e2e/support/fixtures/relay1.json | 29 -- e2e/support/relayMocks.ts | 30 -- link_deps | 57 ++-- package.json | 8 +- playwright.config.ts | 42 ++- pnpm-lock.yaml | Bin 341858 -> 381057 bytes pnpm-workspace.yaml | 11 +- src/app/core.ts | 4 - src/app/env.ts | 55 ++-- src/app/session.ts | 18 +- src/lib/test/env.ts | 10 + src/lib/test/relayMocks.ts | 83 ------ src/lib/test/session.ts | 19 ++ tsconfig.json | 17 +- vite.config.ts | 17 ++ 38 files changed, 2371 insertions(+), 257 deletions(-) create mode 100644 e2e/ARCHITECTURE.md create mode 100644 e2e/harness/app/boot.ts create mode 100644 e2e/harness/app/session.ts create mode 100644 e2e/harness/index.ts create mode 100644 e2e/harness/keys.ts create mode 100644 e2e/harness/net/http.ts create mode 100644 e2e/harness/net/websocket.ts create mode 100644 e2e/harness/seed/scenario.ts create mode 100644 e2e/harness/seed/space.ts create mode 100644 e2e/harness/zooid/config.ts create mode 100644 e2e/harness/zooid/docker/compose.yaml create mode 100644 e2e/harness/zooid/docker/config/other.toml create mode 100644 e2e/harness/zooid/docker/config/space.toml create mode 100644 e2e/harness/zooid/relay.ts create mode 100644 e2e/harness/zooid/testRelay.ts create mode 100644 e2e/harness/zooid/transport.ts create mode 100644 e2e/harness/zooid/types.ts delete mode 100644 e2e/smoke.spec.ts create mode 100644 e2e/specs/auth.spec.ts create mode 100644 e2e/specs/multiuser.spec.ts create mode 100644 e2e/specs/routing.spec.ts create mode 100644 e2e/specs/space.spec.ts delete mode 100644 e2e/support/fixtures.ts delete mode 100644 e2e/support/fixtures/relay1.json delete mode 100644 e2e/support/relayMocks.ts create mode 100644 src/lib/test/env.ts delete mode 100644 src/lib/test/relayMocks.ts create mode 100644 src/lib/test/session.ts diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md new file mode 100644 index 00000000..bafc6a07 --- /dev/null +++ b/e2e/ARCHITECTURE.md @@ -0,0 +1,303 @@ +# E2E architecture + +Flotilla's end-to-end suite runs the real app against a relay network that is entirely under the +test's control. Nothing in this directory may ever open a connection to a host the test did not +create. That is the single invariant everything else is arranged around. + +## The relay + +Every spec runs against a real [zooid](https://github.com/coracle-social/zooid) relay in Docker, the +implementation Flotilla is built for, so protocol drift between the client's assumptions and a real +relay shows up as a failing test. + +zooid is multi-tenant: it binds a config to a `Host` header and serves any number of virtual relays +from one process. `harness/zooid/config.ts` names them, one toml apiece in `harness/zooid/docker/`, +and a scenario picks one by name. A second space costs a config file and no extra process, which is +what keeps outbox routing and cross-space isolation testable. + +A relay's policy is its toml and nothing else. A scenario says what is _on_ a relay — its rooms, its +members, its messages — never what the relay _is_, so there is no policy negotiation anywhere in the +harness. + +### Why the relays are called `.test` + +The container listens on plaintext loopback, but a url that is local or insecure is dropped from +every relay selection unless the caller opts in — see `isLocalUrl` and `RelaySelection.getUrls` in +`@welshman/util` — and Flotilla never opts in, since in production neither belongs in a routing +decision. Handed `ws://localhost:3334/`, the client would load a space by its explicit url and +resolve nothing else: no outbox loads for profiles or relay lists, no relay hints. + +So the client is given `wss://space.test/` and never learns the container has an address. Everything +that speaks to it goes through `zooid/transport.ts`, which dials `127.0.0.1:3334` carrying the two +headers a TLS terminator adds in front of a real deployment: + +- `Host: space.test` — zooid's dispatcher binds a config to a Host (`cmd/relay/main.go`), so this is + what selects which toml answers. +- `X-Forwarded-Proto: https` — khatru derives the url it checks NIP-42 and NIP-86 signatures against + from Host plus this (`getBaseURL` in `khatru/relay.go`), arriving at `wss://space.test/`, which is + exactly what the client signed into its `relay` tag. Seeding signs the same url, so a fixture is + written over the same relay the app talks to. + +`.test` is reserved by RFC 2606 and resolves nowhere, so a url that ever escapes this process fails +to connect rather than reaching a host. + +## Transport: one interception point + +All relay traffic is intercepted in the **Node** process via Playwright's `routeWebSocket`, applied +to the `BrowserContext` so every page in it is covered: + +``` +browser context ──▶ context.routeWebSocket(everything but vite's hmr socket) + │ + ▼ + zooid.relays.get(url) + │ + ┌─────────────────┴─────────────────┐ + │ │ + a socket this process opens unknown url: a relay that + to the container, as that holds no events, and the + relay's virtual host url is recorded as a leak +``` + +Every socket the browser opens is terminated here, in this process, and the only one that leaves it +is the loopback connection `zooid/transport.ts` makes to the container the test started. +`assertNoLeaks()` fails a test that touched a url the scenario never declared. + +Interception is installed by `as()`, on a context it creates, so a page that came from anywhere else +has none of it. Playwright's own `context` fixture — and the `page` fixture built on it — is +therefore overridden to throw, so a spec written the ordinary way fails immediately with a message +saying so rather than quietly dialling the relays in `.env`. The built-in `request` fixture goes the +same way: an `APIRequestContext` is an http client in the node process that belongs to no browser +context, so the block-all below cannot see it and nothing records what it sent. + +`playwright` is the one fixture left alone, and it has to be: it is where the run's own browser comes +from, so every test would fail if it threw. A spec that goes around `as()` through +`playwright.request` or `playwright.chromium.launch()` reaches the network unwatched, and no fixture +can refuse that without refusing the suite. + +Interception in Node rather than in the page is what makes multi-user testing work. Three browser +contexts logged in as three different users all dispatch into the _same_ relay, so one user +genuinely observes another user's writes, over the wire, through the client's real socket stack. + +### Why not an `AdapterFactory` + +The obvious alternative — `new App({getAdapter})` returning a `Repository`-backed adapter — cannot +test authentication. NIP-42 lives on `Socket`: `AuthState` listens to `SocketEvent.Receiving`/ +`Sending`, and `socketPolicyAuthBuffer` replays messages that were rejected with `auth-required:`. +An `AbstractAdapter` whose `sockets` getter returns `[]` never constructs any of that. Patching the +transport instead leaves `Pool → Socket → SocketAdapter` untouched, so auth, message buffering, +replay-after-auth and reconnect are all exercised as written. + +## HTTP + +Relays are not the only egress. `installHttpRoutes` routes every url that is not the dev server — a +predicate rather than a `"**/*"` pattern, so the hundreds of module requests a sveltekit page makes +in dev are never even matched — and aborts what it catches. Two origins get past it: the dev server +on `localhost:1847`, which is left unrouted, and each relay's own origin, which is forwarded to the +container by the same transport carrying the same two headers, so the NIP-11 document and the NIP-86 +management API the app reads are the real relay's answers, signed against the url the client used. +Every other request is aborted and recorded. + +A relay's NIP-11 document decides whether a space is synced by reconciliation or a plain REQ +(NIP-77), whether a message the UI composes carries a protected `-` tag (NIP-70), what the space is +called, and which pubkey room state is trusted from. Its NIP-86 answers decide whether the user is an +admin, since a relay refuses management calls from anyone else and the method list that comes back +doubles as the client's permission set — the space, room, event and pin menus, the directory and the +library are all gated on it. + +Services the app talks to are then mocked back in per-scenario, so a blocked request is always a bug +rather than ambient noise: Dufflepud (`dufflepud.coracle.social`), Blossom uploads, the push server +(`nps.flotilla.social`), the hosting API, LiveKit token endpoints, and image/thumbnail fetches. The +analytics script hard-coded in `src/app.html` is mocked with an empty body: it is requested on every +navigation whatever the scenario is doing, so leaving it to the block-all would make +`assertNoBlockedRequests()` a statement about the page shell rather than about the test. + +## Containment + +A browser has a fixed set of ways to put bytes on a wire. + +| how the app can reach the network | what stops it | +| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WebSocket` — `@welshman/net`'s `Socket`, the only constructor call | `context.routeWebSocket(url => !isDevServerUrl(url))`. Terminated in node; an undeclared url gets a relay holding nothing and `assertNoLeaks()` fails the test | +| `fetch` / `XMLHttpRequest` — app code, welshman, `@pomade/core` | `context.route(url => !isDevServerUrl(url))`, which aborts unless a mock registered later answers first | +| `img`, `script`, `link`, fonts, media, and every other subresource | the same route. Images never reach it — `mockImages` answers anything with `resourceType() === "image"` with a 1×1 png | +| navigation, including the external links the ui offers | the same route: a document request is routed like any other, and a popup opens in the context that owns it | +| `EventSource` | the same route. Neither `src` nor welshman constructs one | +| `navigator.sendBeacon` | no call site. The one script that would use it is the plausible tag in `src/app.html`, served with an empty body | +| a service worker | `serviceWorkers: "block"` on the context. Playwright does not route a worker's requests, so the worker is refused instead | +| a web worker | no call site in `src`; `@pomade/core`'s is argon2, which is cpu and no socket | +| Capacitor's native http and push plugins | not reachable from a browser, and the zooid config leaves `[push]` disabled so nothing is asked to register | + +Two connections leave this process, both to something the test started: the browser's to the vite dev +server on `localhost:1847`, and `zooid/transport.ts`'s to `127.0.0.1:3334`. Everything else the app +initiates dies in node. + +The two ends are enforced differently. A websocket to a url no scenario declared is answered rather +than refused — by a relay that EOSEs every REQ and accepts every event into the void — and the url is +recorded, so the test fails on `assertNoLeaks()` naming it rather than on a timeout somewhere +downstream. An http request nothing mocked is aborted outright, but noticing it is opt-in: +`assertNoBlockedRequests()` is for a spec that has mocked what it exercises, because some of what a +page asks for is meant to be refused. + +Configuration is the other half. `boot()` overrides every `VITE_` value that names a relay — default, +indexer, search, messaging, signer, platform, blocked, the default space list — with the scenario's +own urls, through the hook `src/app/env.ts` reads them with, and points `VITE_PUSH_BRIDGE` at `ws://localhost:1/`, which nothing serves, so a push +bridge connection is reported as a leak rather than blending into a relay's traffic. The `VITE_` +values it does not override still name real hosts — the blossom server, the pomade signers, the +thumbnail service, the push server, the hosting api — and none of them is contacted at boot. Blossom +is read only when an upload starts, the thumbnail url only on android, pomade only when a signup uses +it, and the hosting api and dufflepud are mocked. They are contained by the block-all rather than by +configuration, which is the weaker of the two guarantees. + +Grepping the repo for hostnames accounts for all of them, and there are only three kinds. Most are an +`href` in help text — nostr.com, nostrapps.com, nsec.app, nosta.me, nostr.how, github.com, +fountain.fm, cal.com, figma.com, coracle.tools, gitea.coracle.social, nwc.getalby.com, and the +`coracle.social` entity links `src/app/env.ts` builds — reachable only by clicking, and routed if +clicked. Two are fetched: dufflepud, whose origin is the one service url hard-coded rather than read +from `VITE_`, and the plausible tag in `src/app.html`. Both are mocked. The rest are in comments. +Under `e2e` the only hostnames are the four service origins `net/http.ts` matches on, the virtual +relays' own `.test` names, and the loopback address `zooid/transport.ts` dials. + +Three things this does not cover: + +- **WebRTC.** `livekit-client` opens an `RTCPeerConnection`, and Playwright cannot see one. A voice + or video room reaches its sfu over ice and dtls with nothing in between. `mockLivekit` decides + where the client is pointed, which is why its `serverUrl` has to be something the test owns, and + why `[livekit]` is omitted from the zooid config entirely. A spec that joins a call escapes this + document's guarantee and needs its own answer. +- **Playwright's own fixtures.** `context`, `page` and `request` are overridden to throw, but + `playwright` cannot be — it is where the browser comes from. A spec that reaches the network + through `playwright.request` or `playwright.chromium.launch()` is unwatched. +- **The browser itself.** A browser's own traffic is not the app's and is not routed. Playwright + launches chromium with `--disable-background-networking`, `--disable-component-update` and + `--disable-breakpad`, which is the whole of the mitigation, and which is chromium's alone — + `E2E_BROWSER=firefox` or `webkit` runs the suite under an engine those flags say nothing about. + +## Determinism without freezing the clock + +Timestamps are never patched. Instead every fixture is generated fresh at the start of each test and +signed with timestamps relative to the moment the test began: + +```ts +const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.message(user.alice, "general", "morning all", at(2, HOUR)) + space.message(user.bob, "general", "morning!", at(90, MINUTE)) +}) +``` + +`at(2, HOUR)` is `now() - int(2, HOUR)` evaluated once per test, so "2 hours ago" renders the same +way on every run without the app's `now()` being touched. Fixtures are published over a real socket, +authenticated as the identity that signed them, so the relay stores exactly what it would have stored +for a real client: there are no pre-signed JSON blobs to drift out of date, and a fixture the relay +would have refused fails the test instead of appearing in a query. + +## Users and sessions + +Test identities are deterministic secp256k1 keypairs derived from fixed secrets (`alice`, `bob`, +`carol`, `admin`), so a pubkey is stable across runs and can be asserted on directly. + +A logged-in user is created by injecting a NIP-01 session before the app boots. `src/app/session.ts` +carries a DEV-only hook: `restoreSession()` prefers `window.__TEST_SESSION__` when it is present, and +`window.__TEST_EVENTS__` becomes the repository contents a returning user's client would have found +on disk. Both branches are stripped from production builds, and injecting through the hook avoids +writing Capacitor's `SecureStorage`/`Preferences` localStorage encoding by hand. + +The injected events go in _after_ `storage.ready`. Storage loads what the last session left behind +with `Repository.load`, which clears the repository before inserting, so anything put there while +IndexedDB is still opening is wiped a few milliseconds later — before the layout has rendered +anything, and long before `authPolicy` reads the room list to decide whether to answer a relay's AUTH +challenge. + +The cache exists for the harness rather than for the app. In production the client bootstraps itself: +asking a keyed collection for a pubkey loads it, so `authPolicy` reading the user's room and relay +lists to decide whether to answer a challenge is itself what fetches them, as is any outbox-routed +load of the user's own data. Kind-10002 comes back from the public indexers, and `syncRelayList` +cascades into the room list from there. + +Here the indexers _are_ the scenario's relays, because `boot()` points every relay list at them, and +those are members-only — so that first load is refused with `auth-required:`, while `authPolicy`, +conservative by default, will not sign for a url no list has named yet. Each waits on the other. The +injected room list breaks the circle, and it is what a user who joined a space through the UI would +have on disk anyway. + +The price is that no spec exercises the bootstrap: every one of them starts already knowing which +relays it belongs to, so a regression in the chain from relay list to room list, or in `authPolicy`'s +conservative gate, leaves the suite green. Covering it needs a scenario with a public relay standing +in for an indexer. + +Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and +localStorage, so nothing bleeds between users. + +## Layout + +``` +e2e/ + ARCHITECTURE.md this document + harness/ + index.ts everything a spec imports: `test`, `expect`, helpers + keys.ts deterministic keypairs + zooid/ + config.ts the virtual relays and their hosts — the one place they are named + relay.ts docker lifecycle, reset, seeding over its own authenticated socket + transport.ts the only thing that knows the container's address: ws and http to it + testRelay.ts the seeding affordances a scenario builds on + types.ts TestRelay, RoomOptions, RelayConnection + docker/ + compose.yaml no data volume; tmpfs for /app/data and /app/media + config/ one toml per virtual relay: its host, and its whole policy + net/ + websocket.ts routeWebSocket install, dispatch, transcript, leak detection + http.ts block-all + per-service mocks + app/ + boot.ts env overrides, navigate, wait for mount and for the app to have read + the overrides + session.ts NIP-01 session injection + seed/ + scenario.ts the `seed()` builder and relative-time helpers + space.ts one space's fixtures: rooms, members, messages, replies, profiles + specs/ + *.spec.ts +``` + +One piece lives outside this directory: `src/lib/test/session.ts` holds the two window keys and the +getters `src/app/session.ts` reads them through. It is the only file the app ships on the harness's +behalf, and `harness/app/session.ts` duplicates the key names rather than importing them, since +importing anything under `src` would pull sveltekit into the node process. + +## Resetting between tests + +`Zooid.reset()` recreates the container rather than restarting it: with no volume mounted for +`/app/data`, storage lives in the container's writable layer and a tmpfs, so a fresh container is a +fresh database. The configs are bind-mounted read-only and survive, so every test starts against the +same relays with nothing in them. The container is a worker fixture, so the docker start-up cost is +paid once per worker rather than once per test. + +Seeding then writes over a real authenticated socket, one per identity per relay, held open for the +rest of the test. A fixture must therefore be signed by one of the identities in `harness/keys.ts`, +since zooid authenticates every write. + +## Running + +The suite is not run by agents (see CLAUDE.md). + +```sh +pnpm exec playwright install # once +docker pull gitea.coracle.social/coracle/zooid:latest # once; the harness never pulls +pnpm test # starts and stops the container itself +``` + +Every test skips when docker is unavailable, rather than failing. + +One engine per run rather than a project per engine: these specs exercise sockets, auth and sync, so +a third copy of each buys much less than it costs. `E2E_BROWSER=webkit pnpm test` runs the whole +suite under another one. The container listens on a fixed port and cannot be sharded, which is why +`workers` is 1. + +`src/app/env.ts` resolves every `VITE_` value through a DEV-only hook that prefers +`window.__TEST_ENV__`, which `boot()` injects per browser context, so any dev server will do and a +server already listening on `:1847` is reused. `boot()` still checks that the app read the injected +values, and fails naming the hook rather than letting a run drift onto the relays in `.env`. diff --git a/e2e/harness/app/boot.ts b/e2e/harness/app/boot.ts new file mode 100644 index 00000000..0ff377a8 --- /dev/null +++ b/e2e/harness/app/boot.ts @@ -0,0 +1,96 @@ +import type {BrowserContext} from "@playwright/test" +import {MINUTE, int, ms} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import type {TestUser} from "../keys" +import {injectEvents, injectSession} from "./session" + +// Must match TEST_ENV_KEY in src/lib/test/env.ts. +const TEST_ENV_KEY = "__TEST_ENV__" + +// Set the first time the app reads a value out of the injected env, which is this side's only +// evidence that the hook in src/app/env.ts ran at all. +const TEST_ENV_READ_KEY = "__TEST_ENV_READ__" + +export type BootOptions = { + // Every relay list the app reads at startup is pointed here, so the urls it dials on its own + // initiative can only ever be relays the scenario created. + relays: string[] + spaces?: string[] + user?: TestUser + // What this user's client already has in local storage, e.g. their room list. + events?: TrustedEvent[] + path?: string +} + +export const boot = async ( + context: BrowserContext, + {relays, spaces = [], user, events = [], path = "/"}: BootOptions, +) => { + const urls = relays.join(",") + + await context.addInitScript( + ([key, readKey, env]) => { + Object.assign(window, { + [key]: new Proxy(env, { + get(target, prop) { + Object.assign(window, {[readKey]: true}) + + return Reflect.get(target, prop) + }, + }), + }) + }, + [ + TEST_ENV_KEY, + TEST_ENV_READ_KEY, + { + VITE_DEFAULT_RELAYS: urls, + VITE_INDEXER_RELAYS: urls, + VITE_DEFAULT_SEARCH_RELAYS: urls, + VITE_DEFAULT_MESSAGING_RELAYS: urls, + VITE_SIGNER_RELAYS: urls, + VITE_DEFAULT_SPACES: spaces.join(","), + VITE_PLATFORM_RELAYS: "", + VITE_BLOCKED_RELAYS: "", + // Nothing serves this url, so a push bridge connection is reported as a leak instead of + // blending into the traffic of a relay the scenario did create. + VITE_PUSH_BRIDGE: "ws://localhost:1/", + }, + ] as const, + ) + + if (user) { + await injectSession(context, user) + await injectEvents(context, events) + } + + const page = await context.newPage() + + await page.goto(path) + + // The root layout renders nothing until its async setup block resolves, so the shell appearing + // is the first point at which the app is really running. With a session injected, wait for the + // signed-in nav instead — one the app rejected renders the landing dialog, and failing on that + // here is much easier to read than the assertions it would break later. + await page + .locator(user ? ".primary-nav" : ".fl") + .waitFor({state: "attached", timeout: ms(int(1, MINUTE))}) + + // src/app/env.ts reads every VITE_ value as it is imported, so by now the app has either resolved + // them against the env above or against .env's real relays — which would otherwise surface as + // every assertion in the suite timing out. + const usedTestEnv = await page.evaluate( + key => Boolean(Reflect.get(window, key)), + TEST_ENV_READ_KEY, + ) + + if (!usedTestEnv) { + throw new Error( + "The app read none of its VITE_ values from the harness, so it is pointed at the relays in " + + ".env rather than at this scenario's. Check that src/app/env.ts still resolves them " + + "through maybeGetTestEnv, and that the dev server is running in dev mode.", + ) + } + + return page +} diff --git a/e2e/harness/app/session.ts b/e2e/harness/app/session.ts new file mode 100644 index 00000000..4e274f31 --- /dev/null +++ b/e2e/harness/app/session.ts @@ -0,0 +1,30 @@ +import type {BrowserContext} from "@playwright/test" +import type {TrustedEvent} from "@welshman/util" +import type {TestUser} from "../keys" + +// Must match TEST_SESSION_KEY and TEST_EVENTS_KEY in src/lib/test/session.ts. +const TEST_SESSION_KEY = "__TEST_SESSION__" + +const TEST_EVENTS_KEY = "__TEST_EVENTS__" + +// A nip01 session in the {method, data} shape @welshman/app's session handlers deserialize, so +// restoreSession can build a signer from it without any of the storage encoding a real login +// would have gone through. addInitScript runs before any page script, so this must be called +// before navigating, and it is installed on the context so every page in it boots as this user. +export const injectSession = (context: BrowserContext, user: TestUser) => + context.addInitScript( + ([key, session]) => { + Object.assign(window, {[key]: session}) + }, + [TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const, + ) + +// The repository contents the app loads once the injected session is restored — the local cache +// a user who had used the app before would boot with. +export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) => + context.addInitScript( + ([key, value]) => { + Object.assign(window, {[key]: value}) + }, + [TEST_EVENTS_KEY, events] as const, + ) diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts new file mode 100644 index 00000000..6db81b91 --- /dev/null +++ b/e2e/harness/index.ts @@ -0,0 +1,165 @@ +import {test as base, expect} from "@playwright/test" +import type {BrowserContext, BrowserContextOptions, Page} from "@playwright/test" +import type {Maybe, MaybeAsync} from "@welshman/lib" +import {normalizeRelayUrl} from "@welshman/util" +import {Zooid, describeDockerProblem} from "./zooid/relay" +import { + installHttpRoutes, + mockAnalytics, + mockDufflepud, + mockHosting, + mockImages, + mockPushServer, +} from "./net/http" +import {assertNoLeaks, installWebSocketRoutes} from "./net/websocket" +import {boot} from "./app/boot" +import {seed} from "./seed/scenario" +import type {Scenario, SeedTools} from "./seed/scenario" +import type {TestUser} from "./keys" + +export {expect} +export {users} from "./keys" +export type {TestUser} from "./keys" +export type {Scenario} from "./seed/scenario" +export type {SeededSpace} from "./seed/space" +export type {TenantName} from "./zooid/config" +export type {TranscriptEntry} from "./net/websocket" +export {formatTranscript, getTranscript} from "./net/websocket" +export {assertNoBlockedRequests, mockBlossom, mockLivekit} from "./net/http" + +// Mirrors encodeRelay in src/app/relays.ts, which can't be imported here — it reaches the app's +// module graph, and with it sveltekit. +const encodeRelay = (url: string) => + encodeURIComponent( + normalizeRelayUrl(url) + .replace(/^wss:\/\//, "") + .replace(/\/$/, ""), + ) + +export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}` + +export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}` + +export type Harness = { + zooid: Zooid + seed(build: (tools: SeedTools) => MaybeAsync): Promise + // A logged-in page for a user: its own browser context, its own storage, its own sockets into + // the relays every other user is talking to. `options` overrides the project's context options, + // for a spec that needs a viewport or colour scheme of its own. + as(user: TestUser, path?: string, options?: BrowserContextOptions): Promise +} + +export type HarnessFixtures = { + harness: Harness + seed: Harness["seed"] + as: Harness["as"] +} + +export type HarnessWorkerFixtures = { + zooid: Zooid +} + +export const test = base.extend({ + // Playwright's own context — and the `page` fixture built on it — is unrouted: no websocket + // interception, no http block-all, no injected env, and nothing collecting leaks from it. A page + // born there boots the app against the relays baked into .env, which is the one thing this suite + // exists to prevent, so it is refused outright and `as()` is the only way to get a page. + context: async () => { + throw new Error( + "The built-in `context` and `page` fixtures reach the real network. Open a page with the " + + "harness's `as(user, path)` fixture, which installs interception before it navigates.", + ) + }, + // Playwright builds this one with `playwright.request.newContext()`, so it is an http client in + // this process that belongs to no browser context: `installHttpRoutes` cannot see it and nothing + // records what it sent. + request: async () => { + throw new Error( + "The built-in `request` fixture makes http requests from node, where nothing intercepts " + + "them. Anything the app fetches belongs in a mock installed by `as(user, path)`.", + ) + }, + // One container per worker, torn down when the worker ends. It is reset between tests rather + // than recreated, so the docker start-up cost is paid once. + zooid: [ + // playwright reads a fixture's dependencies off this pattern, so it has to stay a pattern + // even when there are none. + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const zooid = new Zooid() + + await use(zooid) + await zooid.stop() + }, + {scope: "worker"}, + ], + harness: async ({zooid, browser}, use, testInfo) => { + const dockerProblem = await describeDockerProblem() + + testInfo.skip(Boolean(dockerProblem), dockerProblem) + + await zooid.start() + await zooid.reset() + + const contexts: BrowserContext[] = [] + + let scenario: Maybe + + const requireScenario = () => { + if (scenario) return scenario + + throw new Error("Seed a scenario before opening a page for a user") + } + + const as = async (user: TestUser, path = "/", options: BrowserContextOptions = {}) => { + const {urls, cache} = requireScenario() + // The project's own `use` first, so a viewport, colour scheme or device descriptor set in + // playwright.config.ts reaches the context rather than being silently dropped. + // + // A request a service worker makes is not seen by context.route, so a worker is the one + // way out of the block-all below. Sveltekit registers src/service-worker.js on every + // navigation in dev; it has no fetch handler today, and blocking registration is what keeps + // that from being the thing containment rests on. + const context = await browser.newContext({ + ...testInfo.project.use, + serviceWorkers: "block", + ...options, + }) + + contexts.push(context) + + // Interception before navigation, and the block-all before the mocks — playwright matches + // the most recently registered route first, and every mock falls through what it doesn't + // recognize, so the mocks have to be registered last to be reachable at all. + await installHttpRoutes(context) + await installWebSocketRoutes(context, zooid) + await mockAnalytics(context) + await mockDufflepud(context) + await mockHosting(context) + await mockPushServer(context) + await mockImages(context) + + return boot(context, {user, path, relays: urls, spaces: urls, events: cache(user)}) + } + + await use({ + zooid, + as, + seed: async build => { + scenario = await seed(zooid, build) + + return scenario + }, + }) + + for (const context of contexts) { + await context.close() + } + + for (const context of contexts) { + assertNoLeaks(context) + } + }, + seed: async ({harness}, use) => use(harness.seed), + as: async ({harness}, use) => use(harness.as), +}) diff --git a/e2e/harness/keys.ts b/e2e/harness/keys.ts new file mode 100644 index 00000000..d17e7a69 --- /dev/null +++ b/e2e/harness/keys.ts @@ -0,0 +1,26 @@ +import {getPubkey} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" + +export type TestUser = { + name: string + secret: string + pubkey: string + signer: Nip01Signer +} + +const makeUser = (name: string, secret: string): TestUser => ({ + name, + secret, + pubkey: getPubkey(secret), + signer: new Nip01Signer(secret), +}) + +// The secrets are near-zero entropy on purpose: they never leave the test process, they are +// stable across runs so a pubkey can be asserted on directly, and the leading nibbles make an +// event's author recognizable at a glance in a failed-assertion diff. +export const users = { + alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"), + bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"), + carol: makeUser("carol", "ca20100000000000000000000000000000000000000000000000000000000003"), + admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"), +} diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts new file mode 100644 index 00000000..5c14b8d6 --- /dev/null +++ b/e2e/harness/net/http.ts @@ -0,0 +1,315 @@ +import {createHash} from "node:crypto" +import type {BrowserContext} from "@playwright/test" +import {now} from "@welshman/lib" +import type {Handle} from "@welshman/util" +import type {ZapperValues} from "@welshman/domain" +import {tenantByUrl} from "../zooid/config" +import {requestZooid} from "../zooid/transport" + +// Mirrors the service urls in src/app/env.ts and .env, which this process can't import: env.ts +// reads import.meta.env and pulls in Capacitor. Nothing here is ever fetched — these are route +// patterns, and every handler answers from memory. +const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social" +const PUSH_SERVER_ORIGIN = "https://nps.flotilla.social" +const HOSTING_ORIGIN = "https://api.hosting.coracle.social" + +// Hard-coded in src/app.html, so every navigation asks for it whatever the scenario is doing. +const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social" + +// Relay-hosted livekit lives under a well-known path rather than an origin of its own. +const LIVEKIT_PATH = "/.well-known/nip29/livekit" + +// A 1x1 transparent png, small enough to inline and real enough for an to decode. +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=", + "base64", +) + +// The dev server from vite.config.ts. Traffic to it is the app loading itself rather than egress, +// so it is the one host both layers here let past, websockets included — Vite's hmr socket has to +// keep working. +export const isDevServerUrl = (url: URL) => + url.port === "1847" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) + +export type BlockedRequest = { + method: string + url: string +} + +const blockedByContext = new WeakMap() + +export const getBlockedRequests = (context: BrowserContext) => { + const blocked = blockedByContext.get(context) + + if (blocked) { + return blocked + } + + throw new Error("installHttpRoutes was never called for this browser context") +} + +/** + * Blocks every http request the app makes, except to the dev server and to a relay's own origin, + * which is forwarded to the container so that the nip-11 document and the nip-86 management api the + * app reads are the real relay's. Install this first: the per-service mocks below are registered + * later and therefore take priority, so a request that still reaches this handler is one nothing + * has mocked. + */ +export const installHttpRoutes = (context: BrowserContext) => { + const blocked: BlockedRequest[] = [] + const hostByOrigin = new Map( + Array.from(tenantByUrl, ([url, host]): [string, string] => [ + new URL(url.replace(/^ws/, "http")).origin, + host, + ]), + ) + + blockedByContext.set(context, blocked) + + // The dev server is left unrouted rather than matched and continued: a sveltekit page in dev is + // hundreds of module requests, and none of them is egress. + return context.route( + url => !isDevServerUrl(url), + async route => { + const request = route.request() + const {origin, pathname, search} = new URL(request.url()) + + const host = hostByOrigin.get(origin) + + if (host) { + return route.fulfill( + await requestZooid( + host, + request.method(), + pathname + search, + request.headers(), + request.postDataBuffer() ?? undefined, + ), + ) + } + + blocked.push({method: request.method(), url: request.url()}) + + return route.abort() + }, + ) +} + +/** + * Opt-in, for a spec that wants every request the app made to have been answered by something the + * scenario stood up. Some of what a page asks for is meant to be refused — the blossom probe + * against a relay with blossom off, for one — so call this from a spec that has mocked what it + * exercises rather than from teardown. + */ +export const assertNoBlockedRequests = (context: BrowserContext) => { + const blocked = getBlockedRequests(context) + + if (blocked.length > 0) { + throw new Error( + [ + `The app made ${blocked.length} http request(s) nothing served:`, + ...blocked.map(({method, url}) => ` ${method} ${url}`), + ].join("\n"), + ) + } +} + +/** + * The analytics script src/app.html loads on every page. Answering with an empty body leaves + * `window.plausible` as the queueing shim src/app/analytics.ts installs, so pageviews accumulate + * in memory and nothing is ever sent — and `assertNoBlockedRequests` stays a statement about the + * scenario rather than about the page shell. + */ +export const mockAnalytics = (context: BrowserContext) => + context.route(`${PLAUSIBLE_ORIGIN}/**`, route => + route.fulfill({contentType: "application/javascript", body: ""}), + ) + +export type DufflepudFixtures = { + // The remote half of the read-state sync in src/app/notifications.ts, keyed by path. + checked?: Record + // A link preview is only rendered when it carries a title or an image. + preview?: {title?: string; description?: string; image?: string} + handles?: {handle: string; info?: Handle}[] + // `lnurl` is hex here, not bech32 — that's the encoding dufflepud speaks. + zappers?: {lnurl: string; info?: Omit}[] +} + +export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) => + context.route(`${DUFFLEPUD_ORIGIN}/**`, route => { + const {pathname} = new URL(route.request().url()) + + if (pathname === "/link/preview") { + return route.fulfill({json: fixtures.preview ?? {}}) + } + + if (pathname === "/handle/info") { + return route.fulfill({json: {data: fixtures.handles ?? []}}) + } + + if (pathname === "/zapper/info") { + return route.fulfill({json: {data: fixtures.zappers ?? []}}) + } + + if (pathname === "/kv/checked") { + return route.fulfill({ + json: route.request().method() === "GET" ? (fixtures.checked ?? {}) : {}, + }) + } + + return route.fallback() + }) + +export type BlossomOptions = { + // The blossom server the scenario expects an upload to land on, e.g. a space's own url. + server: string +} + +/** + * A blossom server that keeps what it was given: an upload is hashed exactly as the real thing + * would be, so the descriptor it answers with points at a blob this mock can then serve back. + */ +export const mockBlossom = (context: BrowserContext, {server}: BlossomOptions) => { + const {origin} = new URL(server) + const blobs = new Map() + + return context.route(`${origin}/**`, route => { + const request = route.request() + const method = request.method() + const {pathname} = new URL(request.url()) + + if (pathname === "/upload") { + if (method === "HEAD") { + return route.fulfill({status: 200, body: ""}) + } + + if (method === "PUT") { + const body = request.postDataBuffer() ?? Buffer.alloc(0) + const type = request.headers()["content-type"] ?? "application/octet-stream" + const sha256 = createHash("sha256").update(body).digest("hex") + + blobs.set(sha256, {body, type}) + + return route.fulfill({ + json: {sha256, type, url: `${origin}/${sha256}`, size: body.length, uploaded: now()}, + }) + } + } + + const blob = blobs.get(pathname.slice(1).split(".")[0]) + + if (blob) { + return route.fulfill({ + contentType: blob.type, + body: method === "HEAD" ? "" : blob.body, + }) + } + + return route.fallback() + }) +} + +/** + * The push server from src/app/push/adapters/capacitor.ts. Only the native adapters talk to it, + * so a browser run reaches this only if the platform detection regresses. + */ +export const mockPushServer = (context: BrowserContext) => + context.route(`${PUSH_SERVER_ORIGIN}/**`, route => { + const [resource] = new URL(route.request().url()).pathname.split("/").filter(Boolean) + + if (resource === "subscription") { + if (route.request().method() === "DELETE") { + return route.fulfill({json: {}}) + } + + // Registration is only usable if it comes back with both, and the relay is told to post + // notifications to the callback rather than the client ever fetching it. + const key = "test-push-subscription" + + return route.fulfill({json: {key, callback: `${PUSH_SERVER_ORIGIN}/callback/${key}`}}) + } + + return route.fallback() + }) + +// Records straight off the hosting api, whose shapes live in src/app/hosting.ts. They're typed as +// plain objects here so that mocking a payment flow doesn't pull the app's module graph — and +// with it import.meta.env — into the node process. +export type HostingFixtures = { + plans?: object[] + // Provisioning runs on every login and ignores what it gets back, so the empty default carries + // any spec that isn't actually exercising the hosting ui. + tenant?: object + relays?: object[] + invoices?: object[] + draftInvoice?: object +} + +export const mockHosting = (context: BrowserContext, fixtures: HostingFixtures = {}) => + context.route(`${HOSTING_ORIGIN}/**`, route => { + const [resource, id, sub, detail] = new URL(route.request().url()).pathname + .split("/") + .filter(Boolean) + + if (resource === "plans") { + return route.fulfill({json: {data: fixtures.plans ?? []}}) + } + + if (resource === "tenants") { + if (sub) { + if (sub === "relays") { + return route.fulfill({json: {data: fixtures.relays ?? []}}) + } + + if (sub === "invoices") { + const data = detail === "draft" ? fixtures.draftInvoice : (fixtures.invoices ?? []) + + return route.fulfill({json: {data}}) + } + } else { + return route.fulfill({json: {data: {pubkey: id, ...fixtures.tenant}}}) + } + } + + return route.fallback() + }) + +export type LivekitOptions = { + // Where the client is told to connect. Point it at something the test owns — the token this + // hands out is accepted by nothing else. + serverUrl: string + token?: string +} + +export const mockLivekit = (context: BrowserContext, {serverUrl, token}: LivekitOptions) => + context.route( + url => url.pathname.startsWith(LIVEKIT_PATH), + route => { + const roomId = new URL(route.request().url()).pathname.slice(LIVEKIT_PATH.length + 1) + + if (roomId) { + return route.fulfill({ + json: {server_url: serverUrl, participant_token: token ?? "test-participant-token"}, + }) + } + + // The support probe in src/lib/livekit.ts reads support off the status alone. + return route.fulfill({status: 204, body: ""}) + }, + ) + +/** + * Serves a png for anything the browser is loading as an image — avatars, banners, blossom blobs, + * video posters — so a scenario's fixtures can reference urls without any of them being fetched. + */ +export const mockImages = (context: BrowserContext) => + context.route( + url => !isDevServerUrl(url), + route => { + if (route.request().resourceType() === "image") { + return route.fulfill({contentType: "image/png", body: PNG}) + } + + return route.fallback() + }, + ) diff --git a/e2e/harness/net/websocket.ts b/e2e/harness/net/websocket.ts new file mode 100644 index 00000000..81763dd0 --- /dev/null +++ b/e2e/harness/net/websocket.ts @@ -0,0 +1,130 @@ +import type {BrowserContext, WebSocketRoute} from "@playwright/test" +import {call, parseJson} from "@welshman/lib" +import {normalizeRelayUrl} from "@welshman/util" +import {RelayMessageType, isClientEvent, isClientReq} from "@welshman/net" +import type {ClientMessage, RelayMessage} from "@welshman/net" +import type {RelayConnection} from "../zooid/types" +import type {Zooid} from "../zooid/relay" +import {isDevServerUrl} from "./http" + +export type Direction = "toRelay" | "toClient" + +export type TranscriptEntry = { + url: string + direction: Direction + message: ClientMessage | RelayMessage +} + +type Traffic = { + transcript: TranscriptEntry[] + leaks: Set +} + +const trafficByContext = new WeakMap() + +const getTraffic = (context: BrowserContext) => { + const traffic = trafficByContext.get(context) + + if (traffic) { + return traffic + } + + throw new Error("installWebSocketRoutes was never called for this browser context") +} + +// A relay that holds nothing: REQs get an immediate EOSE and events are accepted into the void. +// Only urls the container does not serve get one, so a leak fails on the assertion that names it +// rather than on a timeout three layers away. +const openEmptyRelay = (): RelayConnection => { + let listener: (message: RelayMessage) => void = () => undefined + + return { + onMessage(f) { + listener = f + }, + send(message) { + if (isClientReq(message)) { + listener([RelayMessageType.Eose, message[1]]) + } else if (isClientEvent(message)) { + listener([RelayMessageType.Ok, message[1].id, true, ""]) + } + }, + close() {}, + } +} + +const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => { + const url = normalizeRelayUrl(route.url()) + const connection = call(() => { + const relay = zooid.relays.get(url) + + if (relay) { + return relay.connect() + } + + traffic.leaks.add(url) + + return openEmptyRelay() + }) + + connection.onMessage(message => { + traffic.transcript.push({url, direction: "toClient", message}) + route.send(JSON.stringify(message)) + }) + + route.onMessage(frame => { + const message = parseJson(frame.toString()) + + if (message) { + traffic.transcript.push({url, direction: "toRelay", message}) + connection.send(message) + } + }) + + route.onClose(() => connection.close()) +} + +/** + * The single interception point for relay traffic. It goes on the context rather than a page, so + * every page in it is covered including ones opened later, and it is safe to call before any page + * exists — routing is the environment a page is born into, not a step in a sequence. + * + * Vite's hmr socket is the one url left alone. Everything else is answered from this process, and + * a url that is not one of the container's virtual relays is served by an empty relay and recorded + * as a leak. + */ +export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => { + const traffic: Traffic = {transcript: [], leaks: new Set()} + + trafficByContext.set(context, traffic) + + return context.routeWebSocket( + url => !isDevServerUrl(url), + route => serve(traffic, zooid, route), + ) +} + +export const getTranscript = (context: BrowserContext) => getTraffic(context).transcript + +// Every frame in both directions, oldest first. Attach it to a failing test to see what the client +// actually said, and to whom. +export const formatTranscript = (context: BrowserContext) => + getTranscript(context) + .map( + ({url, direction, message}) => + `${direction === "toRelay" ? ">>" : "<<"} ${url} ${JSON.stringify(message)}`, + ) + .join("\n") + +export const assertNoLeaks = (context: BrowserContext) => { + const {leaks} = getTraffic(context) + + if (leaks.size > 0) { + throw new Error( + [ + `The app opened a websocket to ${leaks.size} url(s) the scenario never created:`, + ...Array.from(leaks).map(url => ` ${url}`), + ].join("\n"), + ) + } +} diff --git a/e2e/harness/seed/scenario.ts b/e2e/harness/seed/scenario.ts new file mode 100644 index 00000000..e7a4ef5d --- /dev/null +++ b/e2e/harness/seed/scenario.ts @@ -0,0 +1,113 @@ +import {int, now} from "@welshman/lib" +import type {MaybeAsync} from "@welshman/lib" +import {ROOMS, makeEvent} from "@welshman/util" +import type {SignedEvent} from "@welshman/util" +import type {Zooid} from "../zooid/relay" +import type {TenantName} from "../zooid/config" +import {users} from "../keys" +import type {TestUser} from "../keys" +import {seedSpace} from "./space" +import type {SeededSpace} from "./space" + +// A fixture timestamp, as an offset from the moment the scenario started: `at(2, HOUR)` is two +// hours before the test began. Count-first, like int and ago. +export type At = (count: number, unit: number) => number + +export type SeedTools = { + // Names a relay the container already serves — its policy is its toml in zooid/docker/config, so + // a scenario describes what is on a relay, never what the relay is. + relay: (name: TenantName) => SeededSpace + user: typeof users + at: At +} + +export type Scenario = { + readonly startedAt: number + readonly at: At + readonly urls: string[] + space(name: TenantName): SeededSpace + // The events a returning user's client would already have on disk. Just the room list: the + // scenario's own relays stand in for the indexers, and a members-only relay won't serve the list + // that would tell authPolicy it may identify to it. See ARCHITECTURE.md, "Users and sessions". + cache(user: TestUser): SignedEvent[] +} + +export const seed = async ( + zooid: Zooid, + build: (tools: SeedTools) => MaybeAsync, +): Promise => { + const startedAt = now() + const at: At = (count, unit) => startedAt - int(count, unit) + const spaces = new Map() + const writes: (() => Promise)[] = [] + const roomLists = new Map() + + const relay = (name: TenantName) => { + const space = seedSpace({ + zooid, + startedAt, + name, + enqueue: write => writes.push(write), + }) + + spaces.set(name, space) + + return space + } + + await build({relay, user: users, at}) + + // Seeding is async and fixtures depend on one another, so the builder only records what to + // write. Draining the queue here publishes each fixture in the order it was declared. + for (const write of writes) { + await write() + } + + const seeded = Array.from(spaces.values()) + + // A room list is replaceable and covers every space at once, so it can only be written after + // all of them have been seeded. + const membershipsByPubkey = new Map() + + for (const space of seeded) { + for (const {user, rooms} of space.memberships) { + const membership = membershipsByPubkey.get(user.pubkey) ?? {user, urls: [], tags: []} + + membership.urls.push(space.url) + membership.tags.push(["r", space.url], ...rooms.map(h => ["group", h, space.url])) + membershipsByPubkey.set(user.pubkey, membership) + } + } + + for (const {user, urls, tags} of membershipsByPubkey.values()) { + const event = await user.signer.sign(makeEvent(ROOMS, {tags, created_at: startedAt})) + + for (const url of urls) { + await zooid.publish(url, event) + } + + roomLists.set(user.pubkey, event) + } + + const getSpace = (name: TenantName) => { + const space = spaces.get(name) + + if (space) return space + + throw new Error(`No space named "${name}" was seeded`) + } + + const cache = (user: TestUser) => { + const roomList = roomLists.get(user.pubkey) + + return roomList ? [roomList] : [] + } + + return { + startedAt, + at, + cache, + urls: seeded.map(space => space.url), + space: getSpace, + } +} diff --git a/e2e/harness/seed/space.ts b/e2e/harness/seed/space.ts new file mode 100644 index 00000000..d4cb9992 --- /dev/null +++ b/e2e/harness/seed/space.ts @@ -0,0 +1,191 @@ +import {neventEncode} from "nostr-tools/nip19" +import type {Maybe, MaybeAsync} from "@welshman/lib" +import { + MESSAGE, + ROOM_ADD_MEMBER, + Resolver, + makeEvent, + tagSpec, + tagValue, + toNostrURI, +} from "@welshman/util" +import type {SignedEvent, StampedEvent} from "@welshman/util" +import {EventWriter, Profile} from "@welshman/domain" +import type {BaseEventReader} from "@welshman/domain" +import type {RoomOptions, TestRelay} from "../zooid/types" +import type {Zooid} from "../zooid/relay" +import type {TenantName} from "../zooid/config" +import {users} from "../keys" +import type {TestUser} from "../keys" + +// @welshman/domain has no writer for NIP-29 kind-9 messages, and none of its readers describe one, +// so this pairs the base writer with the base reader. The behavior tags it renders — `h` via +// setRoom, `q`/`p` via addQuote/addMention — are everything a room message carries. +class MessageWriter extends EventWriter {} + +// A handle to an event the scenario is going to publish. Seeding calls record what to write and +// return before anything is written, so the event is filled in when its turn in the queue comes up. +export type SeededEvent = { + readonly event: SignedEvent + readonly id: string +} + +export type ProfileValues = { + name?: string + about?: string + picture?: string + nip05?: string +} + +// A queued write. Seeding is ordered — a reply's parent has to exist first — so every write goes +// through the scenario's queue rather than starting when it is declared. +export type Enqueue = (write: () => Promise) => void + +// A user's membership as their own client sees it, which the scenario turns into one room list +// per user once every space has been seeded. +export type SeededMembership = { + user: TestUser + rooms: string[] +} + +export type SeededSpace = { + readonly name: TenantName + // The url is read off the relay handle, so this only reads once seeding has run. + readonly url: string + readonly memberships: SeededMembership[] + room(h: string, options?: RoomOptions): void + member(user: TestUser, h?: string): void + // Relay and room membership, plus a place in the user's own room list — what a user who joined + // this space through the ui would end up with. + join(user: TestUser, ...rooms: string[]): void + message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent + reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent + profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent + event(user: TestUser, template: StampedEvent): SeededEvent +} + +export type SeedSpaceOptions = { + zooid: Zooid + enqueue: Enqueue + // The moment the scenario began. A fixture declared without a timestamp is stamped with it + // rather than with the wall clock, so a run's events never drift apart from one another. + startedAt: number + name: TenantName +} + +export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => { + const memberships: SeededMembership[] = [] + + let testRelay: Maybe + + const relay = () => { + if (testRelay) return testRelay + + throw new Error(`Space "${name}" has not been seeded yet, await seed() first`) + } + + enqueue(async () => { + testRelay = await zooid.relay(name) + }) + + // Every fixture is published to this space, so a relay hint always resolves to its url. + const context = {resolver: new Resolver(() => [relay().url])} + + const publish = (write: () => Promise): SeededEvent => { + let signed: Maybe + + const event = () => { + if (signed) return signed + + throw new Error(`An event seeded into "${name}" was read before seed() published it`) + } + + enqueue(async () => { + signed = await write() + }) + + return { + get event() { + return event() + }, + get id() { + return event().id + }, + } + } + + const publishTemplate = (user: TestUser, build: () => MaybeAsync) => + publish(async () => relay().event(user, await build())) + + const room = (h: string, roomOptions: RoomOptions = {}) => + enqueue(() => relay().room(h, roomOptions, startedAt)) + + const member = (user: TestUser, h?: string) => enqueue(() => relay().member(user, h, startedAt)) + + const event = (user: TestUser, template: StampedEvent) => publishTemplate(user, () => template) + + const join = (user: TestUser, ...roomIds: string[]) => { + memberships.push({user, rooms: roomIds}) + member(user) + + for (const h of roomIds) { + event( + users.admin, + makeEvent(ROOM_ADD_MEMBER, { + created_at: startedAt, + tags: [ + ["h", h], + ["p", user.pubkey], + ], + }), + ) + } + } + + const message = (user: TestUser, h: string, content: string, createdAt = startedAt) => + publish(() => relay().message(user, h, content, createdAt)) + + // Flotilla replies in a room by quoting: Content.svelte renders a quote from the nostr uri in the + // content rather than from the q tag, so the uri has to be prepended, as prependParent does in + // src/app/rooms.ts. + const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) => + publishTemplate(user, async () => { + const h = tagValue(tagSpec("h"), parent.event.tags) + + if (h) { + const url = relay().url + const nevent = neventEncode({...parent.event, relays: [url]}) + const template = await new MessageWriter(MESSAGE, context) + .setRoom(url, h) + .addQuote(parent.event) + .addMention(parent.event.pubkey) + .setContent(toNostrURI(nevent) + "\n\n" + content) + .renderTemplate() + + return {...template, created_at: createdAt} + } + + throw new Error(`Cannot reply to ${parent.id}, it is not in a room`) + }) + + const profile = (user: TestUser, values: ProfileValues, createdAt = startedAt) => + publishTemplate(user, async () => ({ + ...(await Profile.configure(context).writer().update(values).renderTemplate()), + created_at: createdAt, + })) + + return { + name, + get url() { + return relay().url + }, + memberships, + room, + member, + join, + message, + reply, + profile, + event, + } +} diff --git a/e2e/harness/zooid/config.ts b/e2e/harness/zooid/config.ts new file mode 100644 index 00000000..ed92de58 --- /dev/null +++ b/e2e/harness/zooid/config.ts @@ -0,0 +1,27 @@ +/** + * The virtual relays the container serves, and the only place their names are written down. + * + * zooid is multi-tenant: it binds a config to a Host header and serves any number of them from one + * process, so a second space costs a toml in docker/config and nothing else. The names are a union + * rather than a string so that a scenario naming a relay that has no config fails to compile + * instead of hanging on a 404 from the dispatcher. + * + * Each host is a name that resolves nowhere. The container is reached on loopback and told what to + * call itself, so that what it calls itself is a url a relay selection will keep — see transport.ts + * for why that matters, and `.test` is reserved by rfc 2606, so a url that escapes this process + * fails to connect rather than reaching a host. + */ +export const tenants = { + space: "space.test", + other: "other.test", +} as const + +export type TenantName = keyof typeof tenants + +export const tenantUrl = (name: TenantName) => `wss://${tenants[name]}/` + +export const tenantNames = Object.keys(tenants) as TenantName[] + +// Which tenant a url belongs to, for the transport's Host header. A url the app opens that is not +// one of these is a leak, and is reported as one rather than dialled. +export const tenantByUrl = new Map(tenantNames.map(name => [tenantUrl(name), tenants[name]])) diff --git a/e2e/harness/zooid/docker/compose.yaml b/e2e/harness/zooid/docker/compose.yaml new file mode 100644 index 00000000..4e708166 --- /dev/null +++ b/e2e/harness/zooid/docker/compose.yaml @@ -0,0 +1,37 @@ +# The relay the whole suite runs against. Bound to loopback only, and the one server this harness is +# allowed to connect to for real. +# +# The image is distroless and runs as `nonroot`. zooid creates DATA and MEDIA itself, but it cannot +# create their parent /app, so both have to arrive already mounted — and writable by uid 65532, +# which is what `mode` below is for: docker defaults a tmpfs to 1777 and podman does not, and a +# tmpfs the relay cannot write is a container that exits 1 the moment it opens its database. +# Nothing is persisted, so `up --force-recreate` is a guaranteed-empty database. +name: flotilla-e2e-zooid + +services: + relay: + image: gitea.coracle.social/coracle/zooid:latest + # The harness dials this published port and sends a `Host` per virtual relay, which is what the + # dispatcher matches against the `host` in each file under config/. + ports: + - "127.0.0.1:3334:3334" + environment: + PORT: "3334" + CONFIG: /app/config + DATA: /app/data + MEDIA: /app/media + volumes: + - type: bind + source: ./config + target: /app/config + read_only: true + - type: tmpfs + target: /app/data + tmpfs: + size: 268435456 + mode: 01777 + - type: tmpfs + target: /app/media + tmpfs: + size: 33554432 + mode: 01777 diff --git a/e2e/harness/zooid/docker/config/other.toml b/e2e/harness/zooid/docker/config/other.toml new file mode 100644 index 00000000..68eb1e87 --- /dev/null +++ b/e2e/harness/zooid/docker/config/other.toml @@ -0,0 +1,44 @@ +# A second virtual relay, identical in policy to space.toml, for specs that need two spaces on +# different relays — outbox routing, cross-space isolation. zooid is multi-tenant, so this costs a +# file and no extra process. +# +# `host` must equal the Host header the harness sends, which is the hostname of the url the client +# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with +# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name +# that resolves nowhere. +host = "other.test" +schema = "e2e_other" +secret = "07be7000000000000000000000000000000000000000000000000000000000e7" + +[info] +# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the +# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the +# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity +# can_manage. +name = "other" +pubkey = "d9220af5f36469d17322f41902b6b30dad1869cbb473b35f5e8f8d8e924f36b8" +description = "Throwaway relay for Flotilla's end-to-end suite." + +[policy] +public_read = false +public_write = false +public_join = true +strip_signatures = false + +[groups] +enabled = true + +[management] +enabled = true + +# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have +# the relay or the client talk to a service the test did not create. +[blossom] +enabled = false + +[push] +enabled = false + +[roles.admin] +pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"] +can_manage = true diff --git a/e2e/harness/zooid/docker/config/space.toml b/e2e/harness/zooid/docker/config/space.toml new file mode 100644 index 00000000..16f60c99 --- /dev/null +++ b/e2e/harness/zooid/docker/config/space.toml @@ -0,0 +1,43 @@ +# One of the virtual relays the container serves, and the only statement of this relay's policy — +# a scenario says what is on a relay, never what the relay is. +# +# `host` must equal the Host header the harness sends, which is the hostname of the url the client +# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with +# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name +# that resolves nowhere. +host = "space.test" +schema = "e2e" +secret = "5e1f000000000000000000000000000000000000000000000000000000000005" + +[info] +# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the +# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the +# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity +# can_manage. +name = "space" +pubkey = "da00132eaf7ef6bbde47e5dc409fa1d481e518db0621af31591e0306eb48ed8a" +description = "Throwaway relay for Flotilla's end-to-end suite." + +[policy] +public_read = false +public_write = false +public_join = true +strip_signatures = false + +[groups] +enabled = true + +[management] +enabled = true + +# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have +# the relay or the client talk to a service the test did not create. +[blossom] +enabled = false + +[push] +enabled = false + +[roles.admin] +pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"] +can_manage = true diff --git a/e2e/harness/zooid/relay.ts b/e2e/harness/zooid/relay.ts new file mode 100644 index 00000000..fb419da2 --- /dev/null +++ b/e2e/harness/zooid/relay.ts @@ -0,0 +1,264 @@ +import {execFile} from "node:child_process" +import {fileURLToPath} from "node:url" +import {promisify} from "node:util" +import {ms, sleep} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" +import {makeRelayAuth} from "@welshman/util" +import type {SignedEvent} from "@welshman/util" +import {ClientMessageType, isRelayAuth, isRelayOk} from "@welshman/net" +import type {ClientMessage} from "@welshman/net" +import {users} from "../keys" +import {tenantNames, tenantUrl, tenants} from "./config" +import type {TenantName} from "./config" +import {makeTestRelay} from "./testRelay" +import {connectToZooid, requestZooid} from "./transport" +import type {ZooidConnection} from "./transport" + +const image = "gitea.coracle.social/coracle/zooid:latest" + +const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url)) + +const testUsersByPubkey = new Map(Object.values(users).map(user => [user.pubkey, user])) + +const execFileAsync = promisify(execFile) + +// execFile does not go through a shell, so a `docker` that exists only as an alias or a function — +// podman, colima, a wrapper in a shell rc file — is invisible to it however well it works when +// typed. Name the executable to drive with E2E_DOCKER in that case. +const dockerCommand = process.env.E2E_DOCKER ?? "docker" + +const docker = (...args: string[]) => execFileAsync(dockerCommand, args) + +const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args) + +// Why the container cannot be driven, or undefined when it can. The two failures need different +// answers — a cli that is not on this process's PATH is usually a shell alias or an inherited +// environment, and telling someone to start docker sends them the wrong way — so they are reported +// apart rather than as one boolean. +const probeDocker = async () => { + try { + await docker("compose", "version") + + return undefined + } catch (error) { + const {code, stderr} = Object(error) as {code?: string; stderr?: string} + + if (code === "ENOENT") { + return ( + `\`${dockerCommand}\` is not on PATH for the test runner. If it works in your shell it is ` + + "an alias or a function rather than an executable, which execFile cannot see: set " + + "E2E_DOCKER to the real command, e.g. `E2E_DOCKER=podman pnpm test`." + ) + } + + return ( + `\`${dockerCommand} compose version\` failed, so the daemon is likely not running. ` + + (stderr?.trim() || String(error)) + ) + } +} + +let problem: Maybe>> + +// Asked once a worker: docker does not come and go mid-run, and the answer is also written to the +// terminal, because a skip reason otherwise reaches only the html report and a run that silently +// skips every test is the least useful thing this can do. +export const describeDockerProblem = () => + (problem ??= probeDocker().then(reason => { + if (reason) { + console.warn( + `\nThe e2e suite cannot drive a container, so every test will skip:\n ${reason}\n`, + ) + } + + return reason + })) + +/** + * The relay every test runs against: one zooid container on loopback, serving a virtual relay per + * entry in `tenants`. Each relay's policy is whatever its toml in docker/config says, which is the + * only place policy is written down — a scenario describes what is *on* a relay, never what the + * relay is. + */ +export class Zooid { + // Every socket into the container — the client's and seeding's alike — is one this process + // opened, so this map is also the definition of a url that is not a leak. + relays = new Map( + tenantNames.map(name => [ + tenantUrl(name), + {connect: () => connectToZooid(tenants[name]), host: tenants[name]}, + ]), + ) + + private sessions = new Map() + + private started = false + + // Verifying docker rather than bringing the container up: every test resets, and that is what + // starts it. Repeat calls are free, so the fixture can call this per test. + start = async () => { + if (this.started) return + + const problem = await describeDockerProblem() + + if (problem) { + throw new Error(problem) + } + + const hasImage = await docker("image", "inspect", image).then( + () => true, + () => false, + ) + + if (!hasImage) { + throw new Error( + `The zooid image ${image} is not present locally. Fetch it with ` + + `\`${dockerCommand} pull ${image}\`, or build it from a zooid checkout with ` + + `\`${dockerCommand} build -t ${image} .\`.`, + ) + } + + this.started = true + } + + relay = async (name: TenantName) => + makeTestRelay({ + name, + url: tenantUrl(name), + publish: event => this.publish(tenantUrl(name), event), + }) + + publish = async (url: string, event: SignedEvent) => { + const relay = this.relays.get(url) + + if (!relay) { + throw new Error(`Attempted to publish to ${url}, which is not one of this container's relays`) + } + + const connection = await this.authenticate(relay.host, event.pubkey) + const {ok, detail} = await this.send(connection, [ClientMessageType.Event, event], event.id) + + if (!ok) { + throw new Error(`zooid refused a kind ${event.kind} fixture: ${detail}`) + } + } + + reset = async () => { + this.closeSessions() + + await this.up() + } + + stop = async () => { + if (this.started) { + this.closeSessions() + + await compose("down") + + this.started = false + } + } + + // Whatever the relay wrote before it died. Compose reports only that a container exited, so + // without this a startup failure is a status code and no reason. + private logs = () => + compose("logs", "--no-color", "--tail", "50").then( + ({stdout, stderr}) => [stdout, stderr].filter(Boolean).join("\n").trim(), + () => "", + ) + + private fail = async (summary: string) => { + const logs = await this.logs() + + throw new Error(logs ? `${summary}\n\nzooid said:\n${logs}` : summary) + } + + // Recreating rather than restarting is what makes this a reset: with storage in tmpfs and no + // volume mounted for it, a new container is a new database. + private up = async () => { + try { + await compose("up", "-d", "--force-recreate", "--wait") + } catch (error) { + const {stderr} = Object(error) as {stderr?: string} + + await this.fail((stderr?.trim() || String(error)).split("\n").slice(-3).join("\n")) + } + + const deadline = Date.now() + ms(30) + + while (Date.now() < deadline) { + // A 404 means the dispatcher has no relay bound to that host yet, so the configs are still + // loading. Anything a relay itself answers means every tenant is ready. + const isUp = await Promise.all( + tenantNames.map(name => + requestZooid(tenants[name], "GET", "/", {accept: "application/nostr+json"}).then( + response => response.status === 200, + () => false, + ), + ), + ).then(results => results.every(Boolean)) + + if (isUp) { + return + } + + await sleep(200) + } + + await this.fail( + `zooid did not answer as ${tenantNames.map(name => tenants[name]).join(" and ")} within 30 ` + + "seconds.", + ) + } + + // NIP-42 binds an identity to a connection, so each test user seeds over its own, per relay. The + // url signed into the auth event is the one the client uses, because that is the one khatru + // rebuilds from the headers transport.ts sends — a fixture is written over the same relay the app + // talks to. + private authenticate = async (host: string, pubkey: string) => { + const key = `${host} ${pubkey}` + const session = this.sessions.get(key) + + if (session) return session + + const user = testUsersByPubkey.get(pubkey) + + if (!user) { + throw new Error( + `Cannot publish as ${pubkey}: zooid authenticates every write, so seeded events must be ` + + "signed by one of the test identities in e2e/harness/keys.ts", + ) + } + + const connection = connectToZooid(host) + const [, challenge] = await connection.wait(isRelayAuth) + const event = await user.signer.sign(makeRelayAuth(`wss://${host}/`, challenge)) + const {ok, detail} = await this.send(connection, [ClientMessageType.Auth, event], event.id) + + if (ok) { + this.sessions.set(key, connection) + + return connection + } + + throw new Error(`Failed to authenticate as ${user.name} on ${host}: ${detail}`) + } + + // Both halves of seeding — the auth that opens a connection and every event written over it — + // are answered with an OK naming the event that was sent. + private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => { + connection.send(message) + + const [, , ok, detail] = await connection.wait(reply => isRelayOk(reply) && reply[1] === id) + + return {ok, detail} + } + + private closeSessions = () => { + for (const connection of this.sessions.values()) { + connection.close() + } + + this.sessions.clear() + } +} diff --git a/e2e/harness/zooid/testRelay.ts b/e2e/harness/zooid/testRelay.ts new file mode 100644 index 00000000..741aaa58 --- /dev/null +++ b/e2e/harness/zooid/testRelay.ts @@ -0,0 +1,72 @@ +import { + MESSAGE, + RELAY_JOIN, + ROOM_ADD_MEMBER, + ROOM_CREATE, + ROOM_EDIT_META, + makeEvent, +} from "@welshman/util" +import type {SignedEvent, StampedEvent} from "@welshman/util" +import {users} from "../keys" +import type {TestUser} from "../keys" +import type {TestRelay} from "./types" + +export type TestRelayOptions = { + name: string + url: string + publish: (event: SignedEvent) => Promise +} + +// Seeding signs the events a real client would send and publishes them over a socket. Room +// administration is signed by `admin`, the only test identity the tomls grant can_manage. +// +// Every timestamp is the caller's: seeding runs off the scenario's clock, and reading the wall +// clock here would leave two fixtures that describe the same thing — a room's membership, say — +// stamped seconds apart, which is enough to decide which of them wins. +export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay => { + const event = async (user: TestUser, template: StampedEvent) => { + const signed = await user.signer.sign(template) + + await publish(signed) + + return signed + } + + return { + name, + url, + event, + room: async (h, meta, createdAt) => { + // A relay stamps the metadata it derives from these ops with the op's own created_at, so + // creation has to be strictly older than the edit or the edit is dropped as stale. + await event( + users.admin, + makeEvent(ROOM_CREATE, {tags: [["h", h]], created_at: createdAt - 1}), + ) + + const tags = [["h", h]] + + if (meta.name) tags.push(["name", meta.name]) + if (meta.about) tags.push(["about", meta.about]) + if (meta.picture) tags.push(["picture", meta.picture]) + if (meta.closed) tags.push(["closed"]) + if (meta.private) tags.push(["private"]) + + await event(users.admin, makeEvent(ROOM_EDIT_META, {tags, created_at: createdAt})) + }, + message: (user, h, content, createdAt) => + event(user, makeEvent(MESSAGE, {content, tags: [["h", h]], created_at: createdAt})), + member: async (user, h, createdAt) => { + await event(user, makeEvent(RELAY_JOIN, {created_at: createdAt})) + + if (h) { + const tags = [ + ["h", h], + ["p", user.pubkey], + ] + + await event(users.admin, makeEvent(ROOM_ADD_MEMBER, {tags, created_at: createdAt})) + } + }, + } +} diff --git a/e2e/harness/zooid/transport.ts b/e2e/harness/zooid/transport.ts new file mode 100644 index 00000000..ac369e1c --- /dev/null +++ b/e2e/harness/zooid/transport.ts @@ -0,0 +1,144 @@ +import {request as httpRequest} from "node:http" +import WebSocket from "ws" +import {parseJson} from "@welshman/lib" +import type {ClientMessage, RelayMessage} from "@welshman/net" +import type {RelayConnection} from "./types" + +/** + * How the harness reaches the zooid container, and the only place that knows the container has a + * loopback address at all. + * + * The client is given `wss://.test/` and never learns anything else. It has to be that + * rather than the container's own `ws://localhost:3334/`, because a relay selection drops a url + * that is local or insecure unless the caller opts in — see isLocalUrl and RelaySelection.getUrls + * in @welshman/util — which Flotilla never does, so the Router would resolve every outbox load, + * profile and relay hint to nothing. + * + * The two headers below are what makes the container answer to that name. zooid's dispatcher binds + * a config to a Host (cmd/relay/main.go), and khatru derives the url it checks nip-42 and nip-86 + * signatures against from that Host plus the forwarded proto (getBaseURL in khatru/relay.go), so + * both the relay the config describes and the url it expects to have been signed are the tenant's. + * They are exactly what a tls terminator adds in front of a real deployment. + */ +const port = 3334 + +const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"}) + +export type ZooidConnection = RelayConnection & { + // Resolves with the first message the relay sends that matches. Seeding waits for its auth + // challenge and for the OK answering each write this way. + wait(match: (message: RelayMessage) => boolean): Promise +} + +// One connection to the container, as one of its virtual relays. Node's own WebSocket cannot carry +// a Host header — fetch forbids it — so this is the one thing in the harness that `ws` is here for. +export const connectToZooid = (host: string): ZooidConnection => { + const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)}) + const listeners = new Set<(message: RelayMessage) => void>() + const pending: ClientMessage[] = [] + + const write = (message: ClientMessage) => socket.send(JSON.stringify(message)) + + socket.on("open", () => pending.splice(0).forEach(write)) + + socket.on("message", data => { + const message = parseJson(data.toString()) + + if (message) { + for (const listener of listeners) { + listener(message) + } + } + }) + + // The likeliest error here is a 404 from zooid's dispatcher, which means `tenants` in config.ts + // and the `host` in the tenant's toml have drifted apart. + socket.on("error", error => + console.error(`The zooid container refused a socket for ${host}: ${error.message}`), + ) + + return { + onMessage: listener => { + listeners.add(listener) + }, + // A client may speak before the container has finished its handshake — the page's socket is + // open as soon as playwright has a route for it, which is well before this one is. + send: message => { + if (socket.readyState === WebSocket.OPEN) { + write(message) + } else { + pending.push(message) + } + }, + close: () => socket.close(), + wait: match => + new Promise(resolve => { + const listener = (message: RelayMessage) => { + if (match(message)) { + listeners.delete(listener) + resolve(message) + } + } + + listeners.add(listener) + }), + } +} + +export type ZooidResponse = { + status: number + headers: Record + body: Buffer +} + +// One http request to the container, carrying the same two headers, so that the nip-11 document +// and the nip-86 management api the browser reads are the real relay's rather than a mock's. +export const requestZooid = ( + host: string, + method: string, + path: string, + requestHeaders: Record, + body?: Buffer, +): Promise => + new Promise((resolve, reject) => { + const request = httpRequest( + { + method, + path, + host: "127.0.0.1", + port, + headers: { + // A pseudo-header describes the request line rather than the request, and node:http + // rejects a header name with a colon in it. + ...Object.fromEntries( + Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")), + ), + ...headersFor(host), + }, + }, + response => { + const chunks: Buffer[] = [] + const responseHeaders: Record = {} + + for (const [name, value] of Object.entries(response.headers)) { + // Hop-by-hop headers describe the connection this answer arrived on rather than the one + // the browser is waiting on, which playwright frames itself. + if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) { + responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value + } + } + + response.on("data", chunk => chunks.push(chunk)) + response.on("end", () => + resolve({ + status: response.statusCode ?? 500, + headers: responseHeaders, + body: Buffer.concat(chunks), + }), + ) + }, + ) + + request.on("error", reject) + request.end(body) + }) diff --git a/e2e/harness/zooid/types.ts b/e2e/harness/zooid/types.ts new file mode 100644 index 00000000..021b688c --- /dev/null +++ b/e2e/harness/zooid/types.ts @@ -0,0 +1,34 @@ +import type {SignedEvent, StampedEvent} from "@welshman/util" +import type {ClientMessage, RelayMessage} from "@welshman/net" +import type {TestUser} from "../keys" + +export type RoomOptions = { + name?: string + about?: string + picture?: string + closed?: boolean + private?: boolean +} + +// A handle to one relay, plus the seeding affordances scenarios build on. Every seeding call +// publishes over a real socket rather than inserting into storage, so the relay stores exactly what +// it would have stored for a real client. Each one is stamped by its caller rather than off the +// wall clock, so a scenario's fixtures share one clock. +export type TestRelay = { + readonly name: string + readonly url: string + room(h: string, options: RoomOptions, createdAt: number): Promise + message(user: TestUser, h: string, content: string, createdAt: number): Promise + // Grants relay membership, and room membership too when `h` is given. + member(user: TestUser, h: string | undefined, createdAt: number): Promise + // Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs. + event(user: TestUser, event: StampedEvent): Promise +} + +// One client's connection to a relay. Every connection to a url reaches the same container, which +// is what lets one browser context observe another's writes over the wire. +export type RelayConnection = { + onMessage(listener: (message: RelayMessage) => void): void + send(message: ClientMessage): void + close(): void +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts deleted file mode 100644 index 38899da3..00000000 --- a/e2e/smoke.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {expect, test} from "@playwright/test" - -test("boots the SPA on the home page", async ({page}) => { - const response = await page.goto("/") - - expect(response?.ok()).toBeTruthy() - - // adapter-static serves an empty shell that hydrates client-side, so the presence of - // rendered text proves the Svelte app actually mounted (not just that a file was served). - // TODO: tighten this to assert concrete onboarding UI once the markup is settled. - await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000}) -}) diff --git a/e2e/specs/auth.spec.ts b/e2e/specs/auth.spec.ts new file mode 100644 index 00000000..52a42166 --- /dev/null +++ b/e2e/specs/auth.spec.ts @@ -0,0 +1,43 @@ +import {CLIENT_AUTH} from "@welshman/util" +import {ClientMessageType, RelayMessageType} from "@welshman/net" +import {expect, getTranscript, roomPath, test, users} from "../harness" + +test("authenticates over nip-42 before a members-only relay serves anything", async ({ + seed, + as, +}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("backroom", {name: "Backroom"}) + space.join(user.alice, "backroom") + space.message(user.alice, "backroom", "the secret handshake") + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "backroom")) + + await expect(page.getByText("the secret handshake")).toBeVisible() + + const transcript = getTranscript(page.context()).filter(entry => entry.url === url) + const challengedAt = transcript.findIndex( + ({direction, message}) => direction === "toClient" && message[0] === RelayMessageType.Auth, + ) + const authenticatedAt = transcript.findIndex( + ({direction, message}) => direction === "toRelay" && message[0] === ClientMessageType.Auth, + ) + const firstEventAt = transcript.findIndex( + ({direction, message}) => direction === "toClient" && message[0] === RelayMessageType.Event, + ) + + expect(challengedAt).toBeGreaterThanOrEqual(0) + expect(authenticatedAt).toBeGreaterThan(challengedAt) + expect(transcript[authenticatedAt].message[1]).toMatchObject({ + kind: CLIENT_AUTH, + pubkey: users.alice.pubkey, + }) + + // The whole point of intercepting the transport rather than swapping in an adapter: until the + // client proves who it is, the relay hands it nothing at all. + expect(firstEventAt).toBeGreaterThan(authenticatedAt) +}) diff --git a/e2e/specs/multiuser.spec.ts b/e2e/specs/multiuser.spec.ts new file mode 100644 index 00000000..3c02be17 --- /dev/null +++ b/e2e/specs/multiuser.spec.ts @@ -0,0 +1,27 @@ +import {expect, roomPath, test, users} from "../harness" + +test("delivers one user's message to another user's open page", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + + // Two browser contexts, two identities, one relay: bob's page is already listening when alice + // sends, so the message reaches him over the wire rather than out of a shared repository. + const alice = await as(users.alice, path) + const bob = await as(users.bob, path) + + await expect(bob.getByRole("link", {name: "General"})).toBeVisible() + + await alice.locator(".chat-editor [contenteditable=true]").pressSequentially("anyone there?") + await alice.locator(".chat-editor [contenteditable=true]").press("Enter") + + await expect(alice.getByText("anyone there?")).toBeVisible() + await expect(bob.getByText("anyone there?")).toBeVisible() +}) diff --git a/e2e/specs/routing.spec.ts b/e2e/specs/routing.spec.ts new file mode 100644 index 00000000..2c2dc576 --- /dev/null +++ b/e2e/specs/routing.spec.ts @@ -0,0 +1,57 @@ +import {spec} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import {RelayMessageType} from "@welshman/net" +import {expect, getTranscript, roomPath, test, users} from "../harness" + +test("keeps two spaces' contents on their own relays", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + // Same room id on both relays, so anything that keys rooms by id alone conflates them. + space.room("lounge", {name: "Space Lounge"}) + other.room("lounge", {name: "Other Lounge"}) + space.join(user.alice, "lounge") + other.join(user.alice, "lounge") + space.message(user.alice, "lounge", "only in space") + other.message(user.alice, "lounge", "only in other") + }) + + const space = scenario.space("space") + const other = scenario.space("other") + const page = await as(users.alice, roomPath(space.url, "lounge")) + + const deliveredByOther = (match: (event: TrustedEvent) => boolean) => + getTranscript(page.context()).some( + ({url, direction, message}) => + url === other.url && + direction === "toClient" && + message[0] === RelayMessageType.Event && + match(message[2]), + ) + + await expect(page.getByText("only in space")).toBeVisible() + await expect(page.getByRole("link", {name: "Space Lounge"})).toBeVisible() + + // Alice belongs to both spaces, so the client is talking to the other relay at the same time — + // its room and its messages just don't belong in this one. It syncs independently, so wait until + // both have actually reached this page: `toHaveCount(0)` is equally satisfied by an element that + // has not loaded yet, which would pass against a client that does conflate them. + await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true) + await expect + .poll(() => deliveredByOther(event => event.tags.some(spec(["name", "Other Lounge"])))) + .toBe(true) + + await expect(page.getByText("only in other")).toHaveCount(0) + await expect(page.getByRole("link", {name: "Other Lounge"})).toHaveCount(0) + + const strays = getTranscript(page.context()).filter( + ({url, direction, message}) => + direction === "toClient" && + message[0] === RelayMessageType.Event && + message[2].content === "only in space" && + url !== space.url, + ) + + expect(strays).toEqual([]) +}) diff --git a/e2e/specs/space.spec.ts b/e2e/specs/space.spec.ts new file mode 100644 index 00000000..51882fd4 --- /dev/null +++ b/e2e/specs/space.spec.ts @@ -0,0 +1,26 @@ +import {HOUR, MINUTE} from "@welshman/lib" +import {expect, roomPath, test, users} from "../harness" + +test("renders a seeded space, its rooms and its messages", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.room("random", {name: "Random"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.message(user.bob, "general", "morning all", at(2, HOUR)) + space.message(user.alice, "general", "morning!", at(90, MINUTE)) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "general")) + + // The room the user belongs to and the one they don't are both advertised by the relay, so + // both appear in the space menu. + await expect(page.getByRole("link", {name: "General"})).toBeVisible() + await expect(page.getByRole("link", {name: "Random"})).toBeVisible() + + await expect(page.getByText("morning all")).toBeVisible() + await expect(page.getByText("morning!")).toBeVisible() +}) diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts deleted file mode 100644 index 778b2664..00000000 --- a/e2e/support/fixtures.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type {SignedEvent} from "@welshman/util" -import type {RelayMockConfig} from "../../src/lib/test/relayMocks" - -import relay1Events from "./fixtures/relay1.json" - -// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in -// EVENTS_BY_RELAY below. To add a relay: drop a `.json` file in ./fixtures/, import it, add a -// url here, and wire it into EVENTS_BY_RELAY. -export const FIXTURE_RELAYS = { - relay1: "wss://relay1.test/", -} as const - -// The events each fake relay serves. The json files hold static, pre-signed events: schnorr -// signatures are non-deterministic, so events are signed once and committed verbatim (they pass -// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer: -// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at})) -const EVENTS_BY_RELAY: Record = { - [FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[], -} - -// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed). -// Any relay not included returns nothing, keeping tests offline. -export const relayFixtures = (...urls: string[]): RelayMockConfig => { - const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY) - - return { - relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])), - } -} diff --git a/e2e/support/fixtures/relay1.json b/e2e/support/fixtures/relay1.json deleted file mode 100644 index 671c88f5..00000000 --- a/e2e/support/fixtures/relay1.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "kind": 0, - "content": "{\"name\":\"Alice\"}", - "tags": [], - "created_at": 1700000000, - "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - "id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90", - "sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc" - }, - { - "kind": 1, - "content": "hello from the fixture relay", - "tags": [], - "created_at": 1700000000, - "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - "id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9", - "sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970" - }, - { - "kind": 1, - "content": "reply from bob", - "tags": [], - "created_at": 1700000001, - "pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", - "id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be", - "sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1" - } -] diff --git a/e2e/support/relayMocks.ts b/e2e/support/relayMocks.ts deleted file mode 100644 index 46eaf581..00000000 --- a/e2e/support/relayMocks.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type {Page} from "@playwright/test" -import type {RelayMockConfig} from "../../src/lib/test/relayMocks" - -// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts. -const RELAY_MOCKS_KEY = "__RELAY_MOCKS__" - -// Hard safety net: intercept every real websocket so a test can never reach the network, even if -// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter -// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process -// mock and simply receives nothing. -export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {}) - -// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script -// on every navigation, so this must be called before page.goto(). -export const injectRelayConfig = (page: Page, config: RelayMockConfig) => - page.addInitScript( - ([key, value]) => { - Object.assign(window, {[key]: value}) - }, - [RELAY_MOCKS_KEY, config] as const, - ) - -// Full network isolation plus optional fixtures, in one call. With no config, every relay returns -// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2). -export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => { - await blockWebsockets(page) - await injectRelayConfig(page, config) -} - -export type {RelayMockConfig} diff --git a/link_deps b/link_deps index 6a929c18..b6b4d64b 100755 --- a/link_deps +++ b/link_deps @@ -10,27 +10,44 @@ if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) { process.exit(1) } -const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')) +// One entry per directory under ../welshman/packages. +const packages = [ + 'app', + 'content', + 'domain', + 'editor', + 'feeds', + 'lib', + 'net', + 'signer', + 'store', + 'util', +] -pkg.pnpm = pkg.pnpm || {} -pkg.pnpm.overrides = pkg.pnpm.overrides || {} -pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app" -pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content" -pkg.pnpm.overrides["@welshman/editor"] = "link:../welshman/packages/editor" -pkg.pnpm.overrides["@welshman/feeds"] = "link:../welshman/packages/feeds" -pkg.pnpm.overrides["@welshman/lib"] = "link:../welshman/packages/lib" -pkg.pnpm.overrides["@welshman/net"] = "link:../welshman/packages/net" -pkg.pnpm.overrides["@welshman/router"] = "link:../welshman/packages/router" -pkg.pnpm.overrides["@welshman/signer"] = "link:../welshman/packages/signer" -pkg.pnpm.overrides["@welshman/store"] = "link:../welshman/packages/store" -pkg.pnpm.overrides["@welshman/util"] = "link:../welshman/packages/util" -// pkg.pnpm.overrides["nostr-editor"] = "link:../nostr-editor" -// pkg.pnpm.overrides["@pomade/core"] = "link:../pomade/packages/core" -// pkg.pnpm.overrides["nostr-signer-capacitor-plugin"] = "link:../nostr-signer-capacitor-plugin" +// pnpm 11 no longer reads the `pnpm` field in package.json, so overrides live in the workspace +// file. They are written for the install and reverted straight after: a committed link: override +// points every clone and every ci run at a checkout that only exists on one machine. +const file = 'pnpm-workspace.yaml' +const workspace = fs.readFileSync(file, 'utf8') -fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n') +if (!/^overrides:$/m.test(workspace)) { + console.error(`Error: ${file} has no \`overrides:\` block to link welshman into.`) + process.exit(1) +} -execSync('pnpm i', { stdio: 'inherit' }) +const links = packages + .map(name => ` '@welshman/${name}': link:../welshman/packages/${name}`) + .join('\n') -execSync('git checkout -f pnpm-lock.yaml', { stdio: 'inherit' }) -execSync('git checkout -f package.json', { stdio: 'inherit' }) +// Other packages worth linking now and then: +// '@pomade/core': link:../pomade/packages/core +// nostr-editor: link:../nostr-editor +// nostr-signer-capacitor-plugin: link:../nostr-signer-capacitor-plugin + +fs.writeFileSync(file, workspace.replace(/^overrides:$/m, `overrides:\n${links}`)) + +try { + execSync('pnpm i', { stdio: 'inherit' }) +} finally { + execSync(`git checkout -f ${file} pnpm-lock.yaml`, { stdio: 'inherit' }) +} diff --git a/package.json b/package.json index 8db6574a..4ee51538 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check src && eslint src", + "lint": "prettier --check src e2e playwright.config.ts && eslint src e2e", "test": "playwright test", "test:ui": "playwright test --ui", "format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write", @@ -26,6 +26,7 @@ "@tailwindcss/postcss": "^4.2.2", "@types/eslint": "^9.6.1", "@types/node": "^25.9.1", + "@types/ws": "^8.18.1", "autoprefixer": "^10.4.23", "classnames": "^2.5.1", "eslint": "^9.39.2", @@ -41,7 +42,8 @@ "tailwindcss": "^4.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.53.1", - "vite": "^6.4.2" + "vite": "^6.4.2", + "ws": "^8.18.3" }, "type": "module", "dependencies": { @@ -101,5 +103,5 @@ "throttle-debounce": "^5.0.2", "tippy.js": "^6.3.7" }, - "packageManager": "pnpm@11.5.1" + "packageManager": "pnpm@11.20.0" } diff --git a/playwright.config.ts b/playwright.config.ts index 7b3bde87..5a83b315 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,27 +1,47 @@ import {defineConfig, devices} from "@playwright/test" -// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts). -// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers). +// One engine per run rather than a project per engine: these specs exercise protocol behaviour — +// sockets, auth, sync — so a third copy of each buys much less than it costs. Run the whole suite +// under another engine with `E2E_BROWSER=webkit pnpm test`. +const deviceNames: Record = { + chromium: "Desktop Chrome", + firefox: "Desktop Firefox", + webkit: "Desktop Safari", +} + +const device = devices[deviceNames[process.env.E2E_BROWSER ?? "chromium"]] + export default defineConfig({ - testDir: "e2e", - fullyParallel: true, + testDir: "e2e/specs", forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - reporter: "html", + // Fixture setup is charged to the test, and the harness fixture does a lot of it: the container + // is recreated and waits for the relay to answer, seeding authenticates a socket per + // identity, and the first test of a run also pays vite's on-demand compile of the whole + // sveltekit graph. Playwright's 30s default kills that partway through as a flake. + timeout: 120_000, + // An assertion carries its own deadline, which the test's does not bound, and the first one in a + // spec runs the moment the shell mounts: it waits out the socket opening, the nip-42 exchange, + // the three negentropy pulls a space is synced with and a render. Playwright's 5s default is a + // coin flip on a loaded machine. + expect: {timeout: 30_000}, + // One container on a fixed port, so the suite can't be sharded. + workers: 1, + // list alongside html: the html report is where a failure is investigated, but a run that skips + // has to say so on the terminal, where the person who started it is looking. + reporter: [["list"], ["html"]], use: { baseURL: "http://localhost:1847", trace: "on-first-retry", }, - // Boots the SvelteKit dev server before the suite and reuses one if already running locally. + // Boots the SvelteKit dev server before the suite and reuses one if already running locally. The + // app resolves its VITE_ values against a key the harness injects per browser context, so any + // dev server will do. webServer: { command: "pnpm dev", url: "http://localhost:1847", reuseExistingServer: !process.env.CI, timeout: 120_000, }, - projects: [ - {name: "chromium", use: {...devices["Desktop Chrome"]}}, - {name: "firefox", use: {...devices["Desktop Firefox"]}}, - {name: "webkit", use: {...devices["Desktop Safari"]}}, - ], + projects: [{name: "zooid", use: {...device}}], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0758c763d20a462743e6171b70b22f60a86c931..d421bbaa160853465d1958e374588ded033ded2e 100644 GIT binary patch delta 35371 zcmeHwd3amLb?*n>OHvC-i4wI_5-C{{DVW5?4#<{8>>IJ~up|2dxY+j{P;5z!>t=PF z;I-rD#IfDjjh)0>_0@2e#&Ofv#C6ix`Cgkeabn5w>(?f){<1vBPV!#z=3a0Cp?g6B zq?)v`{EvAt%b7E0&hnd?Gq1fdcJZM-_rxz%`|fA$n#vb5nRKp@p9!QB>D-)nmN$#l zf7(BGX&U`j|9NayI*ZL@a)e-dYOYKq@{uH-;-rYe-109iKP005mLFQH6bsQr%TH5t zskA>qa010#iOA1!VK?H|;tLH8E_4ol{3deZ^z>9YKPQ>xac6nct%dW^aEi#S(mFWx zCUWfeYCKM+@$|;4R#s8`(zWtvgWA0G#FiIWJ847)JpCfd1wYx0{_xU!hq1oSMd~kX zmyFR0-qVMyb<613Zd$RN22(enSeD{H#` z*T2|P-#kW1552V4FT)ZSnkCjDU+YpX(^IK*zL1*%KYtTBJk+Wa>zBu;6+}h-+y9Nqbbst{beszKhc1iZu&StJz$sQ>kSNIX-yl?C$dev=$7! zyt9A2{?_=fI$FTj>&RhGhY~}m4@~bzx7Ob{{PLC)bA@V#$aBj1`jvrweOyU>?(i5Y znT7xJ%*2m&(wNHn{gVmW?*m7Ybl)#!j{F+Ao~EH$3_4nLhMFAaez12M8L1x_7^**T z90!?SA}BI3QLbC}?>R(LH_NAN7-`3r40Y3cyY$=R3g4a_9!(W;1i{JUsc0cuBW8-Z z#GJU!dhbb3EE>-H4D7s74PC^jT%1?gifO4!Y?8(*>|i9R)uyafrQH?G_{y$g0hbrW z?vlxyH7e)rVRbkp=VK{>kms=QOEIOaq*h49{G{yk=}V_Ba-SR{Dg`1%dJfR(4H7f{ zL^==$NsKk)G=xh*tJW@vd2(VY?&Oz!W{q6u^Li7~l0q&nDpf_B+9k-Rc{y(`l#F_f zWxhk=ccc;~l}bYhY)(R!%yXlHP_SGsSu@g*X*_A4M_eogwbr~QAJfgJOdLI~auu>6k1u6W zDQX4{?$#0pjw~(6drLZdFeR`QBE>tSSsVa|yV-;MsSt?x(!4^Sk~>pgtw4bnq*<-d)3A-UFm(IYzA)n4QiAt-A z9CI#SFr}@YEVsgwno?Sh#;mJZeMw1_a99(3wSgy2RYG!$Od<`K(po`Mt#T&i^FoeU zty7tFv8Y$W&$=^VQ7Ns<*79Z38YXS$GMB>m3W_)ZsY}h($}2Ip%pw$cG_J5Bsg(Lv z8gD>Y7Mjx`uR?7N8^sB+Mb6b@m`qSI#|bQLiEH(#Y|3n|S`uo!V&;fVq6FVlRtHiB zl1;0KQHx|p@LX`lpCfQevS%H3n@eLzD7*%dOk=|$l8P-J$J}PM#-r8gQo(ePuQ7z( zPJvWrDe5%Js3$1N*P@=hJ&}q!^X_CetV}9B0aMCevjkjXnN{L7fv5efqr5I=E)vZb z(z$AL+Sn0$HLJ3v)h37A67^LC9JQkA<6wk3E0>0f#-KwkkSRD>X|!DA+e~hmwUEf` z!$KY(^M^RuWYsM4`zkhP0E_cywuhAndB@f@wic!^6G7|jLeYKrSXgtQ}Jv96;EyP^5dD3Q_SH;#TKVGny)6ZM9i+QBnx_r##_eJ zW~0?BO;=JXRVn1t8s#z(7b|298A`HOVN{FGEVSdBd6J2^)o0;IQo)K;XbwC5Tu(^Ek0&kW2)|Oz6muFoFPi6< zFr&v#@@Q)dkAM?nJ+i&2sq@8fn8+8PF>4YKv{p+BT^Nf5WG*5i2q|<4 znytzBoqkE)lnAFZxun*U!ZVsmEndncw5d{x@c1h8aZXIEs3heUgWi-%3M~GLptky| z8V27#j2>$2<+9>~TZj@g82LFqM3St}O?e7Ab3h=IIUNyIO&Y;7mV!MfE{CAXw;D>0 zLQ&}Fi@DBq+%B_4%-(i;cR~~z*p(~Y@XDk3Jc1KLRK8GCxZfK zfTvP%xGASAS*g}C9#PooQDQDhOqbJOIkA^8Yg{_N*A}l3>9nFy2A`I)8VEU_dww|O z;(9D)8^$(o&11(ozny;MRx`CvrCF{JhoU^v6OfnK}|n zs*=^TSM8Qnizc2i8&3%^qfbzb$K++8I>q`+7Prp1^Q22OFLG6r21m_nKJ6HyX) zaPGW9Yse)W39mwKbR?_>buk!Ba}_0PKqchquw*%8*5lP8;Z%r?`l827XdMBaQzYRh zYC$aElr>&E#hMz}1RY6;qCxNR`43Hb91)jVq*S;f&{c41s%#vusjY&z$0}BF9aXW6 zU(Kk^1f~n(YVw`E<1mJt^Fu;5#!0nN494 z*>lN?wpik6Lo&juFo<%Aq(UMPYTN|j(w7r*y*8X@+nwPsPZ$npWie7*cVyB@JO~lS z1&0fp<%5s>0vX#Igx{kn=z}eTMvGX>t}V;oVDP-R_0P*~e~#_n+R{S(wq5&H{Qdo1 zdt3ho7Y?xwPc&VuC3XbTDd>l`K&efZq*Z!_Tj?-I@<|tTA**^9503@p-U4AL%A%4S z-{JC`BoUrmAMg@MYoXwE;8!lB z<~vMclfm!s$ZKj*tPlxA(|%P<>67{A?G6!FZk$&trIhult5~D0eC>|SZEUR@-`aoG zh=^bmIvH&vauhLpk)5p>bP*`nlDNH;Ocu<9PixC0`D|laTI7nMW8sd}{6suns~8=H zTtqHOr*lEYd@<}+ml9YN7L3ZP+^C!_R!F@eMcwkg>(006<)&lL<<5#1A|D!mnv6Ks zTCN$HbS{0q8OSWU>75HNlwI83xpmZD3(NLyhW zZPe1@CA5{NEl%3+<#JS5#)csp-Apdq+vN)Fx&lh+@fPKU1UL32GiUG`03$rVUVv>UkrD_qLwubR^Ege;-aZ*v%|LzytS{LLhiVlwLMC;h7 zsBuZ9t-wo~>I_#`3u&b*6}IQtI+I7wReQAaI!`TZu2^lj$ZX_R_##UwDp&BsQd^qu z)mpVW7z^;(i9{mp&SHG7D2_WlvY<#=G^^lVRnQ#)Pye#-AQ-uYwRP`_WdwW$&fV-i zEA&gae7z~0kOv}a4~LNQFg@QCNZ`r}o+nH>ldP&V7hHKabj(a#L0lj*xeaW~d@AEi znz4W$7wHM5IBb@tBzBv;Ds*KnVmh`#zMN#{OZyg8lQ@||$+gHd1p|j$j<`K8vC(P? ziqgiYN##`}y&RL@9!m?{0$Y-oa#Z+ELaWr|1vW#@s*w~q9C?_})77F`i!)#|vb|-o z!>p6}NM{jD459<|+0B~gQxE;Ng?@hT@O z33%8U+$tu-*<@TUmX>5@b<|`}RpM!mMr=}*U06y2eWa>A>L}s-jKQ8}dzAv44%Zhl zg^-mpZddWWT{FPt&Juu4H=@^p-|S}{Zb;3np;xv=^XX(dmx)9JGtf5^f`=YtO*@lC zT~O?+rKHueGGFzC9XdSZ$W?iH7e7_C`v|9!5P6B9E6CNBibWzLl;w4atby$zLhg*$ zQPpdNgb?Gac@nF?7olnZSuoqAQH^+!Gq?$X^*Ie zS5*>g!WoQ~39n8RP&t$wkKCSB2hs{>q8LhXcv6erCY9zSyb8CfOzAQ?TL8zrC7CxP zbF+yWrYTzEF1bQf(Fm<^rPv&Uz#ABS7~R$Qx0$}54Uf+F(}@C?I};>GW0-?oXG^SU z58tXvhr&_0*&02uZHxPY9-paTuja87jHg3Y4PlSUGzxj%Sk`KsN;^kyO?WwZ zN6MX-a%DVI1hY#$o{&spuS&xrYo=&DeY)}T4SmlHZB50ap=vBYCxDLN^MidSoE8bL zvIw0VC6}X1IJ`VF;mxaZ!E!|E*A(Yfe9RQ^6b!0ZIH(QakrH7|TgygX)n|gpp;eoA zt817=Y%mmq(Y!xbQ-p0;Ua4)|uI>Bw(AJp(o{!HYpn1!K%xE9m(-~ygWy~V%F+dA-<3TBP5T1UB^KM6$~b&!yWeASCDqX~W^<-h zhN^l#6t{B!(0`jF&P)oux zV6@6Q?usg1aT`w_lV#%$WkK!1B2q!sRZ{U}(TKv6P-9lHP3N)eEjGJNsKYEWuESWA zg+*$uI+M&gQi6h|qR0GIOksdPhB1*+&udf+U=9p)8u$LTZxBK75)Qufkv_JA7c2;} zdWaW$b!A058cCbk+K_AB#4bjI;;6r1NcjYr*t}Zc%|c^X))XyR(V>}lI8!B4T$YjY zvJt*F8Mc)K<#eo)iV3T7OMT+ZVelC?GBO~T#b&V+jeGl$hi@Ecp3nf?B5TSi6azNetG6C#u5z zWr;*)^LT>JT%uI)8>}8SZXhlHA@J%MWU&6;Uyp;AMEwJ7QprK2WwU!3Y{|t8E;P#I;jAszlPPz>f zkg+0%>X={)XxNZX&CqUk65mixQH=Pl4dH=|51C@> z`(7XN0xi{~9D&PDWHY$nLAEnhhXN4>PyR772qx0V;C}Lu7WC<8CcHVbYi@6}p5hOJ ze|rzIb3=LZ(~HPKl2XR;P)y6XkS*YgcOrZSDYy_plwjM#aKrE+Xm}WT3{3|qpsP_`e?znj+<)r^vlephG?SDpu1O3$ zcn31N8hzv{WcMzU4JebFS*z3^4j>b&33jOd>J8h#PyEP_dzwmyVP@_`Zdip4w)#D1 z2Emg-L`*x1RrSfmcOy5|Z#z8*n>hnMMj-TtqMK$0>tCKb3QVCMWrmp*@>NwXTx>4z z!2QTEPzxjbH+Uvi@BaCRkV!D{L8uVk6+w=6cM<`T{@*s3;MGLO0KOYVd_9Rx9lvR3 zeO3cCc^ugUzV`{J`R|D#`+I5&P$Ia{82wsaoA}LYCct;&y-F~tcRo5h2rl1+@b=Ic zCGrpnNvgJfgLXIgdZO2Us3zug6I2r$JmYe3+gW5UNIZy)cPD+Fe3hr~Kpgcs-2k}l zL1YM2&i1&lnDqUUneDXjmxk4)qejvW{_lfuMWPwxc+cXgCEFMVPi592;@W!1xCi{U z-lJM%Vqa5iVFZ!wcFv)3;et6Fxe1&pB0E4bhp>AR$7@d6y?JC}Ln(s}lKRi=aP+qo zkaHWmP=KX~%ygDBW;wqB3t0iB?LF-eKPY&VCXZ2bH_7J-r`y?gC$^u1oJf=sF5wwQDe|KY17# z1;4Bz)4eFjn;%B@fVML;7F5WgFObaUDF@f-@HoQSTIhH!dK8byR=iQ_y?-B#g5+ zbONA9q3BMluP}w9fFkRQe}o+QJtXV9@A-Wo>pYY2Q%ef+Z!0kU@y0uf>zmeF?&?+6 z*KE^(`BCU=5`VHTRk}HJ1nhtBx|a1Dk3w_McyG^Sd=Sxr(~rS6Zh7AZ&*HLn#BPW9 z>5|>*ZoyKKcIy4eaV9xjEuaA(cno?uyFSq4=sK7kh?tFl?H**j7fTGm1FT1V`avY$ zle+3Klx4hyiOuA4uiumyU9TT^Q*t-B@9rM`A|~RRt9IR?(P;gq8*kfeHIx5Z`aqwvLAdF834}*HeQ3QZP|fO{OP(7jnjX|0GzJ{Y}0~g zA4i7k=gWiOYk_qMjOhtvQ~mbJ2=Has+s^L#J=H-lcWh=Nf)_pyA0YaY-`tNVdtl73 z6i30kKY>U)@}m`~Kem>Vqk!|kDm;vaI7n+1f`8M#?)e6n&_JM<4Bi4}blZ&YI zRM(7~Y=Xj<9%SC^COaTYO?P0}abS1|Inj0R$HBc1A=6z~>s~&NfD?a?%yt;SB!wAu zP01vHAR*&10cvXQu#bQ}5Si=Ld3^!CW7Hc|OCNWRfdA2IweHg)* zh@%Qz_il*A{_Vq^hX7cQAV<3Eof0gFIt^2btcSXHKhl}^;MK&p!<3REk zs|*c-JNvqdL7+x@DY1Q0+N2el5ONHG^e0E}TN zCD5Bg2f(h>YRrEWndvx(H$H~U_P{n6_x7SLVPLu&{OGY&i0*Okn_^q1Xr5x*P04NG z#a;~TpGA&>CqIKQ+ElVY{ax7+(EnLx&cQ;4jx!B|6-G@(u?|Ke zz33nqKn#JI$C)Xk8i#ShC^)xTDHskg8ps&h2J4DH+iW^iODa6gUw6KxZ%2Sc^wHtAvTAvh_cDwl! za@S#ov>5P+GY=k1J6At-akBBNOUML^KK}^LL9zP82ewWmqOiCSrkRk?^B=tY19%Sg z-!CJ>Od>`V*meck!c^e-33Lo>n?MJ_=l%*gNKX%yDCCX5LXI*t(inXPF?Bdd_UdK0 z)A9Oukuma69uH=x>o}ZF)KbG4c@4tMuQZU&4CIhpVbn@no<&YF)!Y4fc#`v(VRR=r z`+4Sl;`HYsKK$IX$U}_61G*b9HJ&eh4%x&+SEup(_;U!GsU94Um*KYt9L4Evv?G8J z$YF4{uYUmi!wbk5ZIM@BKvJ|nP-kJWB8q~<-!q#I{nw!@aaRP9faksr&xSs89vuOF zFETUqDEP{Y$UcUVfQuK=@y6RPA_Mfpp-6%gG6G(D4cX4HbT98j857n`z79_nU49i& z)_?VnTbW1w_3t4=OeB8idq|ME=G<{~6S(yi#^nX@ss4lDfmaZmxeeI+=6&$~1B0Ge z8UvWULY_W`cPik`NSeaU)5j#c>gjI}fG>ZaSt{`>%&TnjS!UdU^}pbW(up51Z2m4y z*Q))KtD=YLTyi;SX%*?D=ce~8=mF!&!$D+$Lq z(t(YP1_KU}>DC5V9G{ZZEt^2*he(Hcq?Y+BuOmk~FboRkYv94xk$ueKu-FU}!425P z+3mkqSyJE0BKbSbB1xva`*Pc*;THM0hPi(i@?$Qhhoo-wLEFRQ&w}5)iHx;pirjy= z<@PmQdf`9N+>rmWYnyZx&a5HfVWadGVnn3$s(N}Vmrf*#8F;l;gw4WC(N|{-T>Z+~ z(rIP}J=?&3jZ6=B$e9RJ4};idlsThCM65b1K#__z@R1fahiy zO&HbC50tC}4dX02#_D>2chM8j`&{e?|GE=pjNYGzETIjr&SU6z=t-ew0kUcv31CV{ z4+*1jkl?LiNHb@NX@A1B+Os_LH(hfH18_Z@VZ5C8GzYpO9R+1S_}UA|eg-#$nnNhT zL%?NVjQ}{2ryEd~pIyb*>iHFjuSn2TM(n~1F?Vrq4^SLCY_T*FxEop)5=3pR8?Ve4$H$XzC&a=l-RcNrFw z$Zu9Gi5X2w%BfW!7@;gPd>~|3i*T(;gf?30CK#XZX?^129USL(isMkPfwU(y?N)MD zCR6KjLA?Xybjh$~4Ycsc$M*9pfSe`uu~KohsXXbbb+s<_|Db0*MmGT08VMEi z#B2Jh0;g+&PEYqS}%PUX`#{Kt30xIOM0@vxn}UnB27oXTAxK z0KN1#$euYWT3wpA_SCzARh2&FV2~MfyIr{(EC~5`bpDDL=+>nem)H3Qzu5MU&cBAQ z5nf9rN4|DQ>HLiW?}7T8$Tm6!)Sf9@us0El{@% z5kqa+S_#VTw}y|lprnhiZd!27QYK6h4z& z05-{2=2rNXFe~o{80N8+*|MP-g|G>=eK5I9#PYmc4P{S~3VMOq<#JbH){^B?%R{33 zHnoIalNn?odP*)*))aXBlYIvutb6l6qVs#Fn*Ft<09~NF4`xAH^aKu)S>rQR%JC4v z*2Ha&Mq38(wLt^j*m&i_ORnoTT<^qnztgym+Ml4wlijU@xGq6>C$w8%XtyzDe!7r@ z8Gp&W8F)gkO`)s|5dnTP+~t1%^TfQq+el6o3 z>J9Py4)ow3lu&%kGfwKe_zc6}6c#F%iqp zG@pYYwY{_jx;hA+`69ZbvH3Bfddjox>VcGH@ioy|!Iev(J}d4ToL z@Z$D&Oz6HtLBd}Q!#IcjQUF*{kH1{6p$MYBU9?@z_4QxJd?)0aRn*$K+Fsf{^XV;H z>f4U&2FV&qyDeYO9Ekz#*UvIAJbIe88{;{Y{yhGD=g^xNx6MC8ULJY#96Er| zHrkYZ@;(dMFJmhs=OKCUq{=?<{_`l~t?!HH(LLa=&!h7U=K>m=FQ5ll2N<|S`gq_Y zf5+P0ICBvlK5}q{Pczr<(y-*{fi2`CG0oSS0emNBANc$);bEM24fbyacYG54*wEsu zF{GAgj696qfb6*HS(jPx@?&uMe`f&Q+faTA9q-@X@&?N+AD$Kh7qor*>x27FH17K> zdJ!FMc{T;Y3-FprW7E^R_EJ&Q*$+546ulEnvFT7jSG4iFTHi_l?DC)^8w9)oRJ_?(D_BZW56A*~O@ zdzMNpz4X#VUY1Z4@aUs`!$5ZkJqkYYP1JCR6!VFRHV}q<=#hM^rHLN(LzU5dv<1LN zTdIuvFjN0H9I+SS)j9kf)z!K~JaWnrD;fbVTEUvHs8iZGWqs1Bx8zXMAYkkpuk? zGpS+Sc>gyy)6qg1O~5d0bT09{*g{u_&h?|4%}dRe8mYaSW)$r3@Js_7I>J7Ut<~3UUlQqkuJq z`gY)3Invo8Z`0F`Q7m!&2-3+M=69(-^D!DT978L)@X*GT7$75u^J63Yj2$>hPML?s zMRGRZuOqKxOkxf`G@uy5r#I37Hq6=Bfo&~0_z1u<2P9>4{%lG>N5+*C_feq5OlPB8 zI~x0S1>*SC$!mPszaR@lDsII5(ENrtYPVBo-KVd64b*&O=x)CQLJZB;`1EJ_Js&V8X7b;Zckw|YC zzkq~8?RY)_YAcBgEc-8shUT|OJT#XP4VdSfog@ebl@fQDT1pPF7y4;n-F@6@T0#se z5uamZBs7&F@QVAWqk~fyQAaJDDYv`3WC;rEUW&X;_elt3%p=aCef(hpBgrsjDcNt8 zOr>DKoxlx zT;`!dM;YndibOz?p`1!IG*yHZe;5?j6AjZp=?RCbNgp-YS`v->BVffs;@x)N6Ka4w zrrzI4#)+no5h3gx=?Nu=)hdck8Yg;M2PN0gEj@3dk>{;Mu?wro(-5|b47JB=Ehdz# zCKF_m!ygjt%PFC-qN1&ITT^tbK2`7FOam1ch!;DWrUANE#T+a~Ts)lEhTiP5j>M>C zSxXYw7a5_TU$0{D6&30ZjY~0{o7R&cE(Xpm!7x^gV1VXz7$XfoVpKXWv$KQ}OYve> zAl*_ist^L|50Yn~;O}UdNf(i=c7q*T^I8-TPowN*iwFHrgomV!7%589r9rPPn0`|j z86>*cAd{@Tgu!_6Jr!tsbedMNY0F=^3^bHtG`_xx6t~s5)HsEw)%Br8RTk?93< zw_tcE5?T(V9JHBi;?KmL=_Y94zgtzNw)tkZjo-YTBtYi+=b!bGD#K_1-ObvpH9c=-h+=C zmaYx916tU716#oAkL}&BCLSObeoZ?SR4N;G^+e643S?H28}2ALl38V*0VaHEr`Wkq zNw&%cxJ-n`&syhqJR~(^gdY7|C3_nWiDgR(55qMqwP^=QWSb;lwT0eoB2KInO<$01 zVun8pg@=hN+cO(eWrdxrQeY}|QPXbyw51AOu7#!}DuFGLUHA)@*uDQ&9kj`EByoY} zUr^)8M^$v{#)VC|Z|fqGdkZVc?%j4AlFY|$5jSW$-u7VI3Dj9h3R@Z{L*;Ga*}Be1 zP_-wC$;x5TXu;PVrB?GCZ~KU_aQ*XJG`e!PWv}d4tbpe4+6c<@3tV8*_vll525GWgCXs#`V9MX5bRgB#D<(Ak%~`lu=^ZIl9$+2 z!(r-q87Kro)?j_uli~^u=Vi1IFkQemG`uqfq@cfj0nI8sVSn?7f*+AiSxW6LT3#w` z%(nD^Ll+fdrDeH6>?ONB>`^f?0Fs2uPuN!Cq$j)V!(L&*4!Vry`ut}M5=jtT4wav( zFJt&gdsyy+!0U=~6&7|sC~dMVv8MaLJ?mlWRV=Y07glYQ`KnBnECXTvb%7Pv6wZvS zAaJ>^z+%A-upNrE7LH%X_z=NxrH7Oo7Et%#;<^qp3BIW!>GG6@Y0$xqn&!wk= zCTvyG)0rlWDM=qJS0?ER%Z)o6Y*r+|>JcwfD0ReQL$OxL2bla82P$PiMgmOl9x@5$ zbCmpq39N_5Z!7Sawibh8o*GrY8+S*sS@Rrsh`OsVU=kW^76HsZ*}F=@Y2f!?EwoCm zdD_^et-ZftT1xKFFYuDj#@{8Wu;(tuF#be9%I^q)Pa)89lDfg(-_Z+|CuulD-Xlw` zIfx}deD{?;R>uE9eqi~F@mpg7vf6#@Y=jeU<9o`|`{YgOb4+;Z0g2({kG-vPX)r6O za?|GJ;E1cYsgJ2IWX4h-$cUpJuzxNMtxl(XtsjP(>YX%vXCqDH;O1%)47E|z8T35G z#6(hw(;P>&@P`w1m2u(-)x$G{c7s278UlSw@C)$(p$51?=v8)OsRr(@7OR34NW5Ld za6x$zOg&Bbx+2OW;q2yVcM)g2$ek?IIn&#oa1mc_Sj$H)=5p!qx+1k zX)k5>4@)#9m=a-o44udt8g%e|B@Kkk7#aZu`}ttl6+<&cz(9Da8+BJ;{NXLt}tU2KALiLlJT#}Lsj$~EccUTC|JiyA0h zKqb$%2dU%Y?Lajgeymv?ji9ksz~SuJMIYJPaS1dMP}MLfPoSsZz+*HR^!dCq^h}^# z2>Qo*_=&KmH=U#V{}^#5ZN@CCePDozBWZ519qg7=rJ66{Be^)Yf1INaGFVYt9w+?&CdRUk2?gJV>X%xGN(e27Z(2IAygQ zU~m8tG!D8=OcT#SKnGij;t{PT36i?HEU`mK{VXBuOz za0yamsS4A6Cx0Pz9z8|@hfqF{wvbMTuNKm997MT8;UYRwxV(sN;G}63;55nz*_-$Q z@^~xnEzEjp&?VMR@NmOf(pKu9*z-tb*1jj>L}hFPBiA!#|*%)4bY z0HVw3H5Fd75AP-Nz6948ItA&X;bs}_3}0{I|6*Q-^{==btEY4~(t5-F3M|wW%jj4Z z-os$7rq7Dp9ztOS?ZVmPb_U(sD3gRcp$u`Y<2tw^)+Fhg)GGRo{S{uYWCEvy^NjYf zUN#E$l~SWD-H>oT6l#{!v#Q(BDubcE40Tjg=>*r!sI5f{iPc@G07^QT^ye#%UkO1q zbdLRh0btM?dJJ$#7$Vjgqj3EYjn%*qS3ZzcrfaQlqQ0>CYnp&Dj>EWgxN;IJQrKe7 z6U<*>BND02JBW`Q_9CI!Wm!A>2!Ks=fH2}4x-uBvy@aahZ&9r~)@`X~CnaJ99FoEZ ziQ6t*g7D=nI@w1A>J61Cybr8*<@-rXb0ZfD6SO#tR3@kuFwKom<)sz?N!KwT&${uQ z1z&eQoIvp{ysFk8i|%n9?*<`myb0=ce1=TUj_>k^;d=fFVXdAIk}KDrCw%+v=4 zWb;PIt>ImvFp*y+bm`6aB!CmtE<$}@{tg8kqk0QxALrkvAkI>un;GdR_2%`$>7jfb z55rPW#C;>NA==7vUODa!P3Db4UNWCY#PKWO`1P;CveEnp>5%jeUoJGf!#iu_P-q#A z_K*S^bI1cGZ{ml`2eurNQYBQKC4ua?p2xuxI;y=$6{ASND4OovDjd$#@sZ%agWmuj zrQ@q`_s@7O%sPctVq_K { const instance: App = new App({ user, - // Test-only: when Playwright has injected window.__RELAY_MOCKS__, serve relays from in-memory - // fixtures instead of the network. No-op for real users; stripped from production builds. - getAdapter: import.meta.env.DEV ? maybeMakeRelayMockAdapter() : undefined, config: { dufflepudUrl: DUFFLEPUD_URL, getDefaultRelays: always(DEFAULT_RELAYS), diff --git a/src/app/env.ts b/src/app/env.ts index 313d4daf..172d63bf 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -2,69 +2,76 @@ import {Capacitor} from "@capacitor/core" import * as nip19 from "nostr-tools/nip19" import {identity} from "@welshman/lib" import {normalizeRelayUrl} from "@welshman/util" +import {maybeGetTestEnv} from "@lib/test/env" const fromCsv = (s: string) => (s || "").split(",").filter(identity) -export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER +// Test-only: when Playwright has injected window.__TEST_ENV__, VITE_ values resolve against it, so +// each browser context can be pointed at the relays its own test created. Vite folds DEV to false +// in a production build, so the branch and the import are stripped from it. +const env = (key: string): string => + (import.meta.env.DEV ? maybeGetTestEnv(key) : undefined) ?? import.meta.env[key] -export const PUSH_BRIDGE = normalizeRelayUrl(import.meta.env.VITE_PUSH_BRIDGE) +export const PUSH_SERVER = env("VITE_PUSH_SERVER") + +export const PUSH_BRIDGE = normalizeRelayUrl(env("VITE_PUSH_BRIDGE")) export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios" -export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS).map(normalizeRelayUrl) +export const SIGNER_RELAYS = fromCsv(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl) -export const BLOCKED_RELAYS = fromCsv(import.meta.env.VITE_BLOCKED_RELAYS).map(normalizeRelayUrl) +export const BLOCKED_RELAYS = fromCsv(env("VITE_BLOCKED_RELAYS")).map(normalizeRelayUrl) -export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl) +export const INDEXER_RELAYS = fromCsv(env("VITE_INDEXER_RELAYS")).map(normalizeRelayUrl) -export const DEFAULT_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl) +export const DEFAULT_RELAYS = fromCsv(env("VITE_DEFAULT_RELAYS")).map(normalizeRelayUrl) -export const DEFAULT_SEARCH_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_SEARCH_RELAYS).map( +export const DEFAULT_SEARCH_RELAYS = fromCsv(env("VITE_DEFAULT_SEARCH_RELAYS")).map( normalizeRelayUrl, ) -export const DEFAULT_MESSAGING_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_MESSAGING_RELAYS).map( +export const DEFAULT_MESSAGING_RELAYS = fromCsv(env("VITE_DEFAULT_MESSAGING_RELAYS")).map( normalizeRelayUrl, ) -export const PLATFORM_RELAYS = fromCsv(import.meta.env.VITE_PLATFORM_RELAYS).map(normalizeRelayUrl) +export const PLATFORM_RELAYS = fromCsv(env("VITE_PLATFORM_RELAYS")).map(normalizeRelayUrl) -export const PLATFORM_URL = import.meta.env.VITE_PLATFORM_URL +export const PLATFORM_URL = env("VITE_PLATFORM_URL") -export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS +export const PLATFORM_TERMS = env("VITE_PLATFORM_TERMS") -export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY +export const PLATFORM_PRIVACY = env("VITE_PLATFORM_PRIVACY") export const PLATFORM_LOGO = import.meta.env.PROD ? PLATFORM_URL + "/logo.png" - : import.meta.env.VITE_PLATFORM_LOGO.replace(/^static/, "") || PLATFORM_URL + "/logo.png" + : env("VITE_PLATFORM_LOGO").replace(/^static/, "") || PLATFORM_URL + "/logo.png" -export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME +export const PLATFORM_NAME = env("VITE_PLATFORM_NAME") -export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT +export const PLATFORM_ACCENT = env("VITE_PLATFORM_ACCENT") // components visual preset (see src/lib/components/theme.css). Selected per // deployment via VITE_THEME, which is assumed to be set to a known theme // (e.g. "clay" or "flat") — there is no default. -export const FL_THEME = import.meta.env.VITE_THEME +export const FL_THEME = env("VITE_THEME") -export const PLATFORM_DESCRIPTION = import.meta.env.VITE_PLATFORM_DESCRIPTION +export const PLATFORM_DESCRIPTION = env("VITE_PLATFORM_DESCRIPTION") -export const POMADE_SIGNERS = fromCsv(import.meta.env.VITE_POMADE_SIGNERS) +export const POMADE_SIGNERS = fromCsv(env("VITE_POMADE_SIGNERS")) -export const DEFAULT_BLOSSOM_SERVERS = fromCsv(import.meta.env.VITE_DEFAULT_BLOSSOM_SERVERS) +export const DEFAULT_BLOSSOM_SERVERS = fromCsv(env("VITE_DEFAULT_BLOSSOM_SERVERS")) -export const DEFAULT_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(normalizeRelayUrl) +export const DEFAULT_SPACES = fromCsv(env("VITE_DEFAULT_SPACES")).map(normalizeRelayUrl) -export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS +export const DEFAULT_PUBKEYS = env("VITE_DEFAULT_PUBKEYS") -export const HOSTING_BACKEND_URL = import.meta.env.VITE_HOSTING_BACKEND_URL +export const HOSTING_BACKEND_URL = env("VITE_HOSTING_BACKEND_URL") -export const HOSTING_RELAY_DOMAIN = import.meta.env.VITE_HOSTING_RELAY_DOMAIN +export const HOSTING_RELAY_DOMAIN = env("VITE_HOSTING_RELAY_DOMAIN") export const DUFFLEPUD_URL = "https://dufflepud.coracle.social" -export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL +export const THUMBNAIL_URL = env("VITE_THUMBNAIL_URL") export const dufflepud = (path: string) => DUFFLEPUD_URL + "/" + path diff --git a/src/app/session.ts b/src/app/session.ts index 33b10a72..06817cb5 100644 --- a/src/app/session.ts +++ b/src/app/session.ts @@ -2,6 +2,7 @@ import type {ClientOptions} from "@pomade/core" import type {Wallet} from "@welshman/util" import {nip01, nip07, nip46, nip55, pomade, toSession} from "@welshman/app" import type {Session} from "@welshman/app" +import {getTestEvents, maybeGetTestSession} from "@lib/test/session" import {app, login, session} from "@app/core" import {wallet} from "@app/lightning" import {kv, ss, storage} from "@app/storage" @@ -61,12 +62,27 @@ const readLegacySession = async () => { // The session is derived from the app's user, so it can't be synced to storage directly — // read it back once at startup, then persist it whenever the identity changes. export const restoreSession = async () => { - const $session = (await ss.get("session")) ?? (await readLegacySession()) + // Test-only: when Playwright has injected window.__TEST_SESSION__, that identity wins over + // whatever is in storage. No-op for real users; stripped from production builds. + const testSession = import.meta.env.DEV ? maybeGetTestSession() : undefined + const $session = testSession ?? (await ss.get("session")) ?? (await readLegacySession()) if ($session) { await login($session) } + // Logging in builds a fresh app, so the test's cached events go into its repository afterwards, + // the same way storage loads what the last session left behind. Storage gets there first and + // loads with Repository.load, which clears the repository before inserting, so these have to + // wait for it or they are wiped before anything reads them. + if (testSession) { + await storage.get()?.ready + + for (const event of getTestEvents()) { + app.get().repository.publish(event) + } + } + return session.subscribe($session => { if ($session) { ss.set("session", $session) diff --git a/src/lib/test/env.ts b/src/lib/test/env.ts new file mode 100644 index 00000000..0115f3b7 --- /dev/null +++ b/src/lib/test/env.ts @@ -0,0 +1,10 @@ +import type {Maybe} from "@welshman/lib" + +// The window key Playwright writes to (see e2e/harness/app/boot.ts). Keep it in sync with the +// literal duplicated there. +export const TEST_ENV_KEY = "__TEST_ENV__" + +// Consulted when the app resolves a VITE_ value, so each browser context can be pointed at the +// relays its own test created. Yields nothing for real users. +export const maybeGetTestEnv = (key: string): Maybe => + (globalThis as {[TEST_ENV_KEY]?: Record})[TEST_ENV_KEY]?.[key] diff --git a/src/lib/test/relayMocks.ts b/src/lib/test/relayMocks.ts deleted file mode 100644 index 2d1a1514..00000000 --- a/src/lib/test/relayMocks.ts +++ /dev/null @@ -1,83 +0,0 @@ -import {on} from "@welshman/lib" -import type {Maybe} from "@welshman/lib" -import {isRelayUrl, normalizeRelayUrl} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" -import {AbstractAdapter, AdapterEvent, LocalAdapter, Repository} from "@welshman/net" -import type {AdapterFactory, ClientMessage, RelayMessage} from "@welshman/net" - -// The window key Playwright writes the mock config to (see e2e/support/relayMocks.ts). Keep it in -// sync with the literal duplicated there. -export const RELAY_MOCKS_KEY = "__RELAY_MOCKS__" - -export type RelayMockConfig = { - // Map of relay url -> events that relay should return. Any relay NOT listed returns nothing (an - // immediate EOSE), which is what keeps tests offline and reproducible by default. - relays?: Record -} - -// Wraps welshman's LocalAdapter so we reuse its REQ/EVENT/CLOSE handling against an in-memory -// Repository, but re-emits its messages under the real relay url instead of LOCAL_RELAY_URL. That -// keeps relay attribution / relay-scoped behaviour (e.g. NIP-29 groups) working as it would over a -// real socket. (Composition rather than inheritance because LocalAdapter emits via a private method -// that hardcodes LOCAL_RELAY_URL.) -class FixtureAdapter extends AbstractAdapter { - readonly local: LocalAdapter - - constructor( - readonly url: string, - repository: Repository, - ) { - super() - - this.local = new LocalAdapter(repository) - - const forward = (message: RelayMessage) => this.emit(AdapterEvent.Receive, message, this.url) - - this._unsubscribers.push(on(this.local, AdapterEvent.Receive, forward), () => - this.local.cleanup(), - ) - } - - get sockets() { - return this.local.sockets - } - - get urls() { - return [this.url] - } - - send(message: ClientMessage) { - this.local.send(message) - } -} - -// An adapter factory serving every real relay url from memory, so no websocket is ever created for -// one. Non-relay urls (e.g. the local:// repository relay) fall through to welshman's default -// handling by returning undefined. -export const makeRelayMockAdapter = (config: RelayMockConfig): AdapterFactory => { - const reposByUrl = new Map() - - for (const [url, events] of Object.entries(config.relays ?? {})) { - const repository = new Repository() - repository.load(events) - reposByUrl.set(normalizeRelayUrl(url), repository) - } - - const emptyRepository = new Repository() - - return (url: string) => { - if (isRelayUrl(url)) { - return new FixtureAdapter(url, reposByUrl.get(normalizeRelayUrl(url)) ?? emptyRepository) - } - } -} - -// Called when the app is created. Yields an adapter factory only when Playwright has injected a -// config, so it is a no-op for real users. -export const maybeMakeRelayMockAdapter = (): Maybe => { - const config = (globalThis as {[RELAY_MOCKS_KEY]?: RelayMockConfig})[RELAY_MOCKS_KEY] - - if (config) { - return makeRelayMockAdapter(config) - } -} diff --git a/src/lib/test/session.ts b/src/lib/test/session.ts new file mode 100644 index 00000000..10f5bc33 --- /dev/null +++ b/src/lib/test/session.ts @@ -0,0 +1,19 @@ +import type {Maybe} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import type {Session} from "@welshman/app" + +// The window keys Playwright writes to (see e2e/harness/app/session.ts). Keep them in sync with +// the literals duplicated there. +export const TEST_SESSION_KEY = "__TEST_SESSION__" + +export const TEST_EVENTS_KEY = "__TEST_EVENTS__" + +// Called when the session is restored at startup. Yields a session only when Playwright has +// injected one, so it is a no-op for real users. +export const maybeGetTestSession = (): Maybe => + (globalThis as {[TEST_SESSION_KEY]?: Session})[TEST_SESSION_KEY] + +// The events a returning user's client would have found in storage, loaded into the repository +// alongside the injected session so a test boots into a state the app can reach on its own. +export const getTestEvents = (): TrustedEvent[] => + (globalThis as {[TEST_EVENTS_KEY]?: TrustedEvent[]})[TEST_EVENTS_KEY] ?? [] diff --git a/tsconfig.json b/tsconfig.json index 593dc196..d0138305 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,22 @@ "sourceMap": true, "strict": true, "moduleResolution": "bundler" - } + }, + // TypeScript does not merge includes, so this repeats .svelte-kit/tsconfig.json's list in order + // to add the e2e harness, which runs in node and is otherwise checked by nothing. + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.ts", + "playwright.config.ts", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte", + "test/**/*.ts", + "tests/**/*.ts", + "e2e/**/*.ts" + ] // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files // diff --git a/vite.config.ts b/vite.config.ts index 23db1456..752a5bed 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,23 @@ config({path: ".env.local"}) config({path: ".env"}) export default defineConfig({ + // @welshman/editor takes tiptap as peer dependencies, so an installed copy resolves them from + // here and there is one prosemirror. link_deps symlinks its source instead, and that directory + // carries node_modules from welshman's own install — a second prosemirror, whose Plugin and + // PluginKey classes tiptap refuses to mix with ours ("Adding different instances of a keyed + // plugin"). Deduping resolves these to this project's copy wherever they are imported from, and + // does nothing when only one copy exists. + resolve: { + dedupe: [ + "@tiptap/core", + "@tiptap/pm", + "@tiptap/suggestion", + "prosemirror-model", + "prosemirror-state", + "prosemirror-transform", + "prosemirror-view", + ], + }, server: { port: 1847, // host: "0.0.0.0", -- 2.45.2 From 9c68f66c39740b5623712053b26c3b1cfc19aaf5 Mon Sep 17 00:00:00 2001 From: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Date: Fri, 7 Aug 2026 21:05:17 +0000 Subject: [PATCH 03/21] fix: replace mobile space menu popover with a bottom-sheet action menu (#330) Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> --- src/app/components/SpaceMenu.svelte | 396 +----------------- src/app/components/SpaceMenuActions.svelte | 122 ++++++ .../components/SpaceMenuActionsMobile.svelte | 118 ++++++ src/app/components/SpaceMenuHeader.svelte | 74 ++++ src/app/components/SpaceMenuMobile.svelte | 19 + src/app/components/SpaceMenuNavItems.svelte | 103 +++++ src/app/components/SpaceMenuRooms.svelte | 106 +++++ src/app/routes.ts | 8 +- 8 files changed, 566 insertions(+), 380 deletions(-) create mode 100644 src/app/components/SpaceMenuActions.svelte create mode 100644 src/app/components/SpaceMenuActionsMobile.svelte create mode 100644 src/app/components/SpaceMenuHeader.svelte create mode 100644 src/app/components/SpaceMenuMobile.svelte create mode 100644 src/app/components/SpaceMenuNavItems.svelte create mode 100644 src/app/components/SpaceMenuRooms.svelte diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 3f1d9244..114f0a5c 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -1,79 +1,13 @@ -{#snippet spaceHeader()} - - {#if showMenu} - - - - {/if} -{/snippet} - -{#snippet spaceNavItems()} - - Space Details - - {#if hasNip29} - - Recent Activity - - {:else} - - Chat - - {/if} - - Directory - - {#if showLibrary} - - Library - - {/if} - {#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)} - - Goals - - {/if} - {#if $spaceKinds.has(THREAD)} - - Threads - - {/if} - {#if $spaceKinds.has(CLASSIFIED)} - - Classifieds - - {/if} - {#if $spaceKinds.has(EVENT_TIME)} - - Calendar - - {/if} - {#if $spaceKinds.has(POLL)} - - Polls - - {/if} - {#if hasNip29} - - Search - - {/if} -{/snippet} - -{#snippet spaceRooms()} - {#if hasNip29} - {#if $userRooms.length > 0} - {#if !mobile} -
- {/if} - Your Rooms - {/if} - {#each $userRooms as h (h)} - - {/each} - {#if $otherRooms.length > 0} - {#if !mobile} -
- {/if} - - {#if $userRooms.length > 0} - Other Rooms - {:else} - Rooms - {/if} - - {/if} - {#if $otherRooms.length > 20} - - {/if} - {#each $roomSearch.searchValues(term) as h (h)} - - {/each} - {#if $otherVoiceRooms.length > 0} - {#if !mobile} -
- {/if} - Voice Rooms - {#each $otherVoiceRooms as h (h)} - - {/each} - {/if} - {#if $canCreateRoom} - - - Create room - - {/if} - {/if} -{/snippet} - -{#snippet spaceFooter()} - - - - -{/snippet} - -
+
{#if mobile}
- {@render spaceHeader()} +
Space - {@render spaceNavItems()} +
- {#if hasNip29 && ($userRooms.length > 0 || $otherRooms.length > 0 || $otherVoiceRooms.length > 0 || $canCreateRoom)} -
- {@render spaceRooms()} -
- {/if} +
- {@render spaceFooter()} + + + +
{:else}
- {@render spaceHeader()} +
- {@render spaceNavItems()} - {@render spaceRooms()} + +
- {@render spaceFooter()} + + + +
{/if}
diff --git a/src/app/components/SpaceMenuActions.svelte b/src/app/components/SpaceMenuActions.svelte new file mode 100644 index 00000000..e4ee3940 --- /dev/null +++ b/src/app/components/SpaceMenuActions.svelte @@ -0,0 +1,122 @@ + + +{#snippet actionButton( + onclick: () => void, + icon: string, + label: string, + variant?: "error" | "primary", +)} +
  • + +
  • +{/snippet} + +{@render actionButton(createInvite, LinkRound, "Create Invite")} +{#if $userIsAdmin} +
  • + +
  • +{/if} +{#if $relay?.pubkey && $relay.pubkey !== $user.pubkey} + {@render actionButton(contactOwner, Letter, "Contact Owner")} +{/if} +{#if $notificationSettings.push} + {@render actionButton( + toggleSpaceNotifications, + $shouldNotify ? Bell : BellOff, + `${$shouldNotify ? "Turn off" : "Turn on"} notifications`, + )} +{:else} +
  • + + + Enable notifications + +
  • +{/if} +{#if HOSTING_ENABLED && $hostedRelay.relay} +
  • + + + Hosting settings + +
  • +{:else if $userIsAdmin} + {@render actionButton(startEdit, Pen, "Edit Space")} +{/if} +{#if $userSpaceUrls.includes(url)} + {@render actionButton(leaveSpace, Exit, "Leave Space", "error")} +{:else} + {@render actionButton(joinSpace, Login, "Join Space", "primary")} +{/if} diff --git a/src/app/components/SpaceMenuActionsMobile.svelte b/src/app/components/SpaceMenuActionsMobile.svelte new file mode 100644 index 00000000..fb6ba2b0 --- /dev/null +++ b/src/app/components/SpaceMenuActionsMobile.svelte @@ -0,0 +1,118 @@ + + +{#snippet actionButton( + onclick: () => void, + icon: string, + label: string, + variant?: "error" | "primary", +)} + +{/snippet} + +{@render actionButton(createInvite, LinkRound, "Create Invite")} +{#if $userIsAdmin} + +{/if} +{#if $relay?.pubkey && $relay.pubkey !== $user.pubkey} + {@render actionButton(contactOwner, Letter, "Contact Owner")} +{/if} +{#if $notificationSettings.push} + {@render actionButton( + toggleSpaceNotifications, + $shouldNotify ? Bell : BellOff, + `${$shouldNotify ? "Turn off" : "Turn on"} notifications`, + )} +{:else} + + + Enable notifications + +{/if} +{#if HOSTING_ENABLED && $hostedRelay.relay} + + + Hosting settings + +{:else if $userIsAdmin} + {@render actionButton(startEdit, Pen, "Edit Space")} +{/if} +{#if $userSpaceUrls.includes(url)} + {@render actionButton(leaveSpace, Exit, "Leave Space", "error")} +{:else} + {@render actionButton(joinSpace, Login, "Join Space", "primary")} +{/if} diff --git a/src/app/components/SpaceMenuHeader.svelte b/src/app/components/SpaceMenuHeader.svelte new file mode 100644 index 00000000..723dcd59 --- /dev/null +++ b/src/app/components/SpaceMenuHeader.svelte @@ -0,0 +1,74 @@ + + + +{#if showMenu && !mobile} + + + +{/if} diff --git a/src/app/components/SpaceMenuMobile.svelte b/src/app/components/SpaceMenuMobile.svelte new file mode 100644 index 00000000..8f4eca2b --- /dev/null +++ b/src/app/components/SpaceMenuMobile.svelte @@ -0,0 +1,19 @@ + + + + +
    + +
    +
    +
    diff --git a/src/app/components/SpaceMenuNavItems.svelte b/src/app/components/SpaceMenuNavItems.svelte new file mode 100644 index 00000000..36cedae9 --- /dev/null +++ b/src/app/components/SpaceMenuNavItems.svelte @@ -0,0 +1,103 @@ + + + + Space Details + +{#if hasNip29} + + Recent Activity + +{:else} + + Chat + +{/if} + + Directory + +{#if showLibrary} + + Library + +{/if} +{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)} + + Goals + +{/if} +{#if $spaceKinds.has(THREAD)} + + Threads + +{/if} +{#if $spaceKinds.has(CLASSIFIED)} + + Classifieds + +{/if} +{#if $spaceKinds.has(EVENT_TIME)} + + Calendar + +{/if} +{#if $spaceKinds.has(POLL)} + + Polls + +{/if} +{#if hasNip29} + + Search + +{/if} diff --git a/src/app/components/SpaceMenuRooms.svelte b/src/app/components/SpaceMenuRooms.svelte new file mode 100644 index 00000000..ec6d4216 --- /dev/null +++ b/src/app/components/SpaceMenuRooms.svelte @@ -0,0 +1,106 @@ + + +{#snippet content()} + {#if $userRooms.length > 0} + {#if !mobile} +
    + {/if} + Your Rooms + {/if} + {#each $userRooms as h (h)} + + {/each} + {#if $otherRooms.length > 0} + {#if !mobile} +
    + {/if} + + {#if $userRooms.length > 0} + Other Rooms + {:else} + Rooms + {/if} + + {/if} + {#if $otherRooms.length > 20} + + {/if} + {#each $roomSearch.searchValues(term) as h (h)} + + {/each} + {#if $otherVoiceRooms.length > 0} + {#if !mobile} +
    + {/if} + Voice Rooms + {#each $otherVoiceRooms as h (h)} + + {/each} + {/if} + {#if $canCreateRoom} + + + Create room + + {/if} +{/snippet} + +{#if ($relay?.hasNip(29) ?? false) && ($userRooms.length > 0 || $otherRooms.length > 0 || $otherVoiceRooms.length > 0 || $canCreateRoom)} + {#if mobile} +
    + {@render content()} +
    + {:else} + {@render content()} + {/if} +{/if} diff --git a/src/app/routes.ts b/src/app/routes.ts index c171488f..f150126e 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -57,13 +57,13 @@ export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(u export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat") -export const goToChat = (pubkeys: string[] = []) => { +export const goToChat = (pubkeys: string[] = [], options: {replaceState?: boolean} = {}) => { if (messagingRelayLists.get().urls(user.get().pubkey).get().length === 0) { - pushModal(ChatEnable, {next: () => goToChat(pubkeys)}) + pushModal(ChatEnable, {next: () => goToChat(pubkeys, options)}) } else if (pubkeys.length === 0) { - goto(lastChatUrl ?? "/chat") + goto(lastChatUrl ?? "/chat", options) } else { - goto(makeChatPath(pubkeys)) + goto(makeChatPath(pubkeys), options) } } -- 2.45.2 From 749989f56b4b1024545c70340a573d09736eaef4 Mon Sep 17 00:00:00 2001 From: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Date: Fri, 7 Aug 2026 21:10:32 +0000 Subject: [PATCH 04/21] Fix DM compose bar being hidden/cut off on mobile (#335) Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> --- src/app/components/Chat.svelte | 117 +++++++++++++++++---------------- 1 file changed, 60 insertions(+), 57 deletions(-) diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index dc530074..4578b3a3 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -26,6 +26,7 @@ import Icon from "@lib/components/Icon.svelte" import Spinner from "@lib/components/Spinner.svelte" import PageBar from "@lib/components/PageBar.svelte" + import PageContent from "@lib/components/PageContent.svelte" import Divider from "@lib/components/Divider.svelte" import Button from "@lib/components/Button.svelte" import ProfileName from "@app/components/ProfileName.svelte" @@ -263,67 +264,69 @@
    -
    - {#if missingRelayLists.length > 0} -
    -
    -

    - - Direct messages are not enabled -

    -

    - Ask - {#each missingRelayLists as pubkey (pubkey)} - - {/each} - to enable direct messaging by opening this conversation in their $app. -

    +
    + + {#if missingRelayLists.length > 0} +
    +
    +

    + + Direct messages are not enabled +

    +

    + Ask + {#each missingRelayLists as pubkey (pubkey)} + + {/each} + to enable direct messaging by opening this conversation in their $app. +

    +
    -
    - {/if} - {#each elements as { type, id, value, showPubkey } (id)} - {#if type === "date"} - {value} - {:else} - {/if} - {/each} -

    - - {#if loading} - Looking for messages... + {#each elements as { type, id, value, showPubkey } (id)} + {#if type === "date"} + {value} {:else} - End of message history + {/if} - - {@render info?.()} -

    -
    -
    + {/each} +

    + + {#if loading} + Looking for messages... + {:else} + End of message history + {/if} + + {@render info?.()} +

    + -
    -
    - {#if parent} - - {/if} - {#if eventToEdit} - - {/if} +
    +
    + {#if parent} + + {/if} + {#if eventToEdit} + + {/if} +
    + {#key eventToEdit} + + {/key}
    - {#key eventToEdit} - - {/key}
    -- 2.45.2 From b2f58b11c05309177295ad0dd805db1e58a328a9 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 7 Aug 2026 14:07:50 -0700 Subject: [PATCH 05/21] Fix touch target for space menu --- src/lib/components/space-menu.css | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/components/space-menu.css b/src/lib/components/space-menu.css index 46655c7c..1ca9062a 100644 --- a/src/lib/components/space-menu.css +++ b/src/lib/components/space-menu.css @@ -68,12 +68,8 @@ @apply mt-2 pt-3; } - .space-menu__header { - @apply p-3; - } - .space-menu__header-button { - @apply rounded-2xl p-0; + @apply w-full rounded-2xl p-0 p-3; &:hover { @apply bg-transparent; -- 2.45.2 From 65c9eac5e54aae5d9c872c367ee1eb6d91be92dc Mon Sep 17 00:00:00 2001 From: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Date: Mon, 10 Aug 2026 16:16:30 +0000 Subject: [PATCH 06/21] Show new-messages badge in tab title when re-foregrounding (#332) Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> --- src/app/components/RoomChat.svelte | 11 +++++++---- src/lib/html.ts | 25 +++++++++++++++++++++++++ src/routes/+layout.svelte | 10 ++++++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index 67ece432..c0608f12 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -15,6 +15,7 @@ import Login2 from "@assets/icons/login-3.svg?dataurl" import {fade, fly} from "@lib/transition" import {popKey} from "@lib/implicit" + import {documentActive} from "@lib/html" import Button from "@lib/components/Button.svelte" import Divider from "@lib/components/Divider.svelte" import Icon from "@lib/components/Icon.svelte" @@ -301,8 +302,10 @@ } } - const onVisibilityChange = () => { - if (document.hidden) { + // A tab can be `visible` but unfocused (user alt-tabbed to another app), so we + // can't rely on document.hidden alone to know the room is actually being watched. + const onActiveChange = (active: boolean) => { + if (!active) { lastVisibleAt = now() } else if ($events.some(e => e.pubkey !== $user.pubkey && e.created_at > lastVisibleAt)) { newMessagesAfter = lastVisibleAt @@ -450,12 +453,12 @@ onMount(() => { start() - document.addEventListener("visibilitychange", onVisibilityChange) + const unsubscribeActive = documentActive.subscribe(onActiveChange) return () => { // Wrap in a closure to avoid calling a stale cleanup function cleanup?.() - document.removeEventListener("visibilitychange", onVisibilityChange) + unsubscribeActive() } }) diff --git a/src/lib/html.ts b/src/lib/html.ts index 3a571ca1..d59e4b58 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -1,7 +1,32 @@ +import {readable} from "svelte/store" import {sleep, randomId} from "@welshman/lib" import {Capacitor} from "@capacitor/core" export {preventDefault, stopPropagation} from "svelte/legacy" +/** Whether the user is actually looking at this tab right now. Display-only concern, + * kept separate from any data store — consult it wherever "is someone watching" should + * affect what's rendered. `document.hidden` alone misses window blur: switching to + * another app without switching tabs leaves visibilityState "visible", so we also track + * focus and require both. */ +export const documentActive = readable( + typeof document === "undefined" ? true : !document.hidden && document.hasFocus(), + set => { + if (typeof document === "undefined") return + + const update = () => set(!document.hidden && document.hasFocus()) + + document.addEventListener("visibilitychange", update) + window.addEventListener("blur", update) + window.addEventListener("focus", update) + + return () => { + document.removeEventListener("visibilitychange", update) + window.removeEventListener("blur", update) + window.removeEventListener("focus", update) + } + }, +) + // Anchors an @svelte-plugins/datepicker popup with fixed positioning so it // escapes scroll-container clipping (e.g. inside modals). Call when the picker // opens; returns a cleanup function that removes the listeners. diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index cc9f4249..08ad01ac 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -15,7 +15,7 @@ import * as util from "@welshman/util" import * as lib from "@welshman/lib" import {Logger} from "@welshman/app" - import {isMobile} from "@lib/html" + import {isMobile, documentActive} from "@lib/html" import AppContainer from "@app/components/AppContainer.svelte" import ModalContainer from "@app/components/ModalContainer.svelte" import {app} from "@app/core" @@ -33,6 +33,7 @@ import {theme} from "@app/theme" import {toast, pushToast} from "@app/toast" import * as notifications from "@app/notifications" + import {notifications as notificationPaths, allNotifications} from "@app/notifications" import {Push} from "@app/push" import {onPushNotificationAction, pushState} from "@app/push/adapters/common" import {syncKeyboard} from "@app/keyboard" @@ -239,7 +240,12 @@ }) $effect(() => { - document.title = getPageTitle({page: $page, pubkey: $app.user?.pubkey}) + const title = getPageTitle({page: $page, pubkey: $app.user?.pubkey}) + // While the tab isn't actively focused the user isn't actually looking at the + // active page, so count notifications for it too rather than treating it as read. + const unreadCount = $documentActive ? $notificationPaths.size : $allNotifications.size + + document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title }) -- 2.45.2 From 7dcc713ec2681e1f3e62f48ae0eb2e9e48128a21 Mon Sep 17 00:00:00 2001 From: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Date: Mon, 10 Aug 2026 18:14:46 +0000 Subject: [PATCH 07/21] Refine calls: accessible controls, mic level check before joining (#331) Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> --- src/app/call.ts | 43 ------ src/app/components/MicLevelMeter.svelte | 91 ++++++++++++ src/app/components/RoomChat.svelte | 110 +++++++++----- src/app/components/VideoCallContent.svelte | 30 +--- .../VoiceCallAudioSettingsDialog.svelte | 31 +++- src/app/components/VoiceRoomItem.svelte | 6 +- src/app/components/VoiceRoomJoinDialog.svelte | 65 +++++++- src/app/components/VoiceWidget.svelte | 140 +++++++++--------- 8 files changed, 335 insertions(+), 181 deletions(-) create mode 100644 src/app/components/MicLevelMeter.svelte diff --git a/src/app/call.ts b/src/app/call.ts index e81c442e..1a2d6ad8 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -2,7 +2,6 @@ * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. */ -import {MediaQuery} from "svelte/reactivity" import { DisconnectReason, LocalParticipant, @@ -32,7 +31,6 @@ export const LIVEKIT_PARTICIPANTS = 39004 export {supportsAudioOutputSelection} const LIVEKIT_DEFAULT_DEVICE_ID = "default" -const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] export type CallSession = { @@ -65,11 +63,6 @@ export enum VideoCallLayout { Split = "split", } -export enum ViewportSize { - Desktop = "desktop", - Mobile = "mobile", -} - export enum DeviceKind { AudioInput = "audioinput", AudioOutput = "audiooutput", @@ -109,24 +102,10 @@ export const triggerVideoTrackRevision = () => { videoTrackRevision.update(n => n + 1) } -export const isDesktopLayout = new MediaQuery("min-width: 768px", false) - -export const videoCallViewportSync = { - previousLayout: undefined as ViewportSize | undefined, -} - export const videoCallLayout = writable(VideoCallLayout.Split) export const videoPrimaryTileKey = writable(undefined) -export const videoTileCount = derived( - [currentCallSession, callState, videoTrackRevision, participantMediaState], - ([$session, $state]) => { - if ($state !== CallState.Connected || !$session) return 0 - return countLiveVisualFeeds($session) - }, -) - export const joinVoiceRoom = async ( url: string, h: string, @@ -375,7 +354,6 @@ export const switchCallActiveDevice = async ( } export const resetVideoCallLayout = () => { - videoCallViewportSync.previousLayout = undefined videoCallLayout.set(VideoCallLayout.Chat) } @@ -458,27 +436,6 @@ const teardownRoom = (livekit: LiveKitRoom) => { livekit.disconnect() } -const countLiveVisualFeeds = (session: CallSession): number => { - const livekit = session.livekit - let n = 0 - const lp = livekit.localParticipant - if (session.cameraOn) { - const pub = lp.getTrackPublication(Track.Source.Camera) - if (pub?.track) n += 1 - } - if (session.screenShareOn) { - const pub = lp.getTrackPublication(Track.Source.ScreenShare) - if (pub?.track) n += 1 - } - for (const rp of livekit.remoteParticipants.values()) { - for (const source of VISUAL_SOURCES) { - const pub = rp.getTrackPublication(source) - if (pub?.isSubscribed && pub.track && !pub.isMuted) n += 1 - } - } - return n -} - const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({ muted: !participant.isMicrophoneEnabled, cameraOn: participant.isCameraEnabled, diff --git a/src/app/components/MicLevelMeter.svelte b/src/app/components/MicLevelMeter.svelte new file mode 100644 index 00000000..5f1e77dc --- /dev/null +++ b/src/app/components/MicLevelMeter.svelte @@ -0,0 +1,91 @@ + + +
    + + {#if error} + + {:else if !track} +

    {offMessage}

    + {:else} +
    + {#each Array(5) as _, i (i)} +
    i ? "bg-success" : "bg-surface-more", + )}> +
    + {/each} +
    + Speak to test + {/if} +
    diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index c0608f12..5aaabfcf 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -13,6 +13,8 @@ import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import ClockCircle from "@assets/icons/clock-circle.svg?dataurl" import Login2 from "@assets/icons/login-3.svg?dataurl" + import ChatRound from "@assets/icons/chat-round.svg?dataurl" + import Close from "@assets/icons/close.svg?dataurl" import {fade, fly} from "@lib/transition" import {popKey} from "@lib/implicit" import {documentActive} from "@lib/html" @@ -31,14 +33,7 @@ import VoiceWidget from "@app/components/VoiceWidget.svelte" import {deletes, relays, rooms, thunks, user} from "@app/core" import {publishRoomJoinRequest} from "@app/access" - import { - CallState, - callTargetRoom, - callState, - VideoCallLayout, - videoCallLayout, - videoTileCount, - } from "@app/call" + import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call" import { PROTECTED, RoomType, @@ -49,7 +44,7 @@ } from "@app/rooms" import {userSettingsValues} from "@app/settings" import {makeFeed} from "@app/feeds" - import {checked, deferredRoomPath, setChecked} from "@app/notifications" + import {checked, deferredRoomPath, notifications, setChecked} from "@app/notifications" import {makeRoomPath} from "@app/routes" import {pushToast} from "@app/toast" @@ -75,45 +70,53 @@ isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video, ) - const roomPath = h ? makeRoomPath(url, h) : undefined + // Basic pass at issue #121's "chat overlaid on video" idea: float the chat panel over + // the full-bleed video instead of splitting the screen into fixed columns, so the call + // stays the visual focus. The panel opens from — and collapses back to — a chat FAB. + const isOverlayChat = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split) const videoCallChatHidden = $derived( voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video, ) + const roomPath = h ? makeRoomPath(url, h) : undefined + const chatUnread = $derived(roomPath !== undefined && $notifications.has(roomPath)) + + const openChat = () => videoCallLayout.set(VideoCallLayout.Split) + const closeChat = () => videoCallLayout.set(VideoCallLayout.Video) + $effect(() => { deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined) if (roomPath && voiceConnectedHere && !videoCallChatHidden) { setChecked(roomPath) } + // The messages pane (and its scroll position) is offscreen while the call view is + // showing — don't leave its floating "scroll down"/"new messages" chips stranded. + if (videoCallChatHidden) { + showScrollButton = false + showFixedNewMessages = false + } }) onDestroy(() => deferredRoomPath.set(undefined)) - let prevVideoTileCount = $state(0) + let wasConnectedHere = $state(false) $effect(() => { - if ($callState !== CallState.Connected) { - videoCallLayout.set(VideoCallLayout.Chat) - prevVideoTileCount = 0 + if (!voiceConnectedHere) { + if ($callState !== CallState.Connected) { + videoCallLayout.set(VideoCallLayout.Chat) + } + wasConnectedHere = false return } - const here = isVoiceRoom && $callTargetRoom?.url === url && $callTargetRoom?.h === h - const n = $videoTileCount - - if (!here) { - prevVideoTileCount = 0 - return - } - - if (prevVideoTileCount === 0 && n >= 1) { + // Land on the call view (chat as a FAB) as soon as the call starts here, whether + // or not anyone's camera is on — chat is one tap away via the FAB. + if (!wasConnectedHere) { videoCallLayout.set(VideoCallLayout.Video) + wasConnectedHere = true } - if (prevVideoTileCount >= 1 && n === 0 && $videoCallLayout === VideoCallLayout.Split) { - videoCallLayout.set(VideoCallLayout.Chat) - } - prevVideoTileCount = n }) const shouldProtect = $relays.hasNip(url, 70) @@ -463,30 +466,45 @@ }) -
    +
    {#if h && voiceConnectedHere}
    + + {#if videoCallChatHidden} + + {/if}
    {#if showScrollButton} diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index c5db244b..310d2d0b 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -2,7 +2,6 @@ import {spec} from "@welshman/lib" import cx from "classnames" import {Track} from "livekit-client" - import {get} from "svelte/store" import Pin from "@assets/icons/pin.svg?dataurl" import Button from "@lib/components/Button.svelte" import Icon from "@lib/components/Icon.svelte" @@ -12,11 +11,7 @@ import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte" import { VideoCallLayout, - isDesktopLayout, toggleVideoPrimaryTile, - videoCallLayout, - videoCallViewportSync, - ViewportSize, videoPrimaryTileKey, currentCallSession, callTargetRoom, @@ -47,32 +42,13 @@ const {layout, mobile = false, url, h, class: className = ""}: Props = $props() - $effect(() => { - const currentLayout = isDesktopLayout.current ? ViewportSize.Desktop : ViewportSize.Mobile - const {previousLayout} = videoCallViewportSync - if (previousLayout === undefined) { - videoCallViewportSync.previousLayout = currentLayout - return - } - if (previousLayout === currentLayout) return - const p = get(videoCallLayout) - if (previousLayout === ViewportSize.Desktop && currentLayout === ViewportSize.Mobile) { - if (p === VideoCallLayout.Split) videoCallLayout.set(VideoCallLayout.Video) - } else if (previousLayout === ViewportSize.Mobile && currentLayout === ViewportSize.Desktop) { - if (p === VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Split) - } - videoCallViewportSync.previousLayout = currentLayout - }) - const isViewingCurrentCallRoom = $derived( $callTargetRoom?.url === url && $callTargetRoom?.h === h, ) const showVideoContent = $derived( isViewingCurrentCallRoom && - (mobile - ? layout === VideoCallLayout.Video - : layout === VideoCallLayout.Split || layout === VideoCallLayout.Video), + (layout === VideoCallLayout.Split || layout === VideoCallLayout.Video), ) const videoTiles = $derived.by(() => { @@ -219,6 +195,7 @@ {#snippet videoTile(tile: VideoTileData, layout: TileLayout)} {@const media = $mediaStateByIdentity(tile.liveKitIdentity)} + {@const label = labelFor(tile.liveKitIdentity, tile.source)}
    - {labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""} + {label}{tile.isLocal ? " (you)" : ""} {#if videoTiles.length > 1} {@const pinned = $videoPrimaryTileKey === tileKey(tile)}
    {#if participantPubkeys.length > 0} - {#if isActive} + {#if isActive && $participants.length <= ACTIVE_LIST_MAX} {#each $participants as p (participantKey(p as CallParticipant))} {@const media = $mediaStateByIdentity(p.liveKitIdentity)}
    diff --git a/src/app/components/VoiceRoomJoinDialog.svelte b/src/app/components/VoiceRoomJoinDialog.svelte index 8b0cafe4..fe78d20b 100644 --- a/src/app/components/VoiceRoomJoinDialog.svelte +++ b/src/app/components/VoiceRoomJoinDialog.svelte @@ -13,6 +13,7 @@ import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte" import {AbortError, TimeoutError} from "$lib/util" + import MicLevelMeter from "@app/components/MicLevelMeter.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte" import {displayRoom} from "@app/rooms" import {deriveCallParticipants, joinVoiceRoom, loadCallParticipants} from "@app/call" @@ -33,6 +34,7 @@ let audioInputs = $state([]) let selectedDeviceId = $state("") let startWithoutMic = $state(false) + let micError = $state(false) const loadDevices = async () => { if (!navigator.mediaDevices?.enumerateDevices) return @@ -49,7 +51,60 @@ void loadDevices() }) - const goBack = () => history.back() + // Live mic level meter so the user can confirm their selected microphone is + // actually picking up sound before joining, rather than finding out mid-call. + // The stream handle stays a plain local while only the track is reactive: the + // effect below both reads and writes it, and reading reactive state an effect + // also writes makes that effect depend on its own output, so it would re-run + // forever and cancel the in-flight getUserMedia every time. + let previewStream: MediaStream | undefined + let previewTrack = $state(undefined) + + const stopMicPreview = () => { + previewStream?.getTracks().forEach(t => t.stop()) + previewStream = undefined + previewTrack = undefined + } + + $effect(() => { + // Re-runs when selectedDeviceId or startWithoutMic changes. + void selectedDeviceId + const withoutMic = startWithoutMic + + stopMicPreview() + micError = false + + if (withoutMic || !navigator.mediaDevices?.getUserMedia) return + + let cancelled = false + const deviceId = selectedDeviceId + + void (async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: deviceId ? {deviceId: {exact: deviceId}} : true, + }) + if (cancelled) { + stream.getTracks().forEach(t => t.stop()) + return + } + previewStream = stream + previewTrack = stream.getAudioTracks()[0] + } catch { + if (!cancelled) micError = true + } + })() + + return () => { + cancelled = true + stopMicPreview() + } + }) + + const goBack = () => { + stopMicPreview() + history.back() + } const handleJoinError = (e: unknown) => { if (e instanceof AbortError) return @@ -62,6 +117,7 @@ } const joinVoice = async () => { + stopMicPreview() popModal() await joinVoiceRoom( url, @@ -121,6 +177,13 @@ {/snippet} + {#if !startWithoutMic} + + {/if}
    diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 88072da8..dfc81dfb 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -19,7 +19,6 @@ import Settings from "@assets/icons/settings.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" - import Spinner from "@lib/components/Spinner.svelte" import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte" import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte" import {rooms} from "@app/core" @@ -30,7 +29,6 @@ import {makeRoomPath} from "@app/routes" import { VideoCallLayout, - isDesktopLayout, toggleCamera, toggleScreenShare, videoCallLayout, @@ -98,24 +96,13 @@ const showChatButton = $derived($callState === CallState.Connected && isViewingCurrentVoiceRoom) - const isChatPanelActive = $derived( - showChatButton && - (isDesktopLayout.current - ? $videoCallLayout === VideoCallLayout.Split - : $videoCallLayout === VideoCallLayout.Chat), - ) + const isChatPanelActive = $derived(showChatButton && $videoCallLayout === VideoCallLayout.Split) const onChatToggle = () => { if (!showChatButton) return - if (isDesktopLayout.current) { - videoCallLayout.update(p => - p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split, - ) - } else { - videoCallLayout.update(p => - p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video, - ) - } + videoCallLayout.update(p => + p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split, + ) } const chatUnread = $derived( @@ -128,99 +115,114 @@ in:fly={{y: 60, duration: 350}} out:fly={{y: 60, duration: 250}} class="card card-sm flex flex-col gap-2"> -
    - - {#if showChatButton} - - {/if} -
    -
    + +
    {#if $callState === CallState.Joining} - {:else if $callState === CallState.Connected && $currentCallSession} {#if !Capacitor.isNativePlatform()} {/if} + {#if showChatButton} + + {/if} {:else} {/if}
    -- 2.45.2 From 72ee0c9ed6422aace4385c6efbeda45434ce31ee Mon Sep 17 00:00:00 2001 From: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Date: Mon, 10 Aug 2026 18:32:11 +0000 Subject: [PATCH 08/21] Reacquire mic after screen lock kills capture mid-call (#339) Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> --- src/app/call.ts | 47 +++++++++++++++++++++++++++++++++++++++ src/routes/+layout.svelte | 4 ++++ 2 files changed, 51 insertions(+) diff --git a/src/app/call.ts b/src/app/call.ts index 1a2d6ad8..3bbaae07 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -14,6 +14,7 @@ import { supportsAudioOutputSelection, type AudioCaptureOptions, } from "livekit-client" +import {App} from "@capacitor/app" import {derived, get, writable} from "svelte/store" import {first, not, nthEq, reject, uniqBy} from "@welshman/lib" import {makeHttpAuth, makeHttpAuthHeader, sortEventsDesc, tagSpec, tagValues} from "@welshman/util" @@ -353,6 +354,25 @@ export const switchCallActiveDevice = async ( } } +/** + * On mobile, locking the screen can suspend microphone capture without ever + * ending the underlying MediaStreamTrack: the local participant still looks + * connected and unmuted, but publishes silence until the track is manually + * reacquired. LiveKit only guards against this for tracks attached to a DOM + * element (i.e. video), so the mic needs the same treatment on foreground + * return. `App.addListener("appStateChange", ...)` fires from Capacitor's web + * fallback too, so this covers both native and browser tabs. + */ +export const syncCallAudioResume = () => { + const listener = App.addListener("appStateChange", ({isActive}) => { + if (isActive) void reacquireMicrophoneIfNeeded() + }) + + return () => { + listener.then(l => l.remove()) + } +} + export const resetVideoCallLayout = () => { videoCallLayout.set(VideoCallLayout.Chat) } @@ -539,6 +559,33 @@ const setUpMicrophone = async ( return muted } +const reacquireMicrophoneIfNeeded = async () => { + const session = get(currentCallSession) + if (!session || get(callMicMuted)) return + + const track = session.livekit.localParticipant.getTrackPublication( + Track.Source.Microphone, + )?.audioTrack + if (!track || track.isMuted || track.isUserProvided) return + + // Mirrors LiveKit's own (mobile-only, video-track-only) reacquisition + // check: a capture device that died silently still reports readyState + // "live", but the browser flips `muted`/`enabled` on the underlying + // MediaStreamTrack. Checking for actual silence instead would false- + // positive any time the user simply isn't talking. + const {mediaStreamTrack} = track + const needsReacquisition = + mediaStreamTrack.readyState !== "live" || mediaStreamTrack.muted || !mediaStreamTrack.enabled + if (!needsReacquisition) return + + try { + await track.restartTrack() + } catch { + // Best-effort: the user can still recover via mute/unmute or by + // rejoining if reacquiring the mic fails here. + } +} + const clearReconnectSchedule = () => { if (reconnectTimeout !== undefined) { clearTimeout(reconnectTimeout) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 08ad01ac..0096a4f1 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -37,6 +37,7 @@ import {Push} from "@app/push" import {onPushNotificationAction, pushState} from "@app/push/adapters/common" import {syncKeyboard} from "@app/keyboard" + import {syncCallAudioResume} from "@app/call" import {getPageTitle} from "@app/title" import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte" @@ -163,6 +164,9 @@ // Initialize keyboard state tracking unsubscribers.push(syncKeyboard()) + // Reacquire the mic if the screen locked mid-call and capture died + unsubscribers.push(syncCallAudioResume()) + // Subscribe to badge count for changes unsubscribers.push(notifications.syncBadges()) -- 2.45.2 From 8243f4d245be53c75b4e2a13924295d2ab214eaa Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 10 Aug 2026 15:09:41 -0700 Subject: [PATCH 09/21] Defer rendering a chat room for a frame --- src/app/components/RoomChat.svelte | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index 5aaabfcf..5703187e 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -454,14 +454,18 @@ const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent) onMount(() => { - start() + // Defer rendering until navigation finishes + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(start) + }) const unsubscribeActive = documentActive.subscribe(onActiveChange) return () => { - // Wrap in a closure to avoid calling a stale cleanup function cleanup?.() unsubscribeActive() + cancelAnimationFrame(frame) + document.removeEventListener("visibilitychange", onVisibilityChange) } }) -- 2.45.2 From d8dea9c4dd512efdc0ae1b737097f793d8bcddeb Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 11 Aug 2026 10:13:05 -0400 Subject: [PATCH 10/21] feat: Jitsi-style mixed-size tile grid sizing (PR-C1 rework) Replaces the uniform grid-sizing helper (computeGridSize -> rows/cols) with a Jitsi-inspired mixed-size layout algorithm (computeTileLayout). Key changes: - Tiles flex between 9:16 and 16:9 aspect ratio per row, matched to container dimensions - Rows can have different tile counts (e.g. [2,1] for 3 participants, [3,2] for 5, [4,3] for 7) - Last row with fewer tiles gets a wider (more landscape) aspect ratio while earlier rows stay more square-ish - CSS grid replaced with flex rows; each tile's aspect-ratio set via inline style (no more hardcoded aspect-video) - Container dimensions bound via bind:clientWidth/clientHeight - Dead {:else} branch (always-true useMultiGrid) removed - Overflow penalty, whitespace penalty, and balance penalty ensure reasonable layouts at all tile counts Per @matt's PR review feedback: read jitsi-meet source (react/features/video-layout/functions.web.ts, react/features/filmstrip/functions.web.ts) for the tile sizing approach. --- src/app/call.ts | 229 ++++++++++++++++++--- src/app/components/VideoCallContent.svelte | 41 ++-- 2 files changed, 224 insertions(+), 46 deletions(-) diff --git a/src/app/call.ts b/src/app/call.ts index 6b4a04f7..4776946e 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -31,39 +31,222 @@ export const LIVEKIT_PARTICIPANTS = 39004 export {supportsAudioOutputSelection} -export type GridSize = {rows: number; cols: number} +/** Aspect ratio constraints — tiles can flex between 9:16 (portrait) and 16:9 (landscape). */ +const TILE_ASPECT_MIN = 9 / 16 +const TILE_ASPECT_MAX = 16 / 9 +const TILE_GAP = 8 /** - * Given a tile count and available container dimensions, returns the optimal - * number of rows and columns to maximize tile size while maintaining ~16:9 - * cells. Handles 0 to 12+ tiles smoothly. + * A single row in the tile layout. */ -export const computeGridSize = ( +export type TileRowLayout = { + /** Number of tiles in this row */ + count: number + /** Aspect ratio (width/height) for tiles in this row */ + aspectRatio: number +} + +/** + * Describes a Jitsi-style mixed-size tile layout: tiles arranged in rows, + * each row may have a different tile count and aspect ratio. Tiles fill + * available width via flex, centered in the last row when it has fewer tiles. + */ +export type TileLayout = { + rows: TileRowLayout[] +} + +/** + * Generate all reasonable row-partitions of `n` tiles. + * Each partition is an array of tile counts per row, e.g. for n=3: + * [3], [2,1], [1,2], [1,1,1] + */ +const generatePartitions = (n: number): number[][] => { + const result: number[][] = [] + + // Single row + result.push([n]) + + // Balanced grids: partition by column count + for (let cols = 2; cols <= n; cols++) { + const rows = Math.ceil(n / cols) + const row: number[] = [] + let remaining = n + for (let r = 0; r < rows; r++) { + const tiles = r < rows - 1 ? cols : remaining + row.push(tiles) + remaining -= tiles + } + result.push(row) + } + + // All-single row (N rows of 1 tile each) + if (n >= 2) result.push(Array.from({length: n}).fill(1)) + + // Deduplicate + const seen = new Set() + return result.filter(r => { + const k = r.join(",") + if (seen.has(k)) return false + seen.add(k) + return true + }) +} + +/** + * Given a row partition, per-row aspect ratios, and available width, + * compute the total height of the layout (including gaps). + */ +const layoutHeight = ( + partition: number[], + aspectRatios: number[], + cw: number, + gap: number, +): number => { + let total = 0 + for (let i = 0; i < partition.length; i++) { + const n = partition[i] + const tileW = (cw - (n - 1) * gap) / n + total += tileW / aspectRatios[i] + } + return total + (partition.length - 1) * gap +} + +/** + * Total area of all tiles in the layout. + */ +const tileArea = (partition: number[], aspectRatios: number[], cw: number, gap: number): number => { + let area = 0 + for (let i = 0; i < partition.length; i++) { + const n = partition[i] + const tileW = (cw - (n - 1) * gap) / n + const tileH = tileW / aspectRatios[i] + area += n * tileW * tileH + } + return area +} + +/** + * Score a candidate layout. Rewards large tile area, penalizes overflow + * heavily, and penalizes extreme row-count imbalance. + */ +const layoutScore = (partition: number[], h: number, area: number, ch: number): number => { + const OVERFLOW_PENALTY = 100000 + const WHITESPACE_PENALTY = 20 + const BALANCE_PENALTY = 15000 + + // Penalize imbalance: max-min > 1 gets penalized + let imbalance = 0 + if (partition.length > 1) { + const max = Math.max(...partition) + const min = Math.min(...partition) + imbalance = Math.max(0, max - min - 1) * BALANCE_PENALTY + } + + if (h > ch) { + // Overflow: heavily penalized + return area - (h - ch) * OVERFLOW_PENALTY - imbalance + } + + // Fits: reward area, penalize whitespace and imbalance + return area - (ch - h) * WHITESPACE_PENALTY - imbalance +} + +/** + * Jitsi-style tile layout: tiles flex between 9:16 and 16:9 aspect ratio, + * arranged in rows where each row may have a different number of tiles. + * The algorithm picks the partition and aspect ratio(s) that maximize tile + * area while fitting within the container. The last row with fewer tiles + * gets a wider (more landscape) aspect ratio. + * + * For 3 participants on a portrait phone this produces e.g. stacked + * single tiles (1,1,1). For a squarish container it produces e.g. [2,1] + * with roughly square tiles on top and a wider tile on the bottom. + * For 6 participants on a landscape desktop it produces e.g. [3,3]. + */ +export const computeTileLayout = ( tileCount: number, containerWidth: number, containerHeight: number, -): GridSize => { - if (tileCount <= 0) return {rows: 0, cols: 0} - if (tileCount === 1) return {rows: 1, cols: 1} +): TileLayout | undefined => { + if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined + if (tileCount === 1) { + return {rows: [{count: 1, aspectRatio: TILE_ASPECT_MAX}]} + } - const TILE_ASPECT = 16 / 9 - let best: GridSize = {rows: 1, cols: tileCount} - let bestArea = 0 + const cw = containerWidth + const ch = containerHeight + const gap = TILE_GAP - // Guard against unmeasured container — fall back to a landscape-ish default - const w = containerWidth > 0 ? containerWidth : 800 - const h = containerHeight > 0 ? containerHeight : 600 + const partitions = generatePartitions(tileCount) - for (let cols = 1; cols <= tileCount; cols++) { - const rows = Math.ceil(tileCount / cols) - // Tile width constrained by both axes while maintaining 16:9 - const tileWidth = Math.min(w / cols, (h / rows) * TILE_ASPECT) - const tileHeight = tileWidth / TILE_ASPECT - const area = tileWidth * tileHeight + let best: TileLayout | undefined = undefined + let bestScore = -Infinity - if (area > bestArea) { - bestArea = area - best = {rows, cols} + for (const partition of partitions) { + const nRows = partition.length + + // --- Uniform pass --- + // Solve for aspect ratio that fills the container: + // totalHeight = sum(tileW_i) / aspect + (nRows-1)*gap = ch + // aspect = sum(tileW_i) / (ch - (nRows-1)*gap) + const sumW = partition.reduce((s, n) => s + (cw - (n - 1) * gap) / n, 0) + const availH = ch - (nRows - 1) * gap + const aspectUniform = + availH > 0 + ? Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, sumW / availH)) + : TILE_ASPECT_MAX + + const uniformRatios = partition.map(() => aspectUniform) + const hUniform = layoutHeight(partition, uniformRatios, cw, gap) + const areaUniform = tileArea(partition, uniformRatios, cw, gap) + const scoreUniform = layoutScore(partition, hUniform, areaUniform, ch) + + if (scoreUniform > bestScore) { + bestScore = scoreUniform + best = { + rows: partition.map(count => ({count, aspectRatio: aspectUniform})), + } + } + + // --- Mixed pass: only for partitions where the last row has fewer tiles --- + // Makes earlier rows more square-ish, last row wider (more landscape). + if (nRows > 1 && partition[nRows - 1] < partition[0]) { + const lastN = partition[nRows - 1] + const lastW = (cw - (lastN - 1) * gap) / lastN + const earlySlice = partition.slice(0, -1) + + // Try several early-aspect ratios relative to the uniform aspect + // to find a better fit without creating extreme per-row differences. + for (const factor of [0.9, 0.95, 1.0, 1.05, 1.1]) { + const aEarly = Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, aspectUniform * factor)) + + const earlyH = layoutHeight( + earlySlice, + earlySlice.map(() => aEarly), + cw, + gap, + ) + + const lastAvailH = ch - earlyH - gap + if (lastAvailH <= 0) continue // doesn't fit + + const aLast = Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, lastW / lastAvailH)) + + const mixedRatios = [...earlySlice.map(() => aEarly), aLast] + const hMixed = layoutHeight(partition, mixedRatios, cw, gap) + const areaMixed = tileArea(partition, mixedRatios, cw, gap) + const scoreMixed = layoutScore(partition, hMixed, areaMixed, ch) + + if (scoreMixed > bestScore) { + bestScore = scoreMixed + best = { + rows: partition.map((count, i) => ({ + count, + aspectRatio: i < nRows - 1 ? aEarly : aLast, + })), + } + } + } } } diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index e6966856..e0789f28 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -24,8 +24,8 @@ participantMediaState, pubkeyFromLiveKitIdentity, videoTrackRevision, - computeGridSize, - type GridSize, + computeTileLayout, + type TileLayout, } from "@app/call" import {profiles} from "@app/core" @@ -45,7 +45,7 @@ source: Track.Source.Camera | Track.Source.ScreenShare } - type TileLayout = "spotlight" | "default" | "strip" + type TileLayoutVariant = "spotlight" | "default" | "strip" const {layout, mobile = false, url, h, class: className = ""}: Props = $props() @@ -181,13 +181,8 @@ const useSpotlightLayout = $derived(primaryTile !== undefined) const useMultiGrid = $derived(!useSpotlightLayout) - const gridSize = $derived( - useMultiGrid ? computeGridSize(videoTiles.length, gridWidth, gridHeight) : {rows: 0, cols: 0}, - ) - const gridStyle = $derived( - gridSize.rows > 0 && gridSize.cols > 0 - ? `grid-template-columns: repeat(${gridSize.cols}, 1fr); grid-template-rows: repeat(${gridSize.rows}, 1fr)` - : "", + const tileLayout = $derived( + useMultiGrid ? computeTileLayout(videoTiles.length, gridWidth, gridHeight) : undefined, ) $effect(() => { @@ -228,13 +223,13 @@ ) -{#snippet videoTile(tile: VideoTileData, layout: TileLayout)} +{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)} {@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
    @@ -293,20 +288,20 @@
    {/if}
    - {:else if useMultiGrid} + {:else if useMultiGrid && tileLayout}
    - {#each videoTiles as tile (tileKey(tile))} - {@render videoTile(tile, "default")} - {/each} -
    - {:else} -
    - {#each videoTiles as tile (tileKey(tile))} - {@render videoTile(tile, "default")} + class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto"> + {#each tileLayout.rows as row, i} + {@const start = tileLayout.rows.slice(0, i).reduce((a, r) => a + r.count, 0)} +
    + {#each videoTiles.slice(start, start + row.count) as tile (tileKey(tile))} +
    + {@render videoTile(tile, "default")} +
    + {/each} +
    {/each}
    {/if} -- 2.45.2 From 1b1e0169ff3e4700b06c03f22f29122a67c3cba8 Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 11 Aug 2026 10:21:34 -0400 Subject: [PATCH 11/21] fix: two critical rendering bugs in mixed-size grid layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — chicken-and-egg gating: '{:else if useMultiGrid && tileLayout}' prevented the measuring container from ever mounting, because tileLayout is undefined while gridWidth/gridHeight are 0, and the bind:clientWidth/ clientHeight are on the same div. Fix: always mount the measuring container when useMultiGrid, gate only the rows with {#if tileLayout}. Bug 2 — collapsed tile height: 'default' tile class lost aspect-video but gained no replacement for intrinsic height. All children are absolutely positioned (VideoCallTile 'absolute inset-0', profile fallback, badges, label, pin button), so the div collapses to 0 height. Fix: add 'h-full' to the default tile class so it fills the aspect-ratio wrapper. --- src/app/components/VideoCallContent.svelte | 26 ++++++++++++---------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index e0789f28..bf776058 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -229,7 +229,7 @@ class={cx( "relative isolate overflow-hidden rounded-2xl shadow-sm", layout === "spotlight" && "min-h-0 flex-1", - layout === "default" && "min-h-0 w-full", + layout === "default" && "min-h-0 h-full w-full", layout === "strip" && "aspect-video w-44 shrink-0", tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-surface", )}> @@ -288,21 +288,23 @@
    {/if}
    - {:else if useMultiGrid && tileLayout} + {:else if useMultiGrid}
    - {#each tileLayout.rows as row, i} - {@const start = tileLayout.rows.slice(0, i).reduce((a, r) => a + r.count, 0)} -
    - {#each videoTiles.slice(start, start + row.count) as tile (tileKey(tile))} -
    - {@render videoTile(tile, "default")} -
    - {/each} -
    - {/each} + {#if tileLayout} + {#each tileLayout.rows as row, i} + {@const start = tileLayout.rows.slice(0, i).reduce((a, r) => a + r.count, 0)} +
    + {#each videoTiles.slice(start, start + row.count) as tile (tileKey(tile))} +
    + {@render videoTile(tile, "default")} +
    + {/each} +
    + {/each} + {/if}
    {/if} {:else} -- 2.45.2 From 4b1e209262fc406043e53284f52672ffa4481033 Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 11 Aug 2026 16:25:55 -0400 Subject: [PATCH 12/21] feat: uniform-size tile grid with flex-fill aspect (PR-C1 plan v2) Replaces the Jitsi-style mixed-row layout (~190 lines: partitions, mixed-size rows, per-row aspect, penalty scoring) with a simple uniform grid (~25 lines): all tiles identical px size, aspect flexes within [3:4 .. 16:9], column count chosen to maximize area via key ordering (overflow < waste < aspect closeness < -area). Key changes: - computeUniformGrid / UniformTileGrid replaces computeTileLayout/TileLayout - TILE_ASPECT_MIN widened from 9/16 to 3/4 per @matt - CSS grid with explicit px column/row tracks replaces flex rows - mx-auto centers the grid block; orphan rows have same-size tiles (never full-width giants) - Outer measuring container always mounted (bind:clientWidth/Height); inner grid gated on tileGrid - Each tile wrapper: overflow-hidden rounded-2xl (grid cell sizes it) - 'default' tile variant keeps min-h-0 h-full w-full (fills grid cell) Per @matt's approved plan v2: flex-fill single rule everywhere, no portrait special-casing, no mixed-size rows, uniform tiles, centered block. --- src/app/call.ts | 257 +++++---------------- src/app/components/VideoCallContent.svelte | 31 ++- 2 files changed, 73 insertions(+), 215 deletions(-) diff --git a/src/app/call.ts b/src/app/call.ts index 4776946e..918d3a0d 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -31,222 +31,81 @@ export const LIVEKIT_PARTICIPANTS = 39004 export {supportsAudioOutputSelection} -/** Aspect ratio constraints — tiles can flex between 9:16 (portrait) and 16:9 (landscape). */ -const TILE_ASPECT_MIN = 9 / 16 +/** Aspect ratio constraints — tiles can flex between 3:4 (portrait) and 16:9 (landscape). */ +const TILE_ASPECT_MIN = 3 / 4 const TILE_ASPECT_MAX = 16 / 9 const TILE_GAP = 8 /** - * A single row in the tile layout. + * Uniform-size tile grid: all tiles are identical px dimensions, arranged + * in a CSS grid. Column count is chosen to maximize tile area while + * respecting aspect ratio bounds [3:4 .. 16:9]. + * Orphan rows (fewer tiles than a full row) center at the same tile size. */ -export type TileRowLayout = { - /** Number of tiles in this row */ - count: number - /** Aspect ratio (width/height) for tiles in this row */ - aspectRatio: number +export type UniformTileGrid = { + cols: number + rows: number + tileWidth: number + tileHeight: number + aspect: number } /** - * Describes a Jitsi-style mixed-size tile layout: tiles arranged in rows, - * each row may have a different tile count and aspect ratio. Tiles fill - * available width via flex, centered in the last row when it has fewer tiles. + * Pick the column count that best fills the viewport. Prioritises: + * 1. No vertical overflow (avoids unnecessary scroll) + * 2. Minimal leftover whitespace (h-pad + v-pad) + * 3. Aspect ratio closest to 16:9 + * 4. Larger tiles */ -export type TileLayout = { - rows: TileRowLayout[] -} - -/** - * Generate all reasonable row-partitions of `n` tiles. - * Each partition is an array of tile counts per row, e.g. for n=3: - * [3], [2,1], [1,2], [1,1,1] - */ -const generatePartitions = (n: number): number[][] => { - const result: number[][] = [] - - // Single row - result.push([n]) - - // Balanced grids: partition by column count - for (let cols = 2; cols <= n; cols++) { - const rows = Math.ceil(n / cols) - const row: number[] = [] - let remaining = n - for (let r = 0; r < rows; r++) { - const tiles = r < rows - 1 ? cols : remaining - row.push(tiles) - remaining -= tiles - } - result.push(row) - } - - // All-single row (N rows of 1 tile each) - if (n >= 2) result.push(Array.from({length: n}).fill(1)) - - // Deduplicate - const seen = new Set() - return result.filter(r => { - const k = r.join(",") - if (seen.has(k)) return false - seen.add(k) - return true - }) -} - -/** - * Given a row partition, per-row aspect ratios, and available width, - * compute the total height of the layout (including gaps). - */ -const layoutHeight = ( - partition: number[], - aspectRatios: number[], - cw: number, - gap: number, -): number => { - let total = 0 - for (let i = 0; i < partition.length; i++) { - const n = partition[i] - const tileW = (cw - (n - 1) * gap) / n - total += tileW / aspectRatios[i] - } - return total + (partition.length - 1) * gap -} - -/** - * Total area of all tiles in the layout. - */ -const tileArea = (partition: number[], aspectRatios: number[], cw: number, gap: number): number => { - let area = 0 - for (let i = 0; i < partition.length; i++) { - const n = partition[i] - const tileW = (cw - (n - 1) * gap) / n - const tileH = tileW / aspectRatios[i] - area += n * tileW * tileH - } - return area -} - -/** - * Score a candidate layout. Rewards large tile area, penalizes overflow - * heavily, and penalizes extreme row-count imbalance. - */ -const layoutScore = (partition: number[], h: number, area: number, ch: number): number => { - const OVERFLOW_PENALTY = 100000 - const WHITESPACE_PENALTY = 20 - const BALANCE_PENALTY = 15000 - - // Penalize imbalance: max-min > 1 gets penalized - let imbalance = 0 - if (partition.length > 1) { - const max = Math.max(...partition) - const min = Math.min(...partition) - imbalance = Math.max(0, max - min - 1) * BALANCE_PENALTY - } - - if (h > ch) { - // Overflow: heavily penalized - return area - (h - ch) * OVERFLOW_PENALTY - imbalance - } - - // Fits: reward area, penalize whitespace and imbalance - return area - (ch - h) * WHITESPACE_PENALTY - imbalance -} - -/** - * Jitsi-style tile layout: tiles flex between 9:16 and 16:9 aspect ratio, - * arranged in rows where each row may have a different number of tiles. - * The algorithm picks the partition and aspect ratio(s) that maximize tile - * area while fitting within the container. The last row with fewer tiles - * gets a wider (more landscape) aspect ratio. - * - * For 3 participants on a portrait phone this produces e.g. stacked - * single tiles (1,1,1). For a squarish container it produces e.g. [2,1] - * with roughly square tiles on top and a wider tile on the bottom. - * For 6 participants on a landscape desktop it produces e.g. [3,3]. - */ -export const computeTileLayout = ( +export const computeUniformGrid = ( tileCount: number, - containerWidth: number, - containerHeight: number, -): TileLayout | undefined => { - if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined - if (tileCount === 1) { - return {rows: [{count: 1, aspectRatio: TILE_ASPECT_MAX}]} - } + cw: number, + ch: number, +): UniformTileGrid | undefined => { + if (tileCount <= 0 || cw <= 0 || ch <= 0) return undefined - const cw = containerWidth - const ch = containerHeight - const gap = TILE_GAP + let best: UniformTileGrid | undefined + let bestKey: number[] | undefined - const partitions = generatePartitions(tileCount) + for (let cols = 1; cols <= tileCount; cols++) { + const rows = Math.ceil(tileCount / cols) + const availH = ch - (rows - 1) * TILE_GAP + const widthLimit = (cw - (cols - 1) * TILE_GAP) / cols + if (availH <= 0 || widthLimit <= 0) continue - let best: TileLayout | undefined = undefined - let bestScore = -Infinity + const rowH = availH / rows + const fillAspect = widthLimit / rowH + const aspect = Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, fillAspect)) - for (const partition of partitions) { - const nRows = partition.length - - // --- Uniform pass --- - // Solve for aspect ratio that fills the container: - // totalHeight = sum(tileW_i) / aspect + (nRows-1)*gap = ch - // aspect = sum(tileW_i) / (ch - (nRows-1)*gap) - const sumW = partition.reduce((s, n) => s + (cw - (n - 1) * gap) / n, 0) - const availH = ch - (nRows - 1) * gap - const aspectUniform = - availH > 0 - ? Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, sumW / availH)) - : TILE_ASPECT_MAX - - const uniformRatios = partition.map(() => aspectUniform) - const hUniform = layoutHeight(partition, uniformRatios, cw, gap) - const areaUniform = tileArea(partition, uniformRatios, cw, gap) - const scoreUniform = layoutScore(partition, hUniform, areaUniform, ch) - - if (scoreUniform > bestScore) { - bestScore = scoreUniform - best = { - rows: partition.map(count => ({count, aspectRatio: aspectUniform})), - } + let tileW: number, tileH: number + if (fillAspect > TILE_ASPECT_MAX) { + // Too wide: cap at 16:9, tiles taller than row -> vertical overflow + tileW = widthLimit + tileH = widthLimit / TILE_ASPECT_MAX + } else if (fillAspect < TILE_ASPECT_MIN) { + // Too tall: cap at 3:4, tiles narrower than full width -> h-pad + tileH = rowH + tileW = rowH * TILE_ASPECT_MIN + } else { + // In range: fills both dims exactly + tileW = widthLimit + tileH = rowH } - // --- Mixed pass: only for partitions where the last row has fewer tiles --- - // Makes earlier rows more square-ish, last row wider (more landscape). - if (nRows > 1 && partition[nRows - 1] < partition[0]) { - const lastN = partition[nRows - 1] - const lastW = (cw - (lastN - 1) * gap) / lastN - const earlySlice = partition.slice(0, -1) + const bboxW = cols * tileW + (cols - 1) * TILE_GAP + const bboxH = rows * tileH + (rows - 1) * TILE_GAP + const vOverflow = Math.max(0, bboxH - ch) + const waste = Math.max(0, cw - bboxW) + Math.max(0, ch - bboxH) + const key = [ + Math.round(vOverflow), + Math.round(waste), + Number(Math.abs(aspect - TILE_ASPECT_MAX).toFixed(3)), + -Math.round(tileW * tileH), + ] - // Try several early-aspect ratios relative to the uniform aspect - // to find a better fit without creating extreme per-row differences. - for (const factor of [0.9, 0.95, 1.0, 1.05, 1.1]) { - const aEarly = Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, aspectUniform * factor)) - - const earlyH = layoutHeight( - earlySlice, - earlySlice.map(() => aEarly), - cw, - gap, - ) - - const lastAvailH = ch - earlyH - gap - if (lastAvailH <= 0) continue // doesn't fit - - const aLast = Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, lastW / lastAvailH)) - - const mixedRatios = [...earlySlice.map(() => aEarly), aLast] - const hMixed = layoutHeight(partition, mixedRatios, cw, gap) - const areaMixed = tileArea(partition, mixedRatios, cw, gap) - const scoreMixed = layoutScore(partition, hMixed, areaMixed, ch) - - if (scoreMixed > bestScore) { - bestScore = scoreMixed - best = { - rows: partition.map((count, i) => ({ - count, - aspectRatio: i < nRows - 1 ? aEarly : aLast, - })), - } - } - } + if (!bestKey || key < bestKey) { + bestKey = key + best = {cols, rows, tileWidth: tileW, tileHeight: tileH, aspect} } } diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index bf776058..e065cef9 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -24,8 +24,8 @@ participantMediaState, pubkeyFromLiveKitIdentity, videoTrackRevision, - computeTileLayout, - type TileLayout, + computeUniformGrid, + type UniformTileGrid, } from "@app/call" import {profiles} from "@app/core" @@ -181,8 +181,8 @@ const useSpotlightLayout = $derived(primaryTile !== undefined) const useMultiGrid = $derived(!useSpotlightLayout) - const tileLayout = $derived( - useMultiGrid ? computeTileLayout(videoTiles.length, gridWidth, gridHeight) : undefined, + const tileGrid = $derived( + useMultiGrid ? computeUniformGrid(videoTiles.length, gridWidth, gridHeight) : undefined, ) $effect(() => { @@ -292,18 +292,17 @@
    - {#if tileLayout} - {#each tileLayout.rows as row, i} - {@const start = tileLayout.rows.slice(0, i).reduce((a, r) => a + r.count, 0)} -
    - {#each videoTiles.slice(start, start + row.count) as tile (tileKey(tile))} -
    - {@render videoTile(tile, "default")} -
    - {/each} -
    - {/each} + class="min-h-0 flex-1 overflow-y-auto"> + {#if tileGrid} +
    + {#each videoTiles as tile (tileKey(tile))} +
    + {@render videoTile(tile, "default")} +
    + {/each} +
    {/if}
    {/if} -- 2.45.2 From 8bcc3ac918fbf656f444601faca7a32d0d58b0d8 Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 11 Aug 2026 16:30:51 -0400 Subject: [PATCH 13/21] =?UTF-8?q?fix:=20orphan=20centering=20=E2=80=94=20s?= =?UTF-8?q?witch=20inner=20grid=20to=20flex-wrap=20+=20justify-center?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS grid + mx-auto centers the whole block but not orphan tiles within their row (e.g. 5 tiles @1685x1342, the 2-tile row was left-aligned). Switched to display:flex; flex-wrap:wrap; justify-content:center with explicit width/height on each tile wrapper. This naturally centers every row, including orphan rows with fewer tiles. The computeUniformGrid algorithm is unchanged — still uniform px tile sizes with aspect flexing in [3:4..16:9]. --- src/app/components/VideoCallContent.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index e065cef9..b1597feb 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -295,10 +295,12 @@ class="min-h-0 flex-1 overflow-y-auto"> {#if tileGrid}
    + class="flex flex-wrap justify-center gap-2" + style={`max-width: ${tileGrid.cols * tileGrid.tileWidth + (tileGrid.cols - 1) * 8}px`}> {#each videoTiles as tile (tileKey(tile))} -
    +
    {@render videoTile(tile, "default")}
    {/each} -- 2.45.2 From 2aca6c539327e57fc7d57124d5b2887011a3ed2a Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 11 Aug 2026 16:35:17 -0400 Subject: [PATCH 14/21] fix: add mx-auto to flex container for grid block centering --- src/app/components/VideoCallContent.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index b1597feb..b725682f 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -295,7 +295,7 @@ class="min-h-0 flex-1 overflow-y-auto"> {#if tileGrid}
    {#each videoTiles as tile (tileKey(tile))}
    Date: Tue, 11 Aug 2026 16:51:30 -0400 Subject: [PATCH 15/21] fix: three bugs in computeUniformGrid sizing and scoring 1. Array-comparison bug (CRITICAL): 'key < bestKey' coerces arrays to comma-joined strings, so [1668,...] < [23,...] is TRUE ('1'<'2'). Replaced with explicit numeric lexicographic compare (compareKeys). Root cause of @matt's report: n=2 @1920x500 picked 1x2 stacked with 1668px overflow instead of 2x1 side-by-side fitting nearly exactly. 2. { + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return 0 +} + /** * Pick the column count that best fills the viewport. Prioritises: * 1. No vertical overflow (avoids unnecessary scroll) - * 2. Minimal leftover whitespace (h-pad + v-pad) - * 3. Aspect ratio closest to 16:9 - * 4. Larger tiles + * 2. No horizontal overflow (avoids sideways clip/scroll) + * 3. Minimal leftover whitespace (h-pad + v-pad) + * 4. Aspect ratio closest to 16:9 + * 5. Larger tiles */ export const computeUniformGrid = ( tileCount: number, @@ -83,9 +93,9 @@ export const computeUniformGrid = ( tileW = widthLimit tileH = widthLimit / TILE_ASPECT_MAX } else if (fillAspect < TILE_ASPECT_MIN) { - // Too tall: cap at 3:4, tiles narrower than full width -> h-pad - tileH = rowH - tileW = rowH * TILE_ASPECT_MIN + // Too tall: cap at 3:4, tiles shorter than row -> v-pad (letterbox) + tileW = widthLimit + tileH = widthLimit / TILE_ASPECT_MIN } else { // In range: fills both dims exactly tileW = widthLimit @@ -95,15 +105,17 @@ export const computeUniformGrid = ( const bboxW = cols * tileW + (cols - 1) * TILE_GAP const bboxH = rows * tileH + (rows - 1) * TILE_GAP const vOverflow = Math.max(0, bboxH - ch) + const hOverflow = Math.max(0, bboxW - cw) const waste = Math.max(0, cw - bboxW) + Math.max(0, ch - bboxH) const key = [ Math.round(vOverflow), + Math.round(hOverflow), Math.round(waste), Number(Math.abs(aspect - TILE_ASPECT_MAX).toFixed(3)), -Math.round(tileW * tileH), ] - if (!bestKey || key < bestKey) { + if (!bestKey || compareKeys(key, bestKey) < 0) { bestKey = key best = {cols, rows, tileWidth: tileW, tileHeight: tileH, aspect} } -- 2.45.2 From 90b9bda329771cddb63aee6773597e294cc73d94 Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 12 Aug 2026 09:43:17 -0400 Subject: [PATCH 16/21] feat: Jitsi-style adaptive grid with mixed row sizes Complete rewrite of the tile grid sizing algorithm addressing all review feedback from @matt across both reviews. Review #1 (Jitsi-style approach): - Tiles flex between 3:4 (portrait) and arbitrarily wide (no upper clamp on aspect ratio) to avoid overflow. - Mixed row sizes: when the last row is partial, those tiles are wider (16:9 'match heights') while full rows use square-ish tiles, creating a cohesive Jitsi-style look. - Example: 3 participants = 2 square-ish tiles on top + 1 centered 16:9 tile on bottom. Review #2 (n=2 half-off-screen bug + minimum size policy): - FIX: No upper-bound clamping on aspect ratio - tiles can be wider than 16:9 when the container is very wide. At n=2 on a wide desktop, tiles are 16:9 (889x500) with side whitespace instead of overflowing. - MIN_TILE_HEIGHT=120: only allows overflow (scroll) when all tiles fall below this threshold. At n=2 tiles are always fully visible. - For n=6-12 on a portrait phone, tiles shrink to fit within aspect bounds with no scroll. Inline code review feedback: - Full words everywhere: containerWidth, columnCount, tileWidth, tileHeight, aspectRatio, etc. - No 'key' naming: replaced with LayoutScore struct with named fields - Type renamed from UniformTileGrid to AdaptiveTileGrid + TileRow - No 'compareKeys' - replaced with compareScores on named fields Scoring: overflow penalty -> min-height penalty -> tile area -> whitespace -> aspect deviation. --- src/app/call.ts | 324 +++++++++++++++++---- src/app/components/VideoCallContent.svelte | 26 +- 2 files changed, 276 insertions(+), 74 deletions(-) diff --git a/src/app/call.ts b/src/app/call.ts index 2cbb2f07..86fb1033 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -31,93 +31,291 @@ export const LIVEKIT_PARTICIPANTS = 39004 export {supportsAudioOutputSelection} -/** Aspect ratio constraints — tiles can flex between 3:4 (portrait) and 16:9 (landscape). */ -const TILE_ASPECT_MIN = 3 / 4 -const TILE_ASPECT_MAX = 16 / 9 +/** + * Aspect ratio constraints — tiles can flex between 3:4 (portrait) + * and 16:9 (landscape). Tiles are ALLOWED to be wider than 16:9 + * when the container is very wide, to prevent overflow (review #2 + * bug: n=2 second tile half off screen). The lower bound (3:4) + * prevents tiles from being too tall. + */ +const TILE_ASPECT_PORTRAIT = 3 / 4 +const TILE_ASPECT_LANDSCAPE = 16 / 9 const TILE_GAP = 8 /** - * Uniform-size tile grid: all tiles are identical px dimensions, arranged - * in a CSS grid. Column count is chosen to maximize tile area while - * respecting aspect ratio bounds [3:4 .. 16:9]. - * Orphan rows (fewer tiles than a full row) center at the same tile size. + * Minimum pixel height for a tile before we allow the grid to + * overflow (scroll) instead of forcing tiles into portrait mode. + * Calibrated so ~6-8 tiles on a standard portrait phone fit + * without scrolling; beyond that, scroll is acceptable. */ -export type UniformTileGrid = { - cols: number - rows: number +const MIN_TILE_HEIGHT = 120 + +/** + * A single row in an adaptive tile grid: tiles in this row share + * the same dimensions but may differ from other rows. + */ +export type TileRow = { + columnCount: number tileWidth: number tileHeight: number - aspect: number + aspectRatio: number } /** -/** Numeric lexicographic comparison for scoring arrays. */ -const compareKeys = (a: number[], b: number[]): number => { - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return a[i] - b[i] + * Adaptive tile grid: rows can have different tile sizes, allowing + * a Jitsi-style layout where tiles flex between 3:4 and 16:9 and + * partial rows use wider tiles to fill whitespace. + * + * Example (3 tiles, 2 columns): + * row 0: [ square ] [ square ] + * row 1: [ 16:9 ] (centered) + */ +export type AdaptiveTileGrid = { + rows: TileRow[] + totalWidth: number + totalHeight: number + /** True when the grid overflows — tiles are below MIN_TILE_HEIGHT */ + overflows: boolean +} + +/** + * Score for comparing candidate layouts. Lower is better. + * Uses named fields instead of opaque array "keys" (per review feedback). + */ +type LayoutScore = { + /** Penalty for vertical overflow: 0 if none, else huge */ + verticalOverflowPenalty: number + /** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */ + minTileHeightPenalty: number + /** Negative tile area (we want to maximize area, so negate) */ + negativeArea: number + /** Leftover whitespace (underflow) in px */ + whitespace: number + /** Average absolute deviation from 16:9 across all tiles */ + aspectDeviation: number +} + +const compareScores = (a: LayoutScore, b: LayoutScore): number => { + const fields: (keyof LayoutScore)[] = [ + "verticalOverflowPenalty", + "minTileHeightPenalty", + "negativeArea", + "whitespace", + "aspectDeviation", + ] + for (const field of fields) { + if (a[field] !== b[field]) return a[field] - b[field] } return 0 } /** - * Pick the column count that best fills the viewport. Prioritises: - * 1. No vertical overflow (avoids unnecessary scroll) - * 2. No horizontal overflow (avoids sideways clip/scroll) - * 3. Minimal leftover whitespace (h-pad + v-pad) - * 4. Aspect ratio closest to 16:9 - * 5. Larger tiles + * Build a single-row TileRow for the uniform case where all rows + * have the same number of tiles. No upper-bound clamping on aspect + * ratio — tiles can be wider than 16:9 to avoid overflow. */ -export const computeUniformGrid = ( +const buildUniformRow = ( + columnCount: number, + containerWidth: number, + containerHeight: number, + rowCount: number, +): TileRow => { + const gap = TILE_GAP + const availableHeight = containerHeight - (rowCount - 1) * gap + const rowHeight = availableHeight / rowCount + const tileWidth = (containerWidth - (columnCount - 1) * gap) / columnCount + const fillAspect = tileWidth / rowHeight + // No upper bound — allow wider than 16:9 to avoid overflow + const aspectRatio = Math.max(TILE_ASPECT_PORTRAIT, fillAspect) + const tileHeight = tileWidth / aspectRatio + return {columnCount, tileWidth, tileHeight, aspectRatio} +} + +/** + * Build a mixed-size layout candidate for a given column count. + * Uses a "match heights" approach: the last (partial) row is at 16:9 + * (per review #1: "single centered 16:9 tile") and full-row tiles + * match that same height. This creates a cohesive Jitsi-style look + * where all tiles in all rows are the same height, with different + * widths per row (full-row tiles are narrower/squarer, last-row + * tiles are wider). + * + * If the full-row aspect would go below the portrait bound (3:4), + * the height diverges: full rows clamp to 3:4 portrait. + */ +const buildMixedCandidate = ( tileCount: number, - cw: number, - ch: number, -): UniformTileGrid | undefined => { - if (tileCount <= 0 || cw <= 0 || ch <= 0) return undefined + columnCount: number, + containerWidth: number, + containerHeight: number, +): AdaptiveTileGrid | undefined => { + const gap = TILE_GAP + const fullRowCount = Math.floor(tileCount / columnCount) + const remainder = tileCount % columnCount + const totalRowCount = fullRowCount + 1 - let best: UniformTileGrid | undefined - let bestKey: number[] | undefined + const tileWidthFull = (containerWidth - (columnCount - 1) * gap) / columnCount + if (tileWidthFull <= 0) return undefined - for (let cols = 1; cols <= tileCount; cols++) { - const rows = Math.ceil(tileCount / cols) - const availH = ch - (rows - 1) * TILE_GAP - const widthLimit = (cw - (cols - 1) * TILE_GAP) / cols - if (availH <= 0 || widthLimit <= 0) continue + const tileWidthLast = (containerWidth - (remainder - 1) * gap) / remainder + if (tileWidthLast <= 0) return undefined - const rowH = availH / rows - const fillAspect = widthLimit / rowH - const aspect = Math.max(TILE_ASPECT_MIN, Math.min(TILE_ASPECT_MAX, fillAspect)) + // Last row at 16:9 (widest aspect, per review: "single centered 16:9 tile") + const lastRowAspect = TILE_ASPECT_LANDSCAPE + const commonHeight = tileWidthLast / lastRowAspect - let tileW: number, tileH: number - if (fillAspect > TILE_ASPECT_MAX) { - // Too wide: cap at 16:9, tiles taller than row -> vertical overflow - tileW = widthLimit - tileH = widthLimit / TILE_ASPECT_MAX - } else if (fillAspect < TILE_ASPECT_MIN) { - // Too tall: cap at 3:4, tiles shorter than row -> v-pad (letterbox) - tileW = widthLimit - tileH = widthLimit / TILE_ASPECT_MIN + // Full-row tile that matches the common height + const fullRowAspect = tileWidthFull / commonHeight + + let tileHeightFull: number + let tileHeightLast: number + let fullRowAspectRatio: number + let lastRowAspectRatio: number + + if (fullRowAspect >= TILE_ASPECT_PORTRAIT) { + // Full-row aspect is within bounds — match heights cleanly + tileHeightFull = commonHeight + tileHeightLast = commonHeight + fullRowAspectRatio = fullRowAspect + lastRowAspectRatio = lastRowAspect + } else { + // Full rows would be too portrait — clamp to portrait bound + fullRowAspectRatio = TILE_ASPECT_PORTRAIT + tileHeightFull = tileWidthFull / fullRowAspectRatio + tileHeightLast = commonHeight + lastRowAspectRatio = lastRowAspect + } + + const totalHeight = fullRowCount * tileHeightFull + tileHeightLast + (totalRowCount - 1) * gap + const totalWidth = Math.max( + columnCount * tileWidthFull + (columnCount - 1) * gap, + remainder * tileWidthLast + (remainder - 1) * gap, + ) + + const rows: TileRow[] = [] + for (let r = 0; r < fullRowCount; r++) { + rows.push({ + columnCount, + tileWidth: tileWidthFull, + tileHeight: tileHeightFull, + aspectRatio: fullRowAspectRatio, + }) + } + rows.push({ + columnCount: remainder, + tileWidth: tileWidthLast, + tileHeight: tileHeightLast, + aspectRatio: lastRowAspectRatio, + }) + + return { + rows, + totalWidth, + totalHeight, + overflows: totalHeight > containerHeight, + } +} + +/** + * Compute an adaptive tile grid using Jitsi-style mixed row sizes. + * + * - Tiles flex between 3:4 (portrait) and wider than 16:9 if needed + * to avoid overflow. No upper bound on aspect ratio — tiles can + * be wider than 16:9 when the container is very wide. + * - When the last row is partial (fewer tiles than a full row), + * those tiles are wider (16:9) while full rows use square-ish + * tiles, filling whitespace per review #1. + * - Only allows overflow (scroll) when tiles would be below + * MIN_TILE_HEIGHT (review #2: ~6-8 tiles on portrait phone). + * + * Prioritises: + * 1. No overflow (tiles shrink to fit within aspect bounds) + * 2. Minimal whitespace + * 3. Aspect ratios close to 16:9 + * 4. Larger tiles + */ +export const computeAdaptiveGrid = ( + tileCount: number, + containerWidth: number, + containerHeight: number, +): AdaptiveTileGrid | undefined => { + if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined + + let best: AdaptiveTileGrid | undefined + let bestScore: LayoutScore | undefined + + for (let columnCount = 1; columnCount <= tileCount; columnCount++) { + const fullRowCount = Math.floor(tileCount / columnCount) + const remainder = tileCount % columnCount + const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0) + + let candidate: AdaptiveTileGrid | undefined + + if (remainder === 0) { + // Uniform layout — all rows identical + const row = buildUniformRow(columnCount, containerWidth, containerHeight, totalRowCount) + const totalHeight = totalRowCount * row.tileHeight + (totalRowCount - 1) * TILE_GAP + const totalWidth = columnCount * row.tileWidth + (columnCount - 1) * TILE_GAP + const rows: TileRow[] = [] + for (let r = 0; r < totalRowCount; r++) { + rows.push({...row}) + } + candidate = { + rows, + totalWidth, + totalHeight, + overflows: totalHeight > containerHeight, + } } else { - // In range: fills both dims exactly - tileW = widthLimit - tileH = rowH + // Mixed layout — last row has fewer tiles, make them wider + candidate = buildMixedCandidate(tileCount, columnCount, containerWidth, containerHeight) } - const bboxW = cols * tileW + (cols - 1) * TILE_GAP - const bboxH = rows * tileH + (rows - 1) * TILE_GAP - const vOverflow = Math.max(0, bboxH - ch) - const hOverflow = Math.max(0, bboxW - cw) - const waste = Math.max(0, cw - bboxW) + Math.max(0, ch - bboxH) - const key = [ - Math.round(vOverflow), - Math.round(hOverflow), - Math.round(waste), - Number(Math.abs(aspect - TILE_ASPECT_MAX).toFixed(3)), - -Math.round(tileW * tileH), - ] + if (!candidate) continue - if (!bestKey || compareKeys(key, bestKey) < 0) { - bestKey = key - best = {cols, rows, tileWidth: tileW, tileHeight: tileH, aspect} + // Compute score + const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight) + const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth) + const whitespace = + Math.max(0, containerWidth - candidate.totalWidth) + + Math.max(0, containerHeight - candidate.totalHeight) + const aspectDeviation = + candidate.rows.reduce( + (sum, row) => sum + Math.abs(row.aspectRatio - TILE_ASPECT_LANDSCAPE), + 0, + ) / candidate.rows.length + const totalArea = candidate.rows.reduce( + (sum, row) => sum + row.tileWidth * row.tileHeight * row.columnCount, + 0, + ) + + // For overflow: if tiles are above MIN_TILE_HEIGHT, overflow is + // unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT, + // overflow is acceptable (tiles are already too small to shrink further). + const minTileHeightInCandidate = Math.min(...candidate.rows.map(r => r.tileHeight)) + const overflowPenalty = + verticalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT + ? verticalOverflow * 1_000_000 + : verticalOverflow + + // Min-tile-height penalty: even when fitting, discourage layouts + // where tiles are below the minimum acceptable size. + const minTileHeightPenalty = + minTileHeightInCandidate < MIN_TILE_HEIGHT + ? (MIN_TILE_HEIGHT - minTileHeightInCandidate) * 1000 + : 0 + + const score: LayoutScore = { + verticalOverflowPenalty: overflowPenalty, + minTileHeightPenalty, + negativeArea: -totalArea, + whitespace, + aspectDeviation, + } + + if (!bestScore || compareScores(score, bestScore) < 0) { + bestScore = score + best = candidate } } diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index b725682f..0c392ca6 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -24,8 +24,8 @@ participantMediaState, pubkeyFromLiveKitIdentity, videoTrackRevision, - computeUniformGrid, - type UniformTileGrid, + computeAdaptiveGrid, + type AdaptiveTileGrid, } from "@app/call" import {profiles} from "@app/core" @@ -181,8 +181,8 @@ const useSpotlightLayout = $derived(primaryTile !== undefined) const useMultiGrid = $derived(!useSpotlightLayout) - const tileGrid = $derived( - useMultiGrid ? computeUniformGrid(videoTiles.length, gridWidth, gridHeight) : undefined, + const tileGrid = $derived( + useMultiGrid ? computeAdaptiveGrid(videoTiles.length, gridWidth, gridHeight) : undefined, ) $effect(() => { @@ -294,14 +294,18 @@ bind:clientHeight={gridHeight} class="min-h-0 flex-1 overflow-y-auto"> {#if tileGrid} -
    - {#each videoTiles as tile (tileKey(tile))} +
    + {#each tileGrid.rows as row, rowIndex}
    - {@render videoTile(tile, "default")} + class="flex flex-wrap justify-center gap-2" + style={`max-width: ${row.columnCount * row.tileWidth + (row.columnCount - 1) * 8}px`}> + {#each videoTiles.slice(rowIndex * row.columnCount, rowIndex * row.columnCount + row.columnCount) as tile (tileKey(tile))} +
    + {@render videoTile(tile, "default")} +
    + {/each}
    {/each}
    -- 2.45.2 From 0d9e511f4125dbdcca1e58d823247240a0eff32e Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 12 Aug 2026 09:49:24 -0400 Subject: [PATCH 17/21] fix: mixed-row rendering bug and Jitsi-style algorithm refinements --- src/app/call.ts | 53 +++++++++++----------- src/app/components/VideoCallContent.svelte | 5 +- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/app/call.ts b/src/app/call.ts index 86fb1033..c16ab812 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -85,6 +85,8 @@ export type AdaptiveTileGrid = { type LayoutScore = { /** Penalty for vertical overflow: 0 if none, else huge */ verticalOverflowPenalty: number + /** Penalty for horizontal overflow: 0 if none, else huge */ + horizontalOverflowPenalty: number /** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */ minTileHeightPenalty: number /** Negative tile area (we want to maximize area, so negate) */ @@ -98,6 +100,7 @@ type LayoutScore = { const compareScores = (a: LayoutScore, b: LayoutScore): number => { const fields: (keyof LayoutScore)[] = [ "verticalOverflowPenalty", + "horizontalOverflowPenalty", "minTileHeightPenalty", "negativeArea", "whitespace", @@ -125,8 +128,8 @@ const buildUniformRow = ( const rowHeight = availableHeight / rowCount const tileWidth = (containerWidth - (columnCount - 1) * gap) / columnCount const fillAspect = tileWidth / rowHeight - // No upper bound — allow wider than 16:9 to avoid overflow - const aspectRatio = Math.max(TILE_ASPECT_PORTRAIT, fillAspect) + // Cap at 16:9 to avoid horizontally stretching video (review feedback) + const aspectRatio = Math.max(TILE_ASPECT_PORTRAIT, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect)) const tileHeight = tileWidth / aspectRatio return {columnCount, tileWidth, tileHeight, aspectRatio} } @@ -160,31 +163,21 @@ const buildMixedCandidate = ( const tileWidthLast = (containerWidth - (remainder - 1) * gap) / remainder if (tileWidthLast <= 0) return undefined - // Last row at 16:9 (widest aspect, per review: "single centered 16:9 tile") - const lastRowAspect = TILE_ASPECT_LANDSCAPE - const commonHeight = tileWidthLast / lastRowAspect + // Find a common height that fits both row types and container + const maxPossibleHeight = Math.min( + tileWidthFull / TILE_ASPECT_PORTRAIT, + tileWidthLast / TILE_ASPECT_PORTRAIT, + (containerHeight - (totalRowCount - 1) * gap) / totalRowCount, + ) - // Full-row tile that matches the common height - const fullRowAspect = tileWidthFull / commonHeight + // Target 16:9 for the last row if possible + const targetHeight = tileWidthLast / TILE_ASPECT_LANDSCAPE + const commonHeight = Math.min(maxPossibleHeight, targetHeight) - let tileHeightFull: number - let tileHeightLast: number - let fullRowAspectRatio: number - let lastRowAspectRatio: number - - if (fullRowAspect >= TILE_ASPECT_PORTRAIT) { - // Full-row aspect is within bounds — match heights cleanly - tileHeightFull = commonHeight - tileHeightLast = commonHeight - fullRowAspectRatio = fullRowAspect - lastRowAspectRatio = lastRowAspect - } else { - // Full rows would be too portrait — clamp to portrait bound - fullRowAspectRatio = TILE_ASPECT_PORTRAIT - tileHeightFull = tileWidthFull / fullRowAspectRatio - tileHeightLast = commonHeight - lastRowAspectRatio = lastRowAspect - } + const tileHeightFull = commonHeight + const tileHeightLast = commonHeight + const fullRowAspectRatio = tileWidthFull / commonHeight + const lastRowAspectRatio = tileWidthLast / commonHeight const totalHeight = fullRowCount * tileHeightFull + tileHeightLast + (totalRowCount - 1) * gap const totalWidth = Math.max( @@ -293,11 +286,16 @@ export const computeAdaptiveGrid = ( // unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT, // overflow is acceptable (tiles are already too small to shrink further). const minTileHeightInCandidate = Math.min(...candidate.rows.map(r => r.tileHeight)) - const overflowPenalty = + const verticalOverflowPenalty = verticalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT ? verticalOverflow * 1_000_000 : verticalOverflow + const horizontalOverflowPenalty = + horizontalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT + ? horizontalOverflow * 1_000_000 + : horizontalOverflow + // Min-tile-height penalty: even when fitting, discourage layouts // where tiles are below the minimum acceptable size. const minTileHeightPenalty = @@ -306,7 +304,8 @@ export const computeAdaptiveGrid = ( : 0 const score: LayoutScore = { - verticalOverflowPenalty: overflowPenalty, + verticalOverflowPenalty, + horizontalOverflowPenalty, minTileHeightPenalty, negativeArea: -totalArea, whitespace, diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 0c392ca6..72a180e7 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -296,10 +296,13 @@ {#if tileGrid}
    {#each tileGrid.rows as row, rowIndex} + {@const offset = tileGrid.rows + .slice(0, rowIndex) + .reduce((sum, r) => sum + r.columnCount, 0)}
    - {#each videoTiles.slice(rowIndex * row.columnCount, rowIndex * row.columnCount + row.columnCount) as tile (tileKey(tile))} + {#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
    -- 2.45.2 From 1df2b8f7b8dd6ef9d16df18f908d5631977bb5d1 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 12 Aug 2026 12:49:03 -0400 Subject: [PATCH 18/21] fix: video grid wrapping and mobile height constraints - flex-nowrap on grid rows prevents subpixel rounding from wrapping the second tile onto a new row (caused 'half off screen' on desktop) - flex flex-col on mobile wrapper gives the grid container a constrained height so it renders at narrow viewport widths --- src/app/components/VideoCallContent.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 9f5b40a8..1692e415 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -278,7 +278,7 @@ .slice(0, rowIndex) .reduce((sum, r) => sum + r.columnCount, 0)}
    {#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
    {#if mobile}
    -
    +
    {@render videoPanelBody()}
    -- 2.45.2 From f099e979810fcf78f6d95a0522e0684d00948825 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 12 Aug 2026 13:06:12 -0400 Subject: [PATCH 19/21] fix: use 1:1 min aspect on landscape, scrollbar-gutter to prevent flicker - minAspect=1:1 (square) on landscape containers, 3:4 on portrait - scrollbar-gutter:stable on grid prevents scrollbar feedback loop --- src/app/call.ts | 31 +++++++++++++++++----- src/app/components/VideoCallContent.svelte | 2 +- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/app/call.ts b/src/app/call.ts index 82bf0428..84ec1960 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -122,14 +122,14 @@ const buildUniformRow = ( containerWidth: number, containerHeight: number, rowCount: number, + minAspect: number, ): TileRow => { const gap = TILE_GAP const availableHeight = containerHeight - (rowCount - 1) * gap const rowHeight = availableHeight / rowCount const tileWidth = (containerWidth - (columnCount - 1) * gap) / columnCount const fillAspect = tileWidth / rowHeight - // Cap at 16:9 to avoid horizontally stretching video (review feedback) - const aspectRatio = Math.max(TILE_ASPECT_PORTRAIT, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect)) + const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect)) const tileHeight = tileWidth / aspectRatio return {columnCount, tileWidth, tileHeight, aspectRatio} } @@ -151,6 +151,7 @@ const buildMixedCandidate = ( columnCount: number, containerWidth: number, containerHeight: number, + minAspect: number, ): AdaptiveTileGrid | undefined => { const gap = TILE_GAP const fullRowCount = Math.floor(tileCount / columnCount) @@ -163,10 +164,9 @@ const buildMixedCandidate = ( const tileWidthLast = (containerWidth - (remainder - 1) * gap) / remainder if (tileWidthLast <= 0) return undefined - // Find a common height that fits both row types and container const maxPossibleHeight = Math.min( - tileWidthFull / TILE_ASPECT_PORTRAIT, - tileWidthLast / TILE_ASPECT_PORTRAIT, + tileWidthFull / minAspect, + tileWidthLast / minAspect, (containerHeight - (totalRowCount - 1) * gap) / totalRowCount, ) @@ -234,6 +234,11 @@ export const computeAdaptiveGrid = ( ): AdaptiveTileGrid | undefined => { if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined + // On landscape viewports, bound tiles at 1:1 (square) so they don't + // become tall and narrow. On portrait viewports, allow 3:4 so more + // tiles fit on a phone screen. + const minAspect = containerWidth > containerHeight ? 1 : TILE_ASPECT_PORTRAIT + let best: AdaptiveTileGrid | undefined let bestScore: LayoutScore | undefined @@ -246,7 +251,13 @@ export const computeAdaptiveGrid = ( if (remainder === 0) { // Uniform layout — all rows identical - const row = buildUniformRow(columnCount, containerWidth, containerHeight, totalRowCount) + const row = buildUniformRow( + columnCount, + containerWidth, + containerHeight, + totalRowCount, + minAspect, + ) const totalHeight = totalRowCount * row.tileHeight + (totalRowCount - 1) * TILE_GAP const totalWidth = columnCount * row.tileWidth + (columnCount - 1) * TILE_GAP const rows: TileRow[] = [] @@ -261,7 +272,13 @@ export const computeAdaptiveGrid = ( } } else { // Mixed layout — last row has fewer tiles, make them wider - candidate = buildMixedCandidate(tileCount, columnCount, containerWidth, containerHeight) + candidate = buildMixedCandidate( + tileCount, + columnCount, + containerWidth, + containerHeight, + minAspect, + ) } if (!candidate) continue diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 1692e415..42f57a40 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -270,7 +270,7 @@
    + class="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]"> {#if tileGrid}
    {#each tileGrid.rows as row, rowIndex} -- 2.45.2 From 13f9a5c13d369bdf1114d041e6426d499a23b3ee Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 12 Aug 2026 13:09:12 -0400 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20grid=20algorithm=20=E2=80=94=204:3?= =?UTF-8?q?=20breakpoint,=2016:9=20cap,=20uniform=20tile=20sizes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lower landscape breakpoint from width>height to width/height>=4:3 - fitTile helper caps tiles at 16:9 and shrinks to fit both dimensions, preventing overflow on ultrawide containers - Mixed candidate uses uniform tile sizes for all rows; partial rows are centered with the same tile dimensions as full rows --- src/app/call.ts | 113 +++++++++++++++++++----------------------------- 1 file changed, 44 insertions(+), 69 deletions(-) diff --git a/src/app/call.ts b/src/app/call.ts index 84ec1960..caaff464 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -62,13 +62,12 @@ export type TileRow = { } /** - * Adaptive tile grid: rows can have different tile sizes, allowing - * a Jitsi-style layout where tiles flex between 3:4 and 16:9 and - * partial rows use wider tiles to fill whitespace. + * Adaptive tile grid: all tiles share the same dimensions. Full rows + * fill the container width; partial rows are centered. * * Example (3 tiles, 2 columns): * row 0: [ square ] [ square ] - * row 1: [ 16:9 ] (centered) + * row 1: [ square ] (centered, same size) */ export type AdaptiveTileGrid = { rows: TileRow[] @@ -112,10 +111,26 @@ const compareScores = (a: LayoutScore, b: LayoutScore): number => { return 0 } +/** + * Compute the largest tile size that fits within a given width and height, + * bounded by [minAspect, TILE_ASPECT_LANDSCAPE]. The tile is shrunk to fit + * whichever dimension is more constraining, so it never overflows. + */ +const fitTile = (availWidth: number, availHeight: number, minAspect: number) => { + const fillAspect = availWidth / availHeight + const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect)) + if (fillAspect >= aspectRatio) { + const tileWidth = availHeight * aspectRatio + return {tileWidth, tileHeight: availHeight, aspectRatio} + } + const tileHeight = availWidth / aspectRatio + return {tileWidth: availWidth, tileHeight, aspectRatio} +} + /** * Build a single-row TileRow for the uniform case where all rows - * have the same number of tiles. No upper-bound clamping on aspect - * ratio — tiles can be wider than 16:9 to avoid overflow. + * have the same number of tiles. Tiles are centered within the row + * and capped at 16:9 to avoid overflow on ultrawide containers. */ const buildUniformRow = ( columnCount: number, @@ -125,26 +140,16 @@ const buildUniformRow = ( minAspect: number, ): TileRow => { const gap = TILE_GAP - const availableHeight = containerHeight - (rowCount - 1) * gap - const rowHeight = availableHeight / rowCount - const tileWidth = (containerWidth - (columnCount - 1) * gap) / columnCount - const fillAspect = tileWidth / rowHeight - const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect)) - const tileHeight = tileWidth / aspectRatio + const availHeight = (containerHeight - (rowCount - 1) * gap) / rowCount + const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount + const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect) return {columnCount, tileWidth, tileHeight, aspectRatio} } /** * Build a mixed-size layout candidate for a given column count. - * Uses a "match heights" approach: the last (partial) row is at 16:9 - * (per review #1: "single centered 16:9 tile") and full-row tiles - * match that same height. This creates a cohesive Jitsi-style look - * where all tiles in all rows are the same height, with different - * widths per row (full-row tiles are narrower/squarer, last-row - * tiles are wider). - * - * If the full-row aspect would go below the portrait bound (3:4), - * the height diverges: full rows clamp to 3:4 portrait. + * All tiles share the same dimensions — full rows fill the width, + * and the partial last row is centered with the same tile size. */ const buildMixedCandidate = ( tileCount: number, @@ -158,48 +163,18 @@ const buildMixedCandidate = ( const remainder = tileCount % columnCount const totalRowCount = fullRowCount + 1 - const tileWidthFull = (containerWidth - (columnCount - 1) * gap) / columnCount - if (tileWidthFull <= 0) return undefined - - const tileWidthLast = (containerWidth - (remainder - 1) * gap) / remainder - if (tileWidthLast <= 0) return undefined - - const maxPossibleHeight = Math.min( - tileWidthFull / minAspect, - tileWidthLast / minAspect, - (containerHeight - (totalRowCount - 1) * gap) / totalRowCount, - ) - - // Target 16:9 for the last row if possible - const targetHeight = tileWidthLast / TILE_ASPECT_LANDSCAPE - const commonHeight = Math.min(maxPossibleHeight, targetHeight) - - const tileHeightFull = commonHeight - const tileHeightLast = commonHeight - const fullRowAspectRatio = tileWidthFull / commonHeight - const lastRowAspectRatio = tileWidthLast / commonHeight - - const totalHeight = fullRowCount * tileHeightFull + tileHeightLast + (totalRowCount - 1) * gap - const totalWidth = Math.max( - columnCount * tileWidthFull + (columnCount - 1) * gap, - remainder * tileWidthLast + (remainder - 1) * gap, - ) + const availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount + const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount + const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect) const rows: TileRow[] = [] for (let r = 0; r < fullRowCount; r++) { - rows.push({ - columnCount, - tileWidth: tileWidthFull, - tileHeight: tileHeightFull, - aspectRatio: fullRowAspectRatio, - }) + rows.push({columnCount, tileWidth, tileHeight, aspectRatio}) } - rows.push({ - columnCount: remainder, - tileWidth: tileWidthLast, - tileHeight: tileHeightLast, - aspectRatio: lastRowAspectRatio, - }) + rows.push({columnCount: remainder, tileWidth, tileHeight, aspectRatio}) + + const totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap + const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap return { rows, @@ -214,12 +189,12 @@ const buildMixedCandidate = ( * * - Tiles flex between 3:4 (portrait) and wider than 16:9 if needed * to avoid overflow. No upper bound on aspect ratio — tiles can - * be wider than 16:9 when the container is very wide. - * - When the last row is partial (fewer tiles than a full row), - * those tiles are wider (16:9) while full rows use square-ish - * tiles, filling whitespace per review #1. - * - Only allows overflow (scroll) when tiles would be below - * MIN_TILE_HEIGHT (review #2: ~6-8 tiles on portrait phone). + * Tiles flex between a minimum aspect (1:1 on landscape, 3:4 on portrait) + * and 16:9, capped so they never overflow the container. Partial rows + * use the same tile size as full rows, centered. + * + * Only allows overflow (scroll) when tiles would be below + * MIN_TILE_HEIGHT (review #2: ~6-8 tiles on portrait phone). * * Prioritises: * 1. No overflow (tiles shrink to fit within aspect bounds) @@ -234,10 +209,10 @@ export const computeAdaptiveGrid = ( ): AdaptiveTileGrid | undefined => { if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined - // On landscape viewports, bound tiles at 1:1 (square) so they don't - // become tall and narrow. On portrait viewports, allow 3:4 so more - // tiles fit on a phone screen. - const minAspect = containerWidth > containerHeight ? 1 : TILE_ASPECT_PORTRAIT + // When the container is at least 4:3 landscape, bound tiles at 1:1 (square) + // so they don't become tall and narrow. On narrower/portrait viewports, + // allow 3:4 so more tiles fit on a phone screen. + const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT let best: AdaptiveTileGrid | undefined let bestScore: LayoutScore | undefined -- 2.45.2 From 63b00e0214baeaa1c1743518ed5a5db0470b8d5f Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 12 Aug 2026 13:26:04 -0400 Subject: [PATCH 21/21] refactor: clean up grid algorithm - Unify buildUniformRow + buildMixedCandidate into single buildCandidate - Remove dead overflows field from AdaptiveTileGrid - Add rowWidth to TileRow to eliminate magic 8 in template - Simplify scoring: all rows uniform, no need for Math.min/reduce - Fix stale comments referencing old mixed-size behavior --- src/app/call.ts | 168 ++++++--------------- src/app/components/VideoCallContent.svelte | 2 +- 2 files changed, 49 insertions(+), 121 deletions(-) diff --git a/src/app/call.ts b/src/app/call.ts index caaff464..c6552dc3 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -32,11 +32,8 @@ export const LIVEKIT_PARTICIPANTS = 39004 export {supportsAudioOutputSelection} /** - * Aspect ratio constraints — tiles can flex between 3:4 (portrait) - * and 16:9 (landscape). Tiles are ALLOWED to be wider than 16:9 - * when the container is very wide, to prevent overflow (review #2 - * bug: n=2 second tile half off screen). The lower bound (3:4) - * prevents tiles from being too tall. + * Aspect ratio constraints for tiles. The lower bound is dynamic + * (1:1 on landscape, 3:4 on portrait); the upper bound is 16:9. */ const TILE_ASPECT_PORTRAIT = 3 / 4 const TILE_ASPECT_LANDSCAPE = 16 / 9 @@ -51,13 +48,14 @@ const TILE_GAP = 8 const MIN_TILE_HEIGHT = 120 /** - * A single row in an adaptive tile grid: tiles in this row share - * the same dimensions but may differ from other rows. + * A single row in an adaptive tile grid. */ export type TileRow = { columnCount: number tileWidth: number tileHeight: number + /** Total width of this row including gaps — for the template's max-width */ + rowWidth: number aspectRatio: number } @@ -73,8 +71,6 @@ export type AdaptiveTileGrid = { rows: TileRow[] totalWidth: number totalHeight: number - /** True when the grid overflows — tiles are below MIN_TILE_HEIGHT */ - overflows: boolean } /** @@ -128,30 +124,11 @@ const fitTile = (availWidth: number, availHeight: number, minAspect: number) => } /** - * Build a single-row TileRow for the uniform case where all rows - * have the same number of tiles. Tiles are centered within the row - * and capped at 16:9 to avoid overflow on ultrawide containers. + * Build a candidate grid for a given column count. All tiles share the + * same dimensions; the partial last row (if any) is centered with the + * same tile size as full rows. */ -const buildUniformRow = ( - columnCount: number, - containerWidth: number, - containerHeight: number, - rowCount: number, - minAspect: number, -): TileRow => { - const gap = TILE_GAP - const availHeight = (containerHeight - (rowCount - 1) * gap) / rowCount - const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount - const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect) - return {columnCount, tileWidth, tileHeight, aspectRatio} -} - -/** - * Build a mixed-size layout candidate for a given column count. - * All tiles share the same dimensions — full rows fill the width, - * and the partial last row is centered with the same tile size. - */ -const buildMixedCandidate = ( +const buildCandidate = ( tileCount: number, columnCount: number, containerWidth: number, @@ -161,40 +138,48 @@ const buildMixedCandidate = ( const gap = TILE_GAP const fullRowCount = Math.floor(tileCount / columnCount) const remainder = tileCount % columnCount - const totalRowCount = fullRowCount + 1 + const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0) const availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount + if (availWidth <= 0 || availHeight <= 0) return undefined + const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect) const rows: TileRow[] = [] for (let r = 0; r < fullRowCount; r++) { - rows.push({columnCount, tileWidth, tileHeight, aspectRatio}) + rows.push({ + columnCount, + tileWidth, + tileHeight, + rowWidth: columnCount * tileWidth + (columnCount - 1) * gap, + aspectRatio, + }) + } + if (remainder > 0) { + rows.push({ + columnCount: remainder, + tileWidth, + tileHeight, + rowWidth: remainder * tileWidth + (remainder - 1) * gap, + aspectRatio, + }) } - rows.push({columnCount: remainder, tileWidth, tileHeight, aspectRatio}) const totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap - return { - rows, - totalWidth, - totalHeight, - overflows: totalHeight > containerHeight, - } + return {rows, totalWidth, totalHeight} } /** - * Compute an adaptive tile grid using Jitsi-style mixed row sizes. - * - * - Tiles flex between 3:4 (portrait) and wider than 16:9 if needed - * to avoid overflow. No upper bound on aspect ratio — tiles can - * Tiles flex between a minimum aspect (1:1 on landscape, 3:4 on portrait) - * and 16:9, capped so they never overflow the container. Partial rows - * use the same tile size as full rows, centered. + * Compute an adaptive tile grid. All tiles share the same dimensions; + * partial rows are centered. Tiles flex between a minimum aspect + * (1:1 on landscape, 3:4 on portrait) and 16:9, capped so they never + * overflow the container. * * Only allows overflow (scroll) when tiles would be below - * MIN_TILE_HEIGHT (review #2: ~6-8 tiles on portrait phone). + * MIN_TILE_HEIGHT (~6-8 tiles on portrait phone). * * Prioritises: * 1. No overflow (tiles shrink to fit within aspect bounds) @@ -209,91 +194,34 @@ export const computeAdaptiveGrid = ( ): AdaptiveTileGrid | undefined => { if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined - // When the container is at least 4:3 landscape, bound tiles at 1:1 (square) - // so they don't become tall and narrow. On narrower/portrait viewports, - // allow 3:4 so more tiles fit on a phone screen. const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT let best: AdaptiveTileGrid | undefined let bestScore: LayoutScore | undefined for (let columnCount = 1; columnCount <= tileCount; columnCount++) { - const fullRowCount = Math.floor(tileCount / columnCount) - const remainder = tileCount % columnCount - const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0) - - let candidate: AdaptiveTileGrid | undefined - - if (remainder === 0) { - // Uniform layout — all rows identical - const row = buildUniformRow( - columnCount, - containerWidth, - containerHeight, - totalRowCount, - minAspect, - ) - const totalHeight = totalRowCount * row.tileHeight + (totalRowCount - 1) * TILE_GAP - const totalWidth = columnCount * row.tileWidth + (columnCount - 1) * TILE_GAP - const rows: TileRow[] = [] - for (let r = 0; r < totalRowCount; r++) { - rows.push({...row}) - } - candidate = { - rows, - totalWidth, - totalHeight, - overflows: totalHeight > containerHeight, - } - } else { - // Mixed layout — last row has fewer tiles, make them wider - candidate = buildMixedCandidate( - tileCount, - columnCount, - containerWidth, - containerHeight, - minAspect, - ) - } - + const candidate = buildCandidate( + tileCount, + columnCount, + containerWidth, + containerHeight, + minAspect, + ) if (!candidate) continue - // Compute score + const {tileWidth, tileHeight, aspectRatio} = candidate.rows[0] const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight) const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth) const whitespace = Math.max(0, containerWidth - candidate.totalWidth) + Math.max(0, containerHeight - candidate.totalHeight) - const aspectDeviation = - candidate.rows.reduce( - (sum, row) => sum + Math.abs(row.aspectRatio - TILE_ASPECT_LANDSCAPE), - 0, - ) / candidate.rows.length - const totalArea = candidate.rows.reduce( - (sum, row) => sum + row.tileWidth * row.tileHeight * row.columnCount, - 0, - ) + const totalArea = tileWidth * tileHeight * tileCount - // For overflow: if tiles are above MIN_TILE_HEIGHT, overflow is - // unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT, - // overflow is acceptable (tiles are already too small to shrink further). - const minTileHeightInCandidate = Math.min(...candidate.rows.map(r => r.tileHeight)) - const verticalOverflowPenalty = - verticalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT - ? verticalOverflow * 1_000_000 - : verticalOverflow - - const horizontalOverflowPenalty = - horizontalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT - ? horizontalOverflow * 1_000_000 - : horizontalOverflow - - // Min-tile-height penalty: even when fitting, discourage layouts - // where tiles are below the minimum acceptable size. + const overflowPenaltyMultiplier = tileHeight >= MIN_TILE_HEIGHT ? 1_000_000 : 1 + const verticalOverflowPenalty = verticalOverflow * overflowPenaltyMultiplier + const horizontalOverflowPenalty = horizontalOverflow * overflowPenaltyMultiplier const minTileHeightPenalty = - minTileHeightInCandidate < MIN_TILE_HEIGHT - ? (MIN_TILE_HEIGHT - minTileHeightInCandidate) * 1000 - : 0 + tileHeight < MIN_TILE_HEIGHT ? (MIN_TILE_HEIGHT - tileHeight) * 1000 : 0 const score: LayoutScore = { verticalOverflowPenalty, @@ -301,7 +229,7 @@ export const computeAdaptiveGrid = ( minTileHeightPenalty, negativeArea: -totalArea, whitespace, - aspectDeviation, + aspectDeviation: Math.abs(aspectRatio - TILE_ASPECT_LANDSCAPE), } if (!bestScore || compareScores(score, bestScore) < 0) { diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 42f57a40..724aee01 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -279,7 +279,7 @@ .reduce((sum, r) => sum + r.columnCount, 0)}
    + style={`max-width: ${row.rowWidth}px`}> {#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}