Compare commits
16 commits
0d9e511f41
...
13f9a5c13d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f9a5c13d | ||
|
|
f099e97981 | ||
|
|
1df2b8f7b8 | ||
|
|
972425a4b7 | ||
|
|
d552b6f245 | ||
|
|
957ab58956 | ||
|
|
b9a1f9c7ba | ||
|
|
1e2e4923c3 | ||
|
|
8243f4d245 | ||
|
|
72ee0c9ed6 | ||
|
|
7dcc713ec2 | ||
|
|
65c9eac5e5 | ||
|
|
b2f58b11c0 | ||
|
|
749989f56b | ||
|
|
9c68f66c39 | ||
|
|
85a4ba127a |
58 changed files with 3493 additions and 959 deletions
303
e2e/ARCHITECTURE.md
Normal file
303
e2e/ARCHITECTURE.md
Normal file
|
|
@ -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 `<name>.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`.
|
||||
96
e2e/harness/app/boot.ts
Normal file
96
e2e/harness/app/boot.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
30
e2e/harness/app/session.ts
Normal file
30
e2e/harness/app/session.ts
Normal file
|
|
@ -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,
|
||||
)
|
||||
165
e2e/harness/index.ts
Normal file
165
e2e/harness/index.ts
Normal file
|
|
@ -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<void>): Promise<Scenario>
|
||||
// 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<Page>
|
||||
}
|
||||
|
||||
export type HarnessFixtures = {
|
||||
harness: Harness
|
||||
seed: Harness["seed"]
|
||||
as: Harness["as"]
|
||||
}
|
||||
|
||||
export type HarnessWorkerFixtures = {
|
||||
zooid: Zooid
|
||||
}
|
||||
|
||||
export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
||||
// 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<Scenario>
|
||||
|
||||
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),
|
||||
})
|
||||
26
e2e/harness/keys.ts
Normal file
26
e2e/harness/keys.ts
Normal file
|
|
@ -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"),
|
||||
}
|
||||
315
e2e/harness/net/http.ts
Normal file
315
e2e/harness/net/http.ts
Normal file
|
|
@ -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 <img> 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<BrowserContext, BlockedRequest[]>()
|
||||
|
||||
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<string, number>
|
||||
// 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<ZapperValues, "lnurl">}[]
|
||||
}
|
||||
|
||||
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<string, {body: Buffer; type: string}>()
|
||||
|
||||
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()
|
||||
},
|
||||
)
|
||||
130
e2e/harness/net/websocket.ts
Normal file
130
e2e/harness/net/websocket.ts
Normal file
|
|
@ -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<string>
|
||||
}
|
||||
|
||||
const trafficByContext = new WeakMap<BrowserContext, Traffic>()
|
||||
|
||||
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<ClientMessage>(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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
113
e2e/harness/seed/scenario.ts
Normal file
113
e2e/harness/seed/scenario.ts
Normal file
|
|
@ -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<void>,
|
||||
): Promise<Scenario> => {
|
||||
const startedAt = now()
|
||||
const at: At = (count, unit) => startedAt - int(count, unit)
|
||||
const spaces = new Map<string, SeededSpace>()
|
||||
const writes: (() => Promise<void>)[] = []
|
||||
const roomLists = new Map<string, SignedEvent>()
|
||||
|
||||
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<string, {user: TestUser; urls: string[]; tags: string[][]}>()
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
191
e2e/harness/seed/space.ts
Normal file
191
e2e/harness/seed/space.ts
Normal file
|
|
@ -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<BaseEventReader> {}
|
||||
|
||||
// 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>) => 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<TestRelay>
|
||||
|
||||
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<SignedEvent>): SeededEvent => {
|
||||
let signed: Maybe<SignedEvent>
|
||||
|
||||
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<StampedEvent>) =>
|
||||
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,
|
||||
}
|
||||
}
|
||||
27
e2e/harness/zooid/config.ts
Normal file
27
e2e/harness/zooid/config.ts
Normal file
|
|
@ -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]]))
|
||||
37
e2e/harness/zooid/docker/compose.yaml
Normal file
37
e2e/harness/zooid/docker/compose.yaml
Normal file
|
|
@ -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
|
||||
44
e2e/harness/zooid/docker/config/other.toml
Normal file
44
e2e/harness/zooid/docker/config/other.toml
Normal file
|
|
@ -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
|
||||
43
e2e/harness/zooid/docker/config/space.toml
Normal file
43
e2e/harness/zooid/docker/config/space.toml
Normal file
|
|
@ -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
|
||||
264
e2e/harness/zooid/relay.ts
Normal file
264
e2e/harness/zooid/relay.ts
Normal file
|
|
@ -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<Promise<Maybe<string>>>
|
||||
|
||||
// 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<string, ZooidConnection>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
72
e2e/harness/zooid/testRelay.ts
Normal file
72
e2e/harness/zooid/testRelay.ts
Normal file
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
// 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}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
144
e2e/harness/zooid/transport.ts
Normal file
144
e2e/harness/zooid/transport.ts
Normal file
|
|
@ -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://<tenant>.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<RelayMessage>
|
||||
}
|
||||
|
||||
// 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<RelayMessage>(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<string, string>
|
||||
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<string, string>,
|
||||
body?: Buffer,
|
||||
): Promise<ZooidResponse> =>
|
||||
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<string, string> = {}
|
||||
|
||||
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)
|
||||
})
|
||||
34
e2e/harness/zooid/types.ts
Normal file
34
e2e/harness/zooid/types.ts
Normal file
|
|
@ -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<void>
|
||||
message(user: TestUser, h: string, content: string, createdAt: number): Promise<SignedEvent>
|
||||
// Grants relay membership, and room membership too when `h` is given.
|
||||
member(user: TestUser, h: string | undefined, createdAt: number): Promise<void>
|
||||
// Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs.
|
||||
event(user: TestUser, event: StampedEvent): Promise<SignedEvent>
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
@ -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})
|
||||
})
|
||||
43
e2e/specs/auth.spec.ts
Normal file
43
e2e/specs/auth.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
27
e2e/specs/multiuser.spec.ts
Normal file
27
e2e/specs/multiuser.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
57
e2e/specs/routing.spec.ts
Normal file
57
e2e/specs/routing.spec.ts
Normal file
|
|
@ -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([])
|
||||
})
|
||||
26
e2e/specs/space.spec.ts
Normal file
26
e2e/specs/space.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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 `<name>.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<string, SignedEvent[]> = {
|
||||
[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] ?? []])),
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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}
|
||||
57
link_deps
57
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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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}}],
|
||||
})
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -3,6 +3,7 @@ allowBuilds:
|
|||
cbor-extract: false
|
||||
esbuild: false
|
||||
sharp: true
|
||||
nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@pomade/core'
|
||||
- '@welshman/app'
|
||||
|
|
@ -16,14 +17,4 @@ minimumReleaseAgeExclude:
|
|||
- '@welshman/store'
|
||||
- '@welshman/util'
|
||||
overrides:
|
||||
'@welshman/app': link:../welshman/packages/app
|
||||
'@welshman/content': link:../welshman/packages/content
|
||||
'@welshman/domain': link:../welshman/packages/domain
|
||||
'@welshman/editor': link:../welshman/packages/editor
|
||||
'@welshman/feeds': link:../welshman/packages/feeds
|
||||
'@welshman/lib': link:../welshman/packages/lib
|
||||
'@welshman/net': link:../welshman/packages/net
|
||||
'@welshman/signer': link:../welshman/packages/signer
|
||||
'@welshman/store': link:../welshman/packages/store
|
||||
'@welshman/util': link:../welshman/packages/util
|
||||
sharp: ^0.35.3
|
||||
|
|
|
|||
220
src/app/call.ts
220
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,
|
||||
|
|
@ -15,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"
|
||||
|
|
@ -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,94 +111,70 @@ 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,
|
||||
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 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,
|
||||
columnCount: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
minAspect: number,
|
||||
): AdaptiveTileGrid | undefined => {
|
||||
const gap = TILE_GAP
|
||||
const fullRowCount = Math.floor(tileCount / columnCount)
|
||||
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
|
||||
|
||||
// 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,
|
||||
)
|
||||
|
||||
// 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,6 +209,11 @@ 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
|
||||
|
||||
|
|
@ -246,7 +226,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 +247,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
|
||||
|
|
@ -322,7 +314,6 @@ export const computeAdaptiveGrid = (
|
|||
}
|
||||
|
||||
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 = {
|
||||
|
|
@ -355,11 +346,6 @@ export enum VideoCallLayout {
|
|||
Split = "split",
|
||||
}
|
||||
|
||||
export enum ViewportSize {
|
||||
Desktop = "desktop",
|
||||
Mobile = "mobile",
|
||||
}
|
||||
|
||||
export enum DeviceKind {
|
||||
AudioInput = "audioinput",
|
||||
AudioOutput = "audiooutput",
|
||||
|
|
@ -399,24 +385,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>(VideoCallLayout.Split)
|
||||
|
||||
export const videoPrimaryTileKey = writable<string | undefined>(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,
|
||||
|
|
@ -664,8 +636,26 @@ 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 = () => {
|
||||
videoCallViewportSync.previousLayout = undefined
|
||||
videoCallLayout.set(VideoCallLayout.Chat)
|
||||
}
|
||||
|
||||
|
|
@ -748,27 +738,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,
|
||||
|
|
@ -872,6 +841,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)
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
</PageBar>
|
||||
|
||||
<div class="flex flex-col-reverse gap-4 py-2 bg-surface scroll-container h-screen overflow-y-auto">
|
||||
{#if missingRelayLists.length > 0}
|
||||
<div class="py-12">
|
||||
<div class="card flex flex-col gap-2 m-auto max-w-md items-center text-center">
|
||||
<p class="flex gap-2 text-lg text-error">
|
||||
<Icon icon={Danger} />
|
||||
Direct messages are not enabled
|
||||
</p>
|
||||
<p>
|
||||
Ask
|
||||
{#each missingRelayLists as pubkey (pubkey)}
|
||||
<ProfileLink {pubkey} />
|
||||
{/each}
|
||||
to enable direct messaging by opening this conversation in their $app.
|
||||
</p>
|
||||
<div class="room flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<PageContent class="flex flex-col-reverse gap-4 py-2 bg-surface !mb-0">
|
||||
{#if missingRelayLists.length > 0}
|
||||
<div class="py-12">
|
||||
<div class="card flex flex-col gap-2 m-auto max-w-md items-center text-center">
|
||||
<p class="flex gap-2 text-lg text-error">
|
||||
<Icon icon={Danger} />
|
||||
Direct messages are not enabled
|
||||
</p>
|
||||
<p>
|
||||
Ask
|
||||
{#each missingRelayLists as pubkey (pubkey)}
|
||||
<ProfileLink {pubkey} />
|
||||
{/each}
|
||||
to enable direct messaging by opening this conversation in their $app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each elements as { type, id, value, showPubkey } (id)}
|
||||
{#if type === "date"}
|
||||
<Divider>{value}</Divider>
|
||||
{:else}
|
||||
<ChatMessage
|
||||
event={$state.snapshot(value as TrustedEvent)}
|
||||
{pubkeys}
|
||||
{showPubkey}
|
||||
{replyTo}
|
||||
canEdit={canEditEvent}
|
||||
onEdit={onEditEvent} />
|
||||
{/if}
|
||||
{/each}
|
||||
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Looking for messages...
|
||||
{#each elements as { type, id, value, showPubkey } (id)}
|
||||
{#if type === "date"}
|
||||
<Divider>{value}</Divider>
|
||||
{:else}
|
||||
End of message history
|
||||
<ChatMessage
|
||||
event={$state.snapshot(value as TrustedEvent)}
|
||||
{pubkeys}
|
||||
{showPubkey}
|
||||
{replyTo}
|
||||
canEdit={canEditEvent}
|
||||
onEdit={onEditEvent} />
|
||||
{/if}
|
||||
</Spinner>
|
||||
{@render info?.()}
|
||||
</p>
|
||||
<div class="h-screen"></div>
|
||||
</div>
|
||||
{/each}
|
||||
<p
|
||||
class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Looking for messages...
|
||||
{:else}
|
||||
End of message history
|
||||
{/if}
|
||||
</Spinner>
|
||||
{@render info?.()}
|
||||
</p>
|
||||
</PageContent>
|
||||
|
||||
<div class="room__compose bg-surface">
|
||||
<div>
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
{/if}
|
||||
{#if eventToEdit}
|
||||
<ChatComposeEdit clear={clearEventToEdit} />
|
||||
{/if}
|
||||
<div class="room__compose bg-surface">
|
||||
<div>
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
{/if}
|
||||
{#if eventToEdit}
|
||||
<ChatComposeEdit clear={clearEventToEdit} />
|
||||
{/if}
|
||||
</div>
|
||||
{#key eventToEdit}
|
||||
<ChatCompose
|
||||
bind:this={compose}
|
||||
{onSubmit}
|
||||
{onEscape}
|
||||
{onEditPrevious}
|
||||
initialValues={eventToEdit}
|
||||
draftKey={eventToEdit ? undefined : draftKey}
|
||||
disabled={Boolean(missingRelayLists.length)} />
|
||||
{/key}
|
||||
</div>
|
||||
{#key eventToEdit}
|
||||
<ChatCompose
|
||||
bind:this={compose}
|
||||
{onSubmit}
|
||||
{onEscape}
|
||||
{onEditPrevious}
|
||||
initialValues={eventToEdit}
|
||||
draftKey={eventToEdit ? undefined : draftKey}
|
||||
disabled={Boolean(missingRelayLists.length)} />
|
||||
{/key}
|
||||
</div>
|
||||
|
|
|
|||
91
src/app/components/MicLevelMeter.svelte
Normal file
91
src/app/components/MicLevelMeter.svelte
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
||||
type Props = {
|
||||
track: MediaStreamTrack | undefined
|
||||
error?: string
|
||||
offMessage?: string
|
||||
}
|
||||
|
||||
const {track, error, offMessage = "Microphone is off"}: Props = $props()
|
||||
|
||||
let level = $state(0)
|
||||
|
||||
$effect(() => {
|
||||
const t = track
|
||||
level = 0
|
||||
|
||||
if (!t || error) return
|
||||
|
||||
let frame: number | undefined
|
||||
const context = new AudioContext()
|
||||
const analyser = context.createAnalyser()
|
||||
analyser.fftSize = 512
|
||||
context.createMediaStreamSource(new MediaStream([t])).connect(analyser)
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
|
||||
// Smooth into a plain local, not into `level` itself: reading the `$state`
|
||||
// we also write would make this effect depend on its own output, so every
|
||||
// frame would invalidate and re-run it, tearing down the AudioContext
|
||||
// before it ever produced a reading.
|
||||
let smoothed = 0
|
||||
|
||||
const tick = () => {
|
||||
if (!t.enabled || t.readyState !== "live") {
|
||||
smoothed = 0
|
||||
level = 0
|
||||
frame = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
analyser.getByteTimeDomainData(data)
|
||||
let sumSquares = 0
|
||||
for (const v of data) {
|
||||
const normalized = (v - 128) / 128
|
||||
sumSquares += normalized * normalized
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / data.length)
|
||||
const NOISE_FLOOR = 0.03
|
||||
const gated = rms < NOISE_FLOOR ? 0 : (rms - NOISE_FLOOR) / (1 - NOISE_FLOOR)
|
||||
smoothed += (Math.min(1, gated * 4) - smoothed) * 0.5
|
||||
if (smoothed < 0.005) smoothed = 0
|
||||
level = smoothed
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
tick()
|
||||
|
||||
return () => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
context.close().catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={Microphone} size={4} class="shrink-0 opacity-70" />
|
||||
{#if error}
|
||||
<p class="text-xs text-error" role="alert">{error}</p>
|
||||
{:else if !track}
|
||||
<p class="text-xs opacity-60">{offMessage}</p>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-1 items-center gap-1"
|
||||
role="meter"
|
||||
aria-label="Microphone input level"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(level * 100)}>
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div
|
||||
class={cx(
|
||||
"h-2 flex-1 rounded-full transition-colors",
|
||||
level * 5 > i ? "bg-success" : "bg-surface-more",
|
||||
)}>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="shrink-0 text-xs opacity-60">Speak to test</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -13,8 +13,11 @@
|
|||
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"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -30,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,
|
||||
|
|
@ -48,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"
|
||||
|
||||
|
|
@ -74,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)
|
||||
|
|
@ -301,8 +305,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
|
||||
|
|
@ -448,42 +454,61 @@
|
|||
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
|
||||
|
||||
onMount(() => {
|
||||
start()
|
||||
// Defer rendering until navigation finishes
|
||||
let frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(start)
|
||||
})
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange)
|
||||
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)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cx(
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split && "md:flex-row",
|
||||
)}>
|
||||
<div class={cx("flex min-h-0 flex-1 flex-col", isOverlayChat && "relative")}>
|
||||
{#if h && voiceConnectedHere}
|
||||
<VideoCallContent
|
||||
layout={$videoCallLayout}
|
||||
{url}
|
||||
{h}
|
||||
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
|
||||
class={cx(
|
||||
"hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex",
|
||||
isOverlayChat && "md:absolute md:inset-0",
|
||||
)} />
|
||||
{/if}
|
||||
|
||||
{#if h && isVoiceRoom && $callState === CallState.Connected}
|
||||
<VideoCallContent
|
||||
layout={$videoCallLayout}
|
||||
mobile
|
||||
{url}
|
||||
{h}
|
||||
class={cx("md:hidden", isOverlayChat && "absolute inset-0")} />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={cx(
|
||||
"room flex min-h-0 min-w-0 flex-1 flex-col",
|
||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
|
||||
videoCallChatHidden && "hidden",
|
||||
isOverlayChat &&
|
||||
"absolute inset-x-2 top-4 bottom-[calc(3.5rem+var(--saib))] z-popover overflow-hidden rounded-2xl border border-line bg-surface/95 shadow-lg md:left-auto md:right-4 md:bottom-4 md:w-96 md:max-w-[26rem] md:bg-surface/90",
|
||||
)}>
|
||||
{#if h}
|
||||
<RoomPinnedMessages {url} {h} />
|
||||
{#if isOverlayChat}
|
||||
<Button
|
||||
aria-label="Close room chat"
|
||||
class="button button-neutral button-xs button-circle absolute right-2 top-2 z-popover"
|
||||
onclick={closeChat}>
|
||||
<Icon icon={Close} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if h && isVoiceRoom && $callState === CallState.Connected}
|
||||
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
|
||||
{#if h}
|
||||
<RoomPinnedMessages {url} {h} />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
|
|
@ -618,13 +643,27 @@
|
|||
<div
|
||||
class={cx(
|
||||
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
|
||||
showMobileVideoPanel && "hidden",
|
||||
videoCallChatHidden && "hidden",
|
||||
)}>
|
||||
<VoiceWidget />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if videoCallChatHidden}
|
||||
<Button
|
||||
aria-label="Open room chat"
|
||||
class="button button-primary button-circle fixed z-popover bottom-[calc(4.5rem+var(--saib))] right-4 shadow-lg md:bottom-4"
|
||||
onclick={openChat}>
|
||||
<Icon icon={ChatRound} size={5} />
|
||||
{#if chatUnread}
|
||||
<span
|
||||
class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-surface"
|
||||
aria-hidden="true"></span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showScrollButton}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,13 @@
|
|||
<script lang="ts">
|
||||
import {derived} from "svelte/store"
|
||||
import cx from "classnames"
|
||||
import {
|
||||
displayRelayUrl,
|
||||
EVENT_TIME,
|
||||
ZAP_GOAL,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
PINBOARD,
|
||||
POLL,
|
||||
} from "@welshman/util"
|
||||
import {createSearch} from "@welshman/app"
|
||||
import {fly} from "@lib/transition"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||
import Home from "@assets/icons/home.svg?dataurl"
|
||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import ServerPath from "@assets/icons/server-path.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Login from "@assets/icons/login-3.svg?dataurl"
|
||||
import History from "@assets/icons/history.svg?dataurl"
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import Revote from "@assets/icons/revote.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
||||
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
||||
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
||||
import SpaceExit from "@app/components/SpaceExit.svelte"
|
||||
import SpaceEdit from "@app/components/SpaceEdit.svelte"
|
||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
||||
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
|
||||
import SpaceMenuHeader from "@app/components/SpaceMenuHeader.svelte"
|
||||
import SpaceMenuNavItems from "@app/components/SpaceMenuNavItems.svelte"
|
||||
import SpaceMenuRooms from "@app/components/SpaceMenuRooms.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||
import {relays, user} from "@app/core"
|
||||
import {ENABLE_ZAPS} from "@app/env"
|
||||
import {CONTENT_KINDS} from "@app/content"
|
||||
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
|
||||
import {
|
||||
deriveSpaceSupportedMethods,
|
||||
deriveUserCanCreateRoom,
|
||||
deriveUserIsSpaceAdmin,
|
||||
} from "@app/management"
|
||||
import {
|
||||
deriveOtherRooms,
|
||||
deriveOtherVoiceRooms,
|
||||
deriveUserRooms,
|
||||
displayRoom,
|
||||
userSpaceUrls,
|
||||
} from "@app/rooms"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {deriveSpaceActionItems} from "@app/actionItems"
|
||||
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {makeSpacePath, goToChat} from "@app/routes"
|
||||
import {notifications} from "@app/notifications"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
|
@ -81,335 +15,45 @@
|
|||
}
|
||||
|
||||
const {url, mobile = false}: Props = $props()
|
||||
|
||||
const relay = $relays.one(url)
|
||||
const chatPath = makeSpacePath(url, "chat")
|
||||
const goalsPath = makeSpacePath(url, "goals")
|
||||
const threadsPath = makeSpacePath(url, "threads")
|
||||
const classifiedsPath = makeSpacePath(url, "classifieds")
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
const pollsPath = makeSpacePath(url, "polls")
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const hostedRelay = deriveHostedRelay(url)
|
||||
const actionItems = deriveSpaceActionItems(url)
|
||||
|
||||
const spaceKinds = derived(
|
||||
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
|
||||
$events => new Set($events.map(e => e.kind)),
|
||||
)
|
||||
|
||||
const boardEvents = deriveEventsForUrl(url, [{kinds: [PINBOARD]}])
|
||||
const hasNip29 = $derived($relay?.hasNip(29) ?? false)
|
||||
const supportedMethods = deriveSpaceSupportedMethods(url)
|
||||
const showLibrary = $derived($boardEvents.length > 0 || $supportedMethods.includes("signevent"))
|
||||
|
||||
const roomSearch = derived(otherRooms, $otherRooms =>
|
||||
createSearch(
|
||||
$otherRooms.map(h => ({h, name: displayRoom(url, h)})),
|
||||
{
|
||||
getValue: item => item.h,
|
||||
fuseOptions: {keys: ["name"]},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const openMenu = () => {
|
||||
showMenu = true
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu = !showMenu
|
||||
}
|
||||
|
||||
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
|
||||
|
||||
const showActionItems = () => pushModal(SpaceActionItems, {url})
|
||||
|
||||
const openSearch = () => pushModal(SpaceSearch, {url})
|
||||
|
||||
const canCreateRoom = deriveUserCanCreateRoom(url)
|
||||
|
||||
const createInvite = () => pushModal(SpaceInvite, {url})
|
||||
|
||||
const leaveSpace = () => pushModal(SpaceExit, {url})
|
||||
|
||||
const joinSpace = () => pushModal(SpaceJoin, {url})
|
||||
|
||||
const addRoom = () => pushModal(RoomCreate, {url})
|
||||
|
||||
const contactOwner = () => goToChat([$relay!.pubkey!])
|
||||
|
||||
const shouldNotify = deriveShouldNotify(url)
|
||||
|
||||
const toggleSpaceNotifications = () => {
|
||||
setSpaceNotifications(url, !$shouldNotify)
|
||||
}
|
||||
|
||||
const clearTerm = () => {
|
||||
setTimeout(() => {
|
||||
term = ""
|
||||
}, 100)
|
||||
}
|
||||
|
||||
let term = $state("")
|
||||
let showMenu = $state(false)
|
||||
let element: Element | undefined = $state()
|
||||
</script>
|
||||
|
||||
{#snippet spaceHeader()}
|
||||
<Button
|
||||
class={cx(
|
||||
mobile
|
||||
? "space-menu__header-button text-content"
|
||||
: "relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-surface",
|
||||
)}
|
||||
onclick={openMenu}>
|
||||
<div class="flex items-center justify-between">
|
||||
<strong class="flex items-center gap-1 relative">
|
||||
<RelayName {url} class="truncate min-w-0" />
|
||||
<div
|
||||
class={cx(
|
||||
"absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary text-primary-content transition-all",
|
||||
$userIsAdmin && $actionItems.length > 0 ? "opacity-100" : "opacity-0",
|
||||
)}>
|
||||
</div>
|
||||
{#if $notificationSettings.push && !$shouldNotify}
|
||||
<Icon icon={BellOff} size={3} class="opacity-50" />
|
||||
{/if}
|
||||
</strong>
|
||||
<Icon icon={AltArrowDown} />
|
||||
</div>
|
||||
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
|
||||
</Button>
|
||||
{#if showMenu}
|
||||
<Popover hideOnClick onClose={toggleMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu absolute z-popover mt-2 w-full gap-1 rounded-2xl bg-surface p-2">
|
||||
<li>
|
||||
<Button onclick={createInvite}>
|
||||
<Icon icon={LinkRound} />
|
||||
Create Invite
|
||||
</Button>
|
||||
</li>
|
||||
{#if $userIsAdmin}
|
||||
<li>
|
||||
<Button onclick={showActionItems}>
|
||||
<Icon icon={Danger} />
|
||||
Action Items ({$actionItems.length})
|
||||
{#if $actionItems.length > 0}
|
||||
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
|
||||
{/if}
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $relay?.pubkey && $relay.pubkey !== $user.pubkey}
|
||||
<li>
|
||||
<Button onclick={contactOwner}>
|
||||
<Icon icon={Letter} />
|
||||
Contact Owner
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
{#if $notificationSettings.push}
|
||||
<Button onclick={toggleSpaceNotifications}>
|
||||
<Icon icon={$shouldNotify ? Bell : BellOff} />
|
||||
{$shouldNotify ? "Turn off" : "Turn on"} notifications
|
||||
</Button>
|
||||
{:else}
|
||||
<Link href="/settings/alerts">
|
||||
<Icon icon={Bell} />
|
||||
Enable notifications
|
||||
</Link>
|
||||
{/if}
|
||||
</li>
|
||||
{#if HOSTING_ENABLED && $hostedRelay.relay}
|
||||
<li>
|
||||
<Link href={makeSpacePath(url, "admin")}>
|
||||
<Icon icon={ServerPath} />
|
||||
Hosting settings
|
||||
</Link>
|
||||
</li>
|
||||
{:else if $userIsAdmin}
|
||||
<li>
|
||||
<Button onclick={startEdit}>
|
||||
<Icon icon={Pen} />
|
||||
Edit Space
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
{#if $userSpaceUrls.includes(url)}
|
||||
<Button onclick={leaveSpace} class="text-error">
|
||||
<Icon icon={Exit} />
|
||||
Leave Space
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
onclick={joinSpace}
|
||||
class="bg-primary text-primary-content"
|
||||
style="color: var(--primary-content)">
|
||||
<Icon icon={Login} />
|
||||
Join Space
|
||||
</Button>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet spaceNavItems()}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "about")}>
|
||||
<Icon icon={Home} /> Space Details
|
||||
</SecondaryNavItem>
|
||||
{#if hasNip29}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
|
||||
<Icon icon={History} /> Recent Activity
|
||||
</SecondaryNavItem>
|
||||
{:else}
|
||||
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
|
||||
<Icon icon={ChatRound} /> Chat
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
|
||||
<Icon icon={UsersGroup} /> Directory
|
||||
</SecondaryNavItem>
|
||||
{#if showLibrary}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "library")}>
|
||||
<Icon icon={GalleryWide} /> Library
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
|
||||
<SecondaryNavItem href={goalsPath}>
|
||||
<Icon icon={StarFallMinimalistic} /> Goals
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(THREAD)}
|
||||
<SecondaryNavItem href={threadsPath}>
|
||||
<Icon icon={NotesMinimalistic} /> Threads
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(CLASSIFIED)}
|
||||
<SecondaryNavItem href={classifiedsPath}>
|
||||
<Icon icon={CaseMinimalistic} /> Classifieds
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(EVENT_TIME)}
|
||||
<SecondaryNavItem href={calendarPath}>
|
||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(POLL)}
|
||||
<SecondaryNavItem href={pollsPath}>
|
||||
<Icon icon={Revote} /> Polls
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if hasNip29}
|
||||
<SecondaryNavItem onclick={openSearch}>
|
||||
<Icon icon={Magnifier} /> Search
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet spaceRooms()}
|
||||
{#if hasNip29}
|
||||
{#if $userRooms.length > 0}
|
||||
{#if !mobile}
|
||||
<div class="h-2 shrink-0"></div>
|
||||
{/if}
|
||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||
{/if}
|
||||
{#each $userRooms as h (h)}
|
||||
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
|
||||
{/each}
|
||||
{#if $otherRooms.length > 0}
|
||||
{#if !mobile}
|
||||
<div class="h-2 shrink-0"></div>
|
||||
{/if}
|
||||
<SecondaryNavHeader>
|
||||
{#if $userRooms.length > 0}
|
||||
Other Rooms
|
||||
{:else}
|
||||
Rooms
|
||||
{/if}
|
||||
</SecondaryNavHeader>
|
||||
{/if}
|
||||
{#if $otherRooms.length > 20}
|
||||
<label class="input input-sm flex items-center gap-2">
|
||||
<Icon icon={Magnifier} />
|
||||
<input bind:value={term} onblur={clearTerm} class="grow" />
|
||||
</label>
|
||||
{/if}
|
||||
{#each $roomSearch.searchValues(term) as h (h)}
|
||||
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
|
||||
{/each}
|
||||
{#if $otherVoiceRooms.length > 0}
|
||||
{#if !mobile}
|
||||
<div class="h-2 shrink-0"></div>
|
||||
{/if}
|
||||
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
|
||||
{#each $otherVoiceRooms as h (h)}
|
||||
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if $canCreateRoom}
|
||||
<SecondaryNavItem onclick={addRoom}>
|
||||
<Icon icon={AddCircle} />
|
||||
Create room
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet spaceFooter()}
|
||||
<VoiceWidget />
|
||||
<Link
|
||||
href={makeSpacePath(url, "about")}
|
||||
class={cx(mobile ? "space-menu__status-link" : "button button-neutral button-sm h-10")}>
|
||||
<SocketStatusIndicator {url} />
|
||||
</Link>
|
||||
{/snippet}
|
||||
|
||||
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
{#if mobile}
|
||||
<SecondaryNavSection class="space-menu min-h-0 flex-1 flex flex-col gap-3 p-0">
|
||||
<div class="card space-menu__card space-menu__header shrink-0 z-nav-item">
|
||||
{@render spaceHeader()}
|
||||
<SpaceMenuHeader {url} mobile />
|
||||
</div>
|
||||
<div class="space-menu__scroll flex flex-col -mt-12 pt-12 -mb-8 pb-8">
|
||||
<div class="card space-menu__card flex flex-col gap-1">
|
||||
<SecondaryNavHeader>Space</SecondaryNavHeader>
|
||||
{@render spaceNavItems()}
|
||||
<SpaceMenuNavItems {url} />
|
||||
</div>
|
||||
{#if hasNip29 && ($userRooms.length > 0 || $otherRooms.length > 0 || $otherVoiceRooms.length > 0 || $canCreateRoom)}
|
||||
<div class="card space-menu__card flex flex-col gap-1">
|
||||
{@render spaceRooms()}
|
||||
</div>
|
||||
{/if}
|
||||
<SpaceMenuRooms {url} mobile />
|
||||
</div>
|
||||
<div class="card space-menu__card space-menu__status shrink-0 z-nav-item">
|
||||
{@render spaceFooter()}
|
||||
<VoiceWidget />
|
||||
<Link href={makeSpacePath(url, "about")} class="space-menu__status-link">
|
||||
<SocketStatusIndicator {url} />
|
||||
</Link>
|
||||
</div>
|
||||
</SecondaryNavSection>
|
||||
{:else}
|
||||
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col pb-0">
|
||||
<div class="shrink-0">
|
||||
{@render spaceHeader()}
|
||||
<SpaceMenuHeader {url} />
|
||||
</div>
|
||||
<div
|
||||
class="space-menu__scroll flex min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto py-1">
|
||||
{@render spaceNavItems()}
|
||||
{@render spaceRooms()}
|
||||
<SpaceMenuNavItems {url} />
|
||||
<SpaceMenuRooms {url} />
|
||||
<div class="h-5 shrink-0"></div>
|
||||
</div>
|
||||
</SecondaryNavSection>
|
||||
<div class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-1 md:pb-2 z-nav">
|
||||
{@render spaceFooter()}
|
||||
<VoiceWidget />
|
||||
<Link href={makeSpacePath(url, "about")} class="button button-neutral button-sm h-10">
|
||||
<SocketStatusIndicator {url} />
|
||||
</Link>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
122
src/app/components/SpaceMenuActions.svelte
Normal file
122
src/app/components/SpaceMenuActions.svelte
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import ServerPath from "@assets/icons/server-path.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Login from "@assets/icons/login-3.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
||||
import SpaceExit from "@app/components/SpaceExit.svelte"
|
||||
import SpaceEdit from "@app/components/SpaceEdit.svelte"
|
||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
||||
import {relays, user} from "@app/core"
|
||||
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/management"
|
||||
import {userSpaceUrls} from "@app/rooms"
|
||||
import {deriveSpaceActionItems} from "@app/actionItems"
|
||||
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
|
||||
import {pushModal, popModal} from "@app/modal"
|
||||
import {makeSpacePath, goToChat} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
|
||||
const relay = $relays.one(url)
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const hostedRelay = deriveHostedRelay(url)
|
||||
const actionItems = deriveSpaceActionItems(url)
|
||||
const shouldNotify = deriveShouldNotify(url)
|
||||
|
||||
const createInvite = () => pushModal(SpaceInvite, {url})
|
||||
|
||||
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
|
||||
|
||||
const leaveSpace = () => pushModal(SpaceExit, {url})
|
||||
|
||||
const joinSpace = () => pushModal(SpaceJoin, {url})
|
||||
|
||||
const showActionItems = () => pushModal(SpaceActionItems, {url})
|
||||
|
||||
const contactOwner = () => goToChat([$relay!.pubkey!])
|
||||
|
||||
const toggleSpaceNotifications = () => {
|
||||
popModal()
|
||||
setSpaceNotifications(url, !$shouldNotify)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet actionButton(
|
||||
onclick: () => void,
|
||||
icon: string,
|
||||
label: string,
|
||||
variant?: "error" | "primary",
|
||||
)}
|
||||
<li>
|
||||
<Button
|
||||
{onclick}
|
||||
class={cx({
|
||||
"text-error": variant === "error",
|
||||
"bg-primary text-primary-content": variant === "primary",
|
||||
})}
|
||||
style={variant === "primary" ? "color: var(--primary-content)" : undefined}>
|
||||
<Icon {icon} />
|
||||
{label}
|
||||
</Button>
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
{@render actionButton(createInvite, LinkRound, "Create Invite")}
|
||||
{#if $userIsAdmin}
|
||||
<li>
|
||||
<Button onclick={showActionItems}>
|
||||
<Icon icon={Danger} />
|
||||
Action Items ({$actionItems.length})
|
||||
{#if $actionItems.length > 0}
|
||||
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
|
||||
{/if}
|
||||
</Button>
|
||||
</li>
|
||||
{/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}
|
||||
<li>
|
||||
<Link href="/settings/alerts">
|
||||
<Icon icon={Bell} />
|
||||
Enable notifications
|
||||
</Link>
|
||||
</li>
|
||||
{/if}
|
||||
{#if HOSTING_ENABLED && $hostedRelay.relay}
|
||||
<li>
|
||||
<Link href={makeSpacePath(url, "admin")}>
|
||||
<Icon icon={ServerPath} />
|
||||
Hosting settings
|
||||
</Link>
|
||||
</li>
|
||||
{: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}
|
||||
118
src/app/components/SpaceMenuActionsMobile.svelte
Normal file
118
src/app/components/SpaceMenuActionsMobile.svelte
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import ServerPath from "@assets/icons/server-path.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Login from "@assets/icons/login-3.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
||||
import SpaceExit from "@app/components/SpaceExit.svelte"
|
||||
import SpaceEdit from "@app/components/SpaceEdit.svelte"
|
||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
||||
import {relays, user} from "@app/core"
|
||||
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/management"
|
||||
import {userSpaceUrls} from "@app/rooms"
|
||||
import {deriveSpaceActionItems} from "@app/actionItems"
|
||||
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
|
||||
import {pushModal, popModal} from "@app/modal"
|
||||
import {makeSpacePath, goToChat} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
|
||||
const relay = $relays.one(url)
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const hostedRelay = deriveHostedRelay(url)
|
||||
const actionItems = deriveSpaceActionItems(url)
|
||||
const shouldNotify = deriveShouldNotify(url)
|
||||
|
||||
const createInvite = () => pushModal(SpaceInvite, {url}, {replaceState: true})
|
||||
|
||||
const startEdit = () =>
|
||||
pushModal(SpaceEdit, {url, initialValues: $relay || {url}}, {replaceState: true})
|
||||
|
||||
const leaveSpace = () => pushModal(SpaceExit, {url}, {replaceState: true})
|
||||
|
||||
const joinSpace = () => pushModal(SpaceJoin, {url}, {replaceState: true})
|
||||
|
||||
const showActionItems = () => pushModal(SpaceActionItems, {url}, {replaceState: true})
|
||||
|
||||
const contactOwner = () => goToChat([$relay!.pubkey!], {replaceState: true})
|
||||
|
||||
const toggleSpaceNotifications = () => {
|
||||
popModal()
|
||||
setSpaceNotifications(url, !$shouldNotify)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet actionButton(
|
||||
onclick: () => void,
|
||||
icon: string,
|
||||
label: string,
|
||||
variant?: "error" | "primary",
|
||||
)}
|
||||
<Button
|
||||
class={cx("button w-full justify-start", {
|
||||
"button-neutral": !variant,
|
||||
"button-error": variant === "error",
|
||||
"button-primary": variant === "primary",
|
||||
})}
|
||||
{onclick}>
|
||||
<Icon size={4} {icon} />
|
||||
{label}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{@render actionButton(createInvite, LinkRound, "Create Invite")}
|
||||
{#if $userIsAdmin}
|
||||
<Button class="button button-neutral w-full justify-start" onclick={showActionItems}>
|
||||
<Icon size={4} icon={Danger} />
|
||||
Action Items ({$actionItems.length})
|
||||
{#if $actionItems.length > 0}
|
||||
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
|
||||
{/if}
|
||||
</Button>
|
||||
{/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}
|
||||
<Link href="/settings/alerts" replaceState class="button button-neutral w-full justify-start">
|
||||
<Icon size={4} icon={Bell} />
|
||||
Enable notifications
|
||||
</Link>
|
||||
{/if}
|
||||
{#if HOSTING_ENABLED && $hostedRelay.relay}
|
||||
<Link
|
||||
href={makeSpacePath(url, "admin")}
|
||||
replaceState
|
||||
class="button button-neutral w-full justify-start">
|
||||
<Icon size={4} icon={ServerPath} />
|
||||
Hosting settings
|
||||
</Link>
|
||||
{: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}
|
||||
74
src/app/components/SpaceMenuHeader.svelte
Normal file
74
src/app/components/SpaceMenuHeader.svelte
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {fly} from "@lib/transition"
|
||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import SpaceMenuMobile from "@app/components/SpaceMenuMobile.svelte"
|
||||
import SpaceMenuActions from "@app/components/SpaceMenuActions.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/management"
|
||||
import {deriveSpaceActionItems} from "@app/actionItems"
|
||||
import {notificationSettings, deriveShouldNotify} from "@app/settings"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
mobile?: boolean
|
||||
}
|
||||
|
||||
const {url, mobile = false}: Props = $props()
|
||||
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const actionItems = deriveSpaceActionItems(url)
|
||||
const shouldNotify = deriveShouldNotify(url)
|
||||
|
||||
const openMenu = () => {
|
||||
if (mobile) {
|
||||
pushModal(SpaceMenuMobile, {url})
|
||||
} else {
|
||||
showMenu = true
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu = !showMenu
|
||||
}
|
||||
|
||||
let showMenu = $state(false)
|
||||
</script>
|
||||
|
||||
<Button
|
||||
class={cx(
|
||||
mobile
|
||||
? "space-menu__header-button text-content"
|
||||
: "relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-surface",
|
||||
)}
|
||||
onclick={openMenu}>
|
||||
<div class="flex items-center justify-between">
|
||||
<strong class="flex items-center gap-1 relative">
|
||||
<RelayName {url} class="truncate min-w-0" />
|
||||
<div
|
||||
class={cx(
|
||||
"absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary text-primary-content transition-all",
|
||||
$userIsAdmin && $actionItems.length > 0 ? "opacity-100" : "opacity-0",
|
||||
)}>
|
||||
</div>
|
||||
{#if $notificationSettings.push && !$shouldNotify}
|
||||
<Icon icon={BellOff} size={3} class="opacity-50" />
|
||||
{/if}
|
||||
</strong>
|
||||
<Icon icon={AltArrowDown} />
|
||||
</div>
|
||||
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
|
||||
</Button>
|
||||
{#if showMenu && !mobile}
|
||||
<Popover hideOnClick onClose={toggleMenu}>
|
||||
<ul transition:fly class="menu absolute z-popover mt-2 w-full gap-1 rounded-2xl bg-surface p-2">
|
||||
<SpaceMenuActions {url} />
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
19
src/app/components/SpaceMenuMobile.svelte
Normal file
19
src/app/components/SpaceMenuMobile.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import SpaceMenuActionsMobile from "@app/components/SpaceMenuActionsMobile.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col gap-2">
|
||||
<SpaceMenuActionsMobile {url} />
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
103
src/app/components/SpaceMenuNavItems.svelte
Normal file
103
src/app/components/SpaceMenuNavItems.svelte
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<script lang="ts">
|
||||
import {derived} from "svelte/store"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, PINBOARD, POLL} from "@welshman/util"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||
import Home from "@assets/icons/home.svg?dataurl"
|
||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import History from "@assets/icons/history.svg?dataurl"
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import Revote from "@assets/icons/revote.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
||||
import {relays} from "@app/core"
|
||||
import {ENABLE_ZAPS} from "@app/env"
|
||||
import {CONTENT_KINDS} from "@app/content"
|
||||
import {deriveSpaceSupportedMethods} from "@app/management"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {notifications} from "@app/notifications"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
|
||||
const relay = $relays.one(url)
|
||||
const chatPath = makeSpacePath(url, "chat")
|
||||
const goalsPath = makeSpacePath(url, "goals")
|
||||
const threadsPath = makeSpacePath(url, "threads")
|
||||
const classifiedsPath = makeSpacePath(url, "classifieds")
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
const pollsPath = makeSpacePath(url, "polls")
|
||||
|
||||
const spaceKinds = derived(
|
||||
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
|
||||
$events => new Set($events.map(e => e.kind)),
|
||||
)
|
||||
|
||||
const boardEvents = deriveEventsForUrl(url, [{kinds: [PINBOARD]}])
|
||||
const hasNip29 = $derived($relay?.hasNip(29) ?? false)
|
||||
const supportedMethods = deriveSpaceSupportedMethods(url)
|
||||
const showLibrary = $derived($boardEvents.length > 0 || $supportedMethods.includes("signevent"))
|
||||
|
||||
const openSearch = () => pushModal(SpaceSearch, {url})
|
||||
</script>
|
||||
|
||||
<SecondaryNavItem href={makeSpacePath(url, "about")}>
|
||||
<Icon icon={Home} /> Space Details
|
||||
</SecondaryNavItem>
|
||||
{#if hasNip29}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
|
||||
<Icon icon={History} /> Recent Activity
|
||||
</SecondaryNavItem>
|
||||
{:else}
|
||||
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
|
||||
<Icon icon={ChatRound} /> Chat
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
|
||||
<Icon icon={UsersGroup} /> Directory
|
||||
</SecondaryNavItem>
|
||||
{#if showLibrary}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "library")}>
|
||||
<Icon icon={GalleryWide} /> Library
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
|
||||
<SecondaryNavItem href={goalsPath}>
|
||||
<Icon icon={StarFallMinimalistic} /> Goals
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(THREAD)}
|
||||
<SecondaryNavItem href={threadsPath}>
|
||||
<Icon icon={NotesMinimalistic} /> Threads
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(CLASSIFIED)}
|
||||
<SecondaryNavItem href={classifiedsPath}>
|
||||
<Icon icon={CaseMinimalistic} /> Classifieds
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(EVENT_TIME)}
|
||||
<SecondaryNavItem href={calendarPath}>
|
||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(POLL)}
|
||||
<SecondaryNavItem href={pollsPath}>
|
||||
<Icon icon={Revote} /> Polls
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if hasNip29}
|
||||
<SecondaryNavItem onclick={openSearch}>
|
||||
<Icon icon={Magnifier} /> Search
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
106
src/app/components/SpaceMenuRooms.svelte
Normal file
106
src/app/components/SpaceMenuRooms.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import {derived} from "svelte/store"
|
||||
import {createSearch} from "@welshman/app"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
||||
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
|
||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||
import {relays} from "@app/core"
|
||||
import {deriveUserCanCreateRoom} from "@app/management"
|
||||
import {deriveUserRooms, deriveOtherRooms, deriveOtherVoiceRooms, displayRoom} from "@app/rooms"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
mobile?: boolean
|
||||
}
|
||||
|
||||
const {url, mobile = false}: Props = $props()
|
||||
|
||||
const relay = $relays.one(url)
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||
const canCreateRoom = deriveUserCanCreateRoom(url)
|
||||
|
||||
const roomSearch = derived(otherRooms, $otherRooms =>
|
||||
createSearch(
|
||||
$otherRooms.map(h => ({h, name: displayRoom(url, h)})),
|
||||
{
|
||||
getValue: item => item.h,
|
||||
fuseOptions: {keys: ["name"]},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const addRoom = () => pushModal(RoomCreate, {url})
|
||||
|
||||
const clearTerm = () => {
|
||||
setTimeout(() => {
|
||||
term = ""
|
||||
}, 100)
|
||||
}
|
||||
|
||||
let term = $state("")
|
||||
</script>
|
||||
|
||||
{#snippet content()}
|
||||
{#if $userRooms.length > 0}
|
||||
{#if !mobile}
|
||||
<div class="h-2 shrink-0"></div>
|
||||
{/if}
|
||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||
{/if}
|
||||
{#each $userRooms as h (h)}
|
||||
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
|
||||
{/each}
|
||||
{#if $otherRooms.length > 0}
|
||||
{#if !mobile}
|
||||
<div class="h-2 shrink-0"></div>
|
||||
{/if}
|
||||
<SecondaryNavHeader>
|
||||
{#if $userRooms.length > 0}
|
||||
Other Rooms
|
||||
{:else}
|
||||
Rooms
|
||||
{/if}
|
||||
</SecondaryNavHeader>
|
||||
{/if}
|
||||
{#if $otherRooms.length > 20}
|
||||
<label class="input input-sm flex items-center gap-2">
|
||||
<Icon icon={Magnifier} />
|
||||
<input bind:value={term} onblur={clearTerm} class="grow" />
|
||||
</label>
|
||||
{/if}
|
||||
{#each $roomSearch.searchValues(term) as h (h)}
|
||||
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
|
||||
{/each}
|
||||
{#if $otherVoiceRooms.length > 0}
|
||||
{#if !mobile}
|
||||
<div class="h-2 shrink-0"></div>
|
||||
{/if}
|
||||
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
|
||||
{#each $otherVoiceRooms as h (h)}
|
||||
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if $canCreateRoom}
|
||||
<SecondaryNavItem onclick={addRoom}>
|
||||
<Icon icon={AddCircle} />
|
||||
Create room
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if ($relay?.hasNip(29) ?? false) && ($userRooms.length > 0 || $otherRooms.length > 0 || $otherVoiceRooms.length > 0 || $canCreateRoom)}
|
||||
{#if mobile}
|
||||
<div class="card space-menu__card flex flex-col gap-1">
|
||||
{@render content()}
|
||||
</div>
|
||||
{:else}
|
||||
{@render content()}
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -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,
|
||||
|
|
@ -49,32 +44,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(() => {
|
||||
|
|
@ -225,6 +201,7 @@
|
|||
|
||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)}
|
||||
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
|
||||
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
|
||||
<div
|
||||
class={cx(
|
||||
"relative isolate overflow-hidden rounded-2xl shadow-sm",
|
||||
|
|
@ -255,12 +232,13 @@
|
|||
{/if}
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-surface/80 px-1.5 py-0.5 text-xs">
|
||||
{labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""}
|
||||
{label}{tile.isLocal ? " (you)" : ""}
|
||||
</span>
|
||||
{#if videoTiles.length > 1}
|
||||
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
||||
<Button
|
||||
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||
aria-label={pinned ? `Exit spotlight for ${label}` : `Spotlight ${label}`}
|
||||
aria-pressed={pinned}
|
||||
class={cx(
|
||||
`button button-${pinned ? "primary" : "ghost"} button-xs button-square`,
|
||||
|
|
@ -292,7 +270,7 @@
|
|||
<div
|
||||
bind:clientWidth={gridWidth}
|
||||
bind:clientHeight={gridHeight}
|
||||
class="min-h-0 flex-1 overflow-y-auto">
|
||||
class="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
{#if tileGrid}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
{#each tileGrid.rows as row, rowIndex}
|
||||
|
|
@ -300,7 +278,7 @@
|
|||
.slice(0, rowIndex)
|
||||
.reduce((sum, r) => sum + r.columnCount, 0)}
|
||||
<div
|
||||
class="flex flex-wrap justify-center gap-2"
|
||||
class="flex flex-nowrap justify-center gap-2"
|
||||
style={`max-width: ${row.columnCount * row.tileWidth + (row.columnCount - 1) * 8}px`}>
|
||||
{#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
|
||||
<div
|
||||
|
|
@ -327,7 +305,7 @@
|
|||
<div class={panelChrome}>
|
||||
{#if mobile}
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{@render videoPanelBody()}
|
||||
</div>
|
||||
<div class="shrink-0 pb-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {spec} from "@welshman/lib"
|
||||
import {RoomEvent, Track} from "livekit-client"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
|
|
@ -8,6 +9,7 @@
|
|||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
||||
import {
|
||||
currentCallSession,
|
||||
DeviceKind,
|
||||
|
|
@ -31,6 +33,11 @@
|
|||
let selectedInput = $state("")
|
||||
let selectedOutput = $state("")
|
||||
let selectedVideo = $state("")
|
||||
let micTrack = $state<MediaStreamTrack | undefined>(undefined)
|
||||
|
||||
const readMicTrack = (session: CallSession): MediaStreamTrack | undefined =>
|
||||
session.livekit.localParticipant.getTrackPublication(Track.Source.Microphone)?.track
|
||||
?.mediaStreamTrack
|
||||
|
||||
const loadDevices = async () => {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||
|
|
@ -61,10 +68,29 @@
|
|||
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
||||
|
||||
// Mic mute/unmute publishes or unpublishes the microphone track; keep the
|
||||
// level meter in sync with whichever track is actually live right now.
|
||||
const {livekit} = session
|
||||
const refreshMicTrack = () => {
|
||||
micTrack = readMicTrack(session)
|
||||
}
|
||||
refreshMicTrack()
|
||||
livekit.on(RoomEvent.LocalTrackPublished, refreshMicTrack)
|
||||
livekit.on(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
|
||||
return () => {
|
||||
livekit.off(RoomEvent.LocalTrackPublished, refreshMicTrack)
|
||||
livekit.off(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
|
||||
}
|
||||
})
|
||||
|
||||
const onInputChange = () => {
|
||||
void switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
|
||||
const onInputChange = async () => {
|
||||
await switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
|
||||
// Switching the active device restarts the existing LocalAudioTrack in
|
||||
// place with a new MediaStreamTrack rather than emitting a publish event,
|
||||
// so the meter needs an explicit re-read here.
|
||||
const session = $currentCallSession
|
||||
if (session) micTrack = readMicTrack(session)
|
||||
}
|
||||
|
||||
const onOutputChange = () => {
|
||||
|
|
@ -109,6 +135,7 @@
|
|||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<MicLevelMeter track={micTrack} offMessage="Microphone is off — unmute to test it" />
|
||||
{#if canPickOutput}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@
|
|||
|
||||
const {url, h, replaceState = false, notification = false}: Props = $props()
|
||||
|
||||
// Beyond this many participants, a full name+avatar row per person makes the
|
||||
// sidebar item too tall — fall back to the compact ProfileCircles cluster instead.
|
||||
const ACTIVE_LIST_MAX = 5
|
||||
|
||||
const participants = deriveCallParticipants(url, h)
|
||||
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
||||
const isActive = $derived(
|
||||
|
|
@ -85,7 +89,7 @@
|
|||
<RoomName {url} {h} />
|
||||
</div>
|
||||
{#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)}
|
||||
<div class="flex items-center gap-2 ml-6">
|
||||
|
|
|
|||
|
|
@ -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<MediaDeviceInfo[]>([])
|
||||
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<MediaStreamTrack | undefined>(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 @@
|
|||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{#if !startWithoutMic}
|
||||
<MicLevelMeter
|
||||
track={previewTrack}
|
||||
error={micError
|
||||
? "Could not access this microphone. Check your browser permissions, or join without one."
|
||||
: undefined} />
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<Button
|
||||
class="min-w-0 flex-1 rounded-xl px-2 py-1 text-left hover:bg-surface"
|
||||
onclick={goToRoom}
|
||||
aria-label="Open room {roomName}">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if $callState === CallState.Joining}
|
||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||
{:else if $callState === CallState.Connected}
|
||||
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||
{:else}
|
||||
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
|
||||
{/if}
|
||||
<span class="truncate min-w-0 text-xs text-content-muted">
|
||||
{roomName} / {spaceName}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
{#if showChatButton}
|
||||
<Button
|
||||
data-tip="Toggle Chat"
|
||||
class={cx(
|
||||
"button button-sm button-square tip tip-top relative",
|
||||
isChatPanelActive && "button-primary",
|
||||
)}
|
||||
onclick={onChatToggle}>
|
||||
<Icon icon={ChatRound} size={4} />
|
||||
{#if chatUnread}
|
||||
<span
|
||||
transition:fade={{duration: 150}}
|
||||
class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"
|
||||
aria-hidden="true"></span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
class="w-full min-w-0 rounded-xl px-2 py-1 text-left hover:bg-surface"
|
||||
onclick={goToRoom}
|
||||
aria-label="Open room {roomName}">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if $callState === CallState.Joining}
|
||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||
{:else if $callState === CallState.Connected}
|
||||
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||
{:else}
|
||||
<span class="text-sm font-semibold text-content-muted">Disconnected</span>
|
||||
{/if}
|
||||
<span class="truncate min-w-0 text-xs text-content-muted">
|
||||
{roomName} / {spaceName}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
{#if $callState === CallState.Joining}
|
||||
<Spinner size="sm" />
|
||||
<Button
|
||||
data-tip="Cancel"
|
||||
class="button button-sm button-square tip tip-top"
|
||||
aria-label="Cancel joining voice room"
|
||||
class="button button-neutral col-span-3 flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||
onclick={cancelJoinVoiceRoom}>
|
||||
<Icon icon={CloseCircle} size={4} />
|
||||
<span class="text-[10px] leading-none font-semibold">Cancel</span>
|
||||
</Button>
|
||||
{:else if $callState === CallState.Connected && $currentCallSession}
|
||||
<Button
|
||||
data-tip={$callMicMuted ? "Unmute" : "Mute"}
|
||||
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
|
||||
aria-pressed={!$callMicMuted}
|
||||
class={cx(
|
||||
"button button-sm button-square tip tip-top",
|
||||
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5",
|
||||
$callMicMuted ? "button-neutral" : "button-primary",
|
||||
)}
|
||||
onclick={toggleMute}>
|
||||
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={$callMicMuted ? 4 : 4.5} />
|
||||
<span class="text-[10px] leading-none font-semibold">
|
||||
{$callMicMuted ? "Unmute" : "Mute"}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
data-tip={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||
aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||
aria-pressed={$currentCallSession.cameraOn}
|
||||
class={cx(
|
||||
"button button-sm button-square tip tip-top",
|
||||
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5",
|
||||
$currentCallSession.cameraOn ? "button-primary" : "button-neutral",
|
||||
)}
|
||||
onclick={toggleCamera}>
|
||||
<Icon icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff} size={4} />
|
||||
<span class="text-[10px] leading-none font-semibold">
|
||||
{$currentCallSession.cameraOn ? "Stop" : "Start"}
|
||||
</span>
|
||||
</Button>
|
||||
{#if !Capacitor.isNativePlatform()}
|
||||
<Button
|
||||
data-tip={$currentCallSession.screenShareOn ? "Stop sharing" : "Share screen"}
|
||||
aria-label={$currentCallSession.screenShareOn ? "Stop sharing screen" : "Share screen"}
|
||||
aria-pressed={$currentCallSession.screenShareOn}
|
||||
class={cx(
|
||||
"button button-sm button-square tip tip-top",
|
||||
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5",
|
||||
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
|
||||
)}
|
||||
onclick={toggleScreenShare}>
|
||||
<Icon icon={Monitor} size={3.75} />
|
||||
<span class="text-[10px] leading-none font-semibold">
|
||||
{$currentCallSession.screenShareOn ? "Stop share" : "Share"}
|
||||
</span>
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
data-tip="Call settings"
|
||||
class="button button-neutral button-sm button-square tip tip-top"
|
||||
aria-label="Call settings"
|
||||
class="button button-neutral w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||
onclick={openCallSettings}>
|
||||
<Icon icon={Settings} size={4} />
|
||||
<span class="text-[10px] leading-none font-semibold">Settings</span>
|
||||
</Button>
|
||||
<Button
|
||||
data-tip="Leave room"
|
||||
class="button button-error button-sm button-square tip tip-top"
|
||||
aria-label="Leave voice room"
|
||||
class="button button-error w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||
onclick={leaveVoiceRoom}>
|
||||
<Icon icon={EndCall} size={4.5} />
|
||||
<span class="text-[10px] leading-none font-semibold">Leave</span>
|
||||
</Button>
|
||||
{#if showChatButton}
|
||||
<Button
|
||||
aria-label="Toggle chat panel"
|
||||
aria-pressed={isChatPanelActive}
|
||||
class={cx(
|
||||
"button w-full flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5 relative",
|
||||
isChatPanelActive ? "button-primary" : "button-neutral",
|
||||
)}
|
||||
onclick={onChatToggle}>
|
||||
<Icon icon={ChatRound} size={4} />
|
||||
<span class="text-[10px] leading-none font-semibold">Chat</span>
|
||||
{#if chatUnread}
|
||||
<span
|
||||
transition:fade={{duration: 150}}
|
||||
class="absolute right-2 top-1.5 h-2 w-2 rounded-full bg-primary"
|
||||
aria-hidden="true"></span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button
|
||||
data-tip="Join Voice"
|
||||
class="button button-primary button-sm button-square tip tip-top"
|
||||
aria-label="Join voice room"
|
||||
class="button button-primary col-span-3 flex-col !h-auto gap-0.5 rounded-xl px-2 py-1.5"
|
||||
onclick={openJoinDialog}>
|
||||
<Icon icon={PhoneCallingRounded} size={4.5} />
|
||||
<span class="text-[10px] leading-none font-semibold">Join</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import {
|
|||
} from "@welshman/app"
|
||||
import type {AppPolicy, DerivedPlugin, Plugin, Session} from "@welshman/app"
|
||||
import type {BaseEventReader, EventWriter, KindFactory} from "@welshman/domain"
|
||||
import {maybeMakeRelayMockAdapter} from "@lib/test/relayMocks"
|
||||
import {DEFAULT_RELAYS, DEFAULT_SEARCH_RELAYS, DUFFLEPUD_URL, INDEXER_RELAYS} from "@app/env"
|
||||
|
||||
// Flotilla's own policies (ingest, sockets, storage) can't be imported here — they depend on
|
||||
|
|
@ -55,9 +54,6 @@ export const appPolicies: AppPolicy[] = [
|
|||
const makeApp = (user?: User) => {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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>("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>("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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
10
src/lib/test/env.ts
Normal file
10
src/lib/test/env.ts
Normal file
|
|
@ -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<string> =>
|
||||
(globalThis as {[TEST_ENV_KEY]?: Record<string, string>})[TEST_ENV_KEY]?.[key]
|
||||
|
|
@ -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<string, TrustedEvent[]>
|
||||
}
|
||||
|
||||
// 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<string, Repository>()
|
||||
|
||||
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<AdapterFactory> => {
|
||||
const config = (globalThis as {[RELAY_MOCKS_KEY]?: RelayMockConfig})[RELAY_MOCKS_KEY]
|
||||
|
||||
if (config) {
|
||||
return makeRelayMockAdapter(config)
|
||||
}
|
||||
}
|
||||
19
src/lib/test/session.ts
Normal file
19
src/lib/test/session.ts
Normal file
|
|
@ -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<Session> =>
|
||||
(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] ?? []
|
||||
|
|
@ -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,9 +33,11 @@
|
|||
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"
|
||||
import {syncCallAudioResume} from "@app/call"
|
||||
import {getPageTitle} from "@app/title"
|
||||
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
|
||||
|
||||
|
|
@ -162,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())
|
||||
|
||||
|
|
@ -239,7 +244,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
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue