diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 00000000..c8eaa0b1 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [dev] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-check-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install dependencies + run: corepack enable && pnpm i --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Check + run: pnpm run check + + - name: Build + run: pnpm run build \ No newline at end of file diff --git a/.gitea/workflows/container-publish.yml b/.gitea/workflows/container-publish.yml index 026482f0..77699b76 100644 --- a/.gitea/workflows/container-publish.yml +++ b/.gitea/workflows/container-publish.yml @@ -14,8 +14,30 @@ env: IMAGE_NAME: coracle/flotilla jobs: + lint-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install dependencies + run: corepack enable && pnpm i --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Check + run: pnpm run check + build-and-push-image: runs-on: ubuntu-latest + needs: lint-check permissions: contents: read packages: write diff --git a/AGENTS.md b/AGENTS.md index 5a898701..1dff8651 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ pnpm run check # Type check **Welshman Development:** - Clone welshman to parent directory -- Use `./link_deps` script to link local welshman packages +- Use `./scripts/link-deps.mjs` to link local welshman packages - Avoid committing `pnpm.overrides` changes **Git Workflow:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c26cfde..609cce95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +# 1.9.1 + +* Re-work how claims work under the hood +* Fix storage and synchronization bugs +* Update welshman library to 0.9.0 +* And long-form articles +* Fix quote loading, rendering, and comments +* Add "create thread" from chat message +* Fix voice chat bugs +* Fix notification bugs +* Improve video tiling and chrome +* Add ability to send logs to developer +* Add theme selector +* Add native sharing registration + # 1.9.0 * Fix nav safe are inset, qr code reactivity diff --git a/Dockerfile b/Dockerfile index 021a8e27..79880306 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ # docker run -p 3000:3000 flotilla # # Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build. -# A .env in the build context is picked up by build.sh for branding config. +# A .env in the build context is picked up by scripts/build.sh for branding config. # https://pnpm.io/docker#example-3-build-on-cicd FROM node:24-slim AS builder diff --git a/README.md b/README.md index 29d5886f..ac843917 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ You can also optionally create an `.env.local` file and populate it with the fol - `VITE_POMADE_SIGNERS` - A comma-separated list of Pomade signer server URLs (3+ required to enable email signup) - `VITE_THUMBNAIL_URL` - URL of the image thumbnail service -These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container. +These values **won't** be used for a built version. Instead, env variables should be provided to `scripts/build.sh` directly or to the built container. If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer. diff --git a/android/app/build.gradle b/android/app/build.gradle index c172b21c..f8eadebb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -8,8 +8,8 @@ android { applicationId "social.flotilla" minSdk rootProject.ext.minSdkVersion targetSdk rootProject.ext.targetSdkVersion - versionCode 50 - versionName "1.9.0" + versionCode 51 + versionName "1.9.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md index bafc6a07..32e92fd0 100644 --- a/e2e/ARCHITECTURE.md +++ b/e2e/ARCHITECTURE.md @@ -1,8 +1,7 @@ # 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. +test's control. Nothing in this directory opens a connection to a host the test did not create. ## The relay @@ -17,13 +16,17 @@ 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. +harness. `space` and `other` are the same permissive policy, for specs that need two relays; `closed` refuses a join that carries no invite claim, which is what raises "Request +Access"; `unsigned` serves events with their signatures stripped, which is what raises "Do you trust +this space?". Seeding a membership on `closed` is therefore not possible — `join()` and `member()` +publish a claimless join and the relay refuses it — so a scenario there seeds admin-created rooms +and lets the spec do the joining. ### Why the relays are called `.test` The container listens on plaintext loopback, but a url that is local or insecure is dropped from every relay selection unless the caller opts in — see `isLocalUrl` and `RelaySelection.getUrls` in -`@welshman/util` — and Flotilla never opts in, since in production neither belongs in a routing +`@welshman/util` — and the app 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. @@ -59,30 +62,29 @@ browser context ──▶ context.routeWebSocket(everything but vite's hmr socke 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 +Every socket the browser opens is terminated in the node process, and the only one that leaves 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 +Interception is installed by `as()` and `visit()`, on a context each of them 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 +`playwright` is the one fixture left alone, because 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 +Interception in Node rather than in the page enables multi-user testing. 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`/ +An `AdapterFactory` backed by 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, @@ -92,7 +94,7 @@ replay-after-auth and reconnect are all exercised as written. 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 +in dev are never 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. @@ -105,13 +107,21 @@ admin, since a relay refuses management calls from anyone else and the method li 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 +Services the app talks to are mocked 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 +analytics script hard-coded in `src/app.html` is mocked with an empty body to avoid making `assertNoBlockedRequests()` a statement about the page shell rather than about the test. +Two of those answers are the scenario's own. NIP-11 fields a spec names are merged over the relay's +real document — a `redirect_to`, a `limitation`, a NIP the relay does not implement — rather than +replacing it, because `self`, `pubkey` and `supported_nips` are what room state is trusted from, and +the merge is installed with the page, since the document is read at startup and cached from then on. +The hosting API is a small stateful fake rather than fixed answers: a write mutates the record it +names and the next read sees it, so editing a space, deactivating it or paying an invoice is +observable. `getHosting(context)` changes the backend's mind between two of the user's clicks — a +custom domain that verifies, an invoice that gets paid. + ## Containment A browser has a fixed set of ways to put bytes on a wire. @@ -147,7 +157,7 @@ values it does not override still name real hosts — the blossom server, the po 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. +configuration. 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, @@ -199,6 +209,8 @@ would have refused fails the test instead of appearing in a query. 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. +`makeTestUser(name)` derives a fifth, sixth or hundredth one from its name and registers it, for the +directory and search specs that need more people than there are named ones. 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 @@ -232,6 +244,24 @@ 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. +An injected session is re-applied on every navigation, so through `as()` a reload, a logout and a +login are all unobservable. `visit(path, options)` is the same page without one — same context, same +interception, same env — and it is what a spec that watches someone arrive, sign in and come back +starts from. Both take the same options, over and above the scenario's own relays: + +| option | what it does | +| ----------- | ------------------------------------------------------------------------------------ | +| `context` | merged over the project's context options: a viewport, a colour scheme, a permission | +| `env` | `VITE_` values applied over the ones derived from the scenario's relays | +| `nip07` | a `window.nostr` backed by that identity's own signer, for an extension login | +| `relayInfo` | fields merged over a relay's own NIP-11 document, keyed by relay url | +| `hosting` | what the hosting backend already knows about this user | + +Whatever `env` names still has to be something the scenario owns — a platform relay, the domain +hosted spaces are created under — or the app dials a host nothing serves and the test fails on a +leak. The NIP-07 provider is a real signer rather than a stub, because the session it produces has to +sign the NIP-42 challenges the members-only relays send. + ## Layout ``` @@ -256,9 +286,11 @@ e2e/ boot.ts env overrides, navigate, wait for mount and for the app to have read the overrides session.ts NIP-01 session injection + nip07.ts a window.nostr backed by a test identity's own signer seed/ scenario.ts the `seed()` builder and relative-time helpers - space.ts one space's fixtures: rooms, members, messages, replies, profiles + space.ts one space's fixtures: rooms, members, messages, replies, profiles, + direct messages, and anything a domain writer renders specs/ *.spec.ts ``` @@ -272,13 +304,23 @@ importing anything under `src` would pull sveltekit into the node process. `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. +fresh database. What it mounts as its config is a copy of `docker/config`, staged outside the repo +and refreshed on the way up — zooid saves a relay's toml back when a NIP-86 call edits its name — 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. +rest of the test. A fixture must therefore be signed by an identity `harness/keys.ts` holds, since +zooid authenticates every write. + +Anything richer than a room message is built by the domain writers the app itself publishes with: +`space.kind(Article)` hands back the kind configured against this space, and `space.event(user, () => +…renderTemplate())` defers the render until the space has a url to render hints against, which is +only true once the queue has drained. A NIP-17 conversation is `space.dm(from, to, content)`: it +gift-wraps one rumor per participant and publishes each wrap over the sender's connection, since a +wrap is signed by an ephemeral key nobody here can authenticate as. zooid stores it anyway, because +it authorizes a kind-1059 by the member named in its `p` tag — and refuses one addressed to a +stranger. ## Running @@ -292,9 +334,9 @@ pnpm test # starts and stops the con 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 +One engine per run. These specs exercise sockets, auth and sync, so running them under three +engines adds little coverage. `E2E_BROWSER=webkit pnpm test` runs the whole +suite under another one. The container listens on a fixed port and cannot be sharded, so `workers` is 1. `src/app/env.ts` resolves every `VITE_` value through a DEV-only hook that prefers diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md new file mode 100644 index 00000000..bfb8f6f5 --- /dev/null +++ b/e2e/USER_STORIES.md @@ -0,0 +1,1570 @@ +# Flotilla user stories + +The catalog e2e specs are written from. Each story is a slice of behavior a +person can observe in the running app. Specs reference stories by stable +id (`US-042`), so numbers are never reused or renumbered. + +**Personas** come from `e2e/harness/keys.ts`, which defines four deterministic +identities: + +- **alice**, **bob**, **carol** — ordinary members. Multi-user stories give each + their own browser context against the same relay, so one genuinely observes + another's writes over the wire. +- **admin** — the space admin, recognized by the relay's NIP-86 answers, which + is what unlocks the space, room, event and directory management surfaces. + +The test architecture is described in `e2e/ARCHITECTURE.md`: real zooid relays in +docker, with every socket and http request terminated in the test process. +Services with a mock seam are mocked per scenario. Features that live past a +boundary with no seam are listed under "Out of scope" below. + +## Onboarding & authentication + +### US-001 — Sign-in gate for logged-out visitors + +As a first-time visitor, I want a clear log-in-or-sign-up screen wherever I +land, so that I know an identity is required before I can use anything. + +Acceptance: + +- Visiting any url while logged out shows a "Welcome" modal offering "Log in" + and "Create an account", with links to the terms of service and privacy + policy. +- The modal cannot be dismissed with escape or by clicking outside it. +- Each button opens its respective flow. + +### US-002 — Sign up by generating a new key + +As a new user with no nostr identity, I want to create a profile and generate a +key in the app, so that I can start participating without an external tool. + +Acceptance: + +- Entering a display name advances to a key-backup step whose "Continue" button + stays disabled until a key file has been downloaded. +- Choosing the encrypted download requires a password of at least 12 characters + and produces a file containing an ncryptsec rather than a plain nsec. +- Finishing the flow logs the new user in, dismisses the dialog, and lands on + the dashboard with the platform's default space visible. +- The display name entered during signup appears on the new user's own profile. + +### US-003 — Log in with an existing private key + +As a returning user holding my own key, I want to paste it to log in, so that I +don't need an extension or signer app. + +Acceptance: + +- Pasting alice's nsec logs her in as her own pubkey; pasting the equivalent + 64-character hex key behaves identically. +- Pasting an ncryptsec reveals a password field: the correct password logs in, a + wrong one shows an error and stays logged out. +- Text that is not a valid key leaves the submit button disabled. +- bob logging in with his own key in a separate context sees his own identity, + not alice's. + +### US-004 — Log in with a browser extension + +As a user with a NIP-07 extension, I want to log in with one click, so that my +key never reaches the app. + +Acceptance: + +- With a NIP-07 provider present, the log-in screen offers "Log in with + Extension". +- Clicking it logs the user in as the pubkey the extension returns, without ever + displaying a private key. +- With no provider present, the button is absent. + +### US-005 — Log in with a remote signer + +As a security-conscious user, I want to connect a remote signer by pasting a +bunker link, so that my key stays on another device. + +Acceptance: + +- A bunker link with no signer pubkey is rejected with an "invalid bunker link" + message and no connection attempt. +- A bunker link carrying no relays is rejected with a message naming that as the + reason. +- Switching to "Log in with a QR code instead" shows a connection code view, and + "Go back" returns to the link input. + +### US-006 — Stay logged in, and log out deliberately + +As a logged-in user, I want the app to remember me across reloads and to confirm +before logging out, so that I neither re-authenticate constantly nor lose local +data by accident. + +Acceptance: + +- After logging in and reloading, alice is still logged in and the sign-in gate + does not reappear; her spaces and settings are still hers. +- "Log Out" opens a confirmation warning that the local database will be + cleared, and "Go back" leaves her logged in. +- Confirming clears her session and returns her to the sign-in gate. + +### US-007 — Inspect your keys and signer status + +As a logged-in user, I want to see my keys and how my signer is behaving, so +that I can back up my identity and tell whether requests are succeeding. + +Acceptance: + +- Settings › Profile shows alice's npub in a read-only field whose copy button + puts it on the clipboard. +- A session logged in with a raw key also shows a masked nsec field with its own + copy button; an extension or remote-signer session shows no private key at + all. +- A signer status card names the login method and reports "Ok", and its + succeeded-request count increases after an action that requires a signature. + +### US-008 — Delete your nostr account + +As a user leaving the network, I want a guarded way to delete my account +everywhere, so that I can't do it by accident and I understand it is final. + +Acceptance: + +- Settings › Profile › Advanced › "Delete your profile" opens a dialog whose + submit stays disabled until the exact confirmation phrase is typed. +- Confirming shows a progress indicator while deletion requests publish, then + logs alice out and returns her to the sign-in gate. +- bob viewing alice's profile afterward sees her name blanked out. + +## Spaces + +### US-009 — Browse, search, and reorder your spaces + +As alice, I want to see the spaces I've joined and find new ones, so that I can +get where I'm going and discover communities. + +Acceptance: + +- `/spaces` shows a "Your spaces" section listing every space alice has joined + and a "Browse Spaces" section of the rest. +- Typing a term filters both sections live, matching name, url, or description. +- Clicking a joined space opens it; clicking one she hasn't joined opens a join + prompt instead. +- Dragging a joined space above another reorders the list immediately, and the + order survives a reload. + +### US-010 — Join a space from an invite link + +As alice, I want to paste an invite link or relay url to join a space, so that I +can start participating without already being a member. + +Acceptance: + +- "Add Space" › "Join a space" accepts a valid invite link and shows a preview + of the space before she confirms. +- Confirming joins the space, shows a welcome message, and takes her into it. +- An unparseable link leaves the join button disabled and shows no preview. +- Navigating directly to the url of a space she hasn't joined opens the same + join prompt automatically, and going back leaves her un-joined. + +### US-011 — Request access when a space turns you away + +As alice, I want a way forward when a space refuses my join, so that a code or +an appeal can still get me in. + +Acceptance: + +- A space that requires an invite code shows a "Request Access" state instead of + joining her; a correct code grants access and an incorrect one shows an error + without joining her. +- When admin has banned bob and he revisits a space he belongs to, he sees an + access-error modal quoting the relay's reason. +- From that modal bob can leave the space, which removes it from his list, or + open the same invite-code request flow. + +### US-012 — Decide whether to trust an unsigned space + +As alice, I want a warning before using a space whose operator can forge +messages, so that I can decide whether it is safe. + +Acceptance: + +- Opening a space that publishes no signatures shows a "Do you trust this + space?" prompt before she can use it. +- Trusting it dismisses the prompt and lets her continue. +- Declining removes the space from her joined spaces and returns her home. + +### US-013 — Follow a space that has moved + +As alice, I want to be told when a space changes address, so that I keep talking +to the right relay. + +Acceptance: + +- A relay reporting a new address shows a "This space has moved" modal naming + both addresses. +- Confirming updates her space list to the new url and takes her there. +- "Not now" leaves her on the old address. + +### US-014 — Leave a space + +As bob, I want to leave a space I've lost interest in, so that it stops +cluttering my list. + +Acceptance: + +- "Leave Space" from the space menu asks for confirmation. +- Confirming removes the space from `/spaces` and returns him to the home + screen. +- He can rejoin later from search or an invite link. + +### US-015 — View a space's details + +As carol, I want to read a space's public information, so that I can size it up +before or after joining. + +Acceptance: + +- The About page shows the space's icon, name, url, description, and any + terms-of-service or privacy-policy links. +- Administrator, contact, software, and version badges appear when the relay + reports them, and auth-required, payment-required, or proof-of-work warnings + appear when they apply. +- A members summary listing admins and newest members links through to the full + directory. +- Content admin has featured renders at the top for every visitor; with none, a + recent-activity summary appears instead. + +### US-016 — Catch up on a space's recent activity + +As bob, I want one feed of what's new across a space, so that I don't have to +open every room. + +Acceptance: + +- "Recent Activity" lists the latest message from each visible room alongside + recent posts and threads, newest first. +- A new message in a previously quiet room moves that room's entry to the top. +- Scrolling to the bottom loads older items, and a space with nothing in it + shows "No recent activity found". + +### US-017 — Search across a space + +As alice, I want to search a whole space at once, so that I can find something +when I don't remember which room it was in. + +Acceptance: + +- Typing a term returns matches from more than one room, grouped into Last 24 + Hours / Last 7 Days / Older, each labeled with the room it came from. +- Clicking a result navigates into that room at the matching message. +- A term with no matches shows a "No results found" state. + +## Rooms & chat + +### US-018 — Send and receive a room message in real time + +As alice, I want to send a message and have bob see it appear live, so that we +can actually hold a conversation. + +Acceptance: + +- Typing in the composer and pressing Enter sends the message, clears the + composer, and shows it at the bottom of the timeline with alice's name, + avatar, and timestamp. +- Shift+Enter inserts a newline instead of sending, and Cmd/Ctrl+Enter sends + from mid-line. +- bob, already viewing the same room in his own session, sees the message appear + without reloading. + +### US-019 — Join and leave a room + +As bob, I want to join a room's member list and leave it later, so that it shows +up among my rooms only while I care about it. + +Acceptance: + +- Clicking "Join Room" moves the room under "Your Rooms" in his sidebar. +- Other members see a centered " joined the room" line in the timeline, + linking to his profile. +- "Leave member list" from the room menu confirms with a toast and the room is + no longer marked as joined. + +### US-020 — Create, edit, and delete a room + +As admin, I want to add a room, adjust its settings later, and remove it when +it's done, so that the space's structure matches how people actually use it. + +Acceptance: + +- Creating a room with a name and description navigates admin into it and lists + it under "Your Rooms". +- Editing the room's name and toggling "Only allow members to read messages" + updates the header and sidebar, and the detail panel shows a "Private" badge; + reopening the edit form shows the toggles as saved. +- Deleting the room after confirming returns admin to the space's default view + and removes it from the sidebar. +- bob sees neither "Edit Room" nor "Delete Room" in the room's detail menu. + +### US-021 — Request access to a private room and get approved + +As carol, I want to ask to join a closed room and be let in, so that I can read +and post there. + +Acceptance: + +- Opening the private room shows a "not a member" message instead of history, + with an "Ask to Join" button. +- After she requests access, her button shows a pending state. +- Once admin accepts the request, carol can read the room's messages and send + her own. + +### US-022 — Bring people into a room + +As admin, I want to invite people to a specific room or add them directly, so +that I can populate a closed room without waiting on requests. + +Acceptance: + +- "Create invite" shows a QR code and a copyable invite url; copying confirms, + and opening that link as carol lands her in the right space and room with a + join option. +- The room's member list shows every current member; searching for and adding + someone shows a success toast and lists them, and adding a person who isn't a + space member offers to add them to the space too. +- Removing a member after confirming takes them off the list. +- bob viewing the same member list sees no "Add members" control. + +### US-023 — Reply to a message + +As bob, I want to reply to alice's message directly, so that my response is +clearly attached to what she said. + +Acceptance: + +- Clicking reply shows a "Replying to alice" banner quoting her message above + the composer. +- Sending posts a reply that renders her original as an embedded quote for + everyone in the room. +- Dismissing the banner with its close button or Escape clears the reply without + sending. + +### US-024 — Edit or delete a message you sent + +As alice, I want to fix or retract a message I just sent, so that the room shows +what I meant. + +Acceptance: + +- Alice's own recent message offers edit and delete; bob's message offers her + neither. +- Editing pre-fills the composer with an "Editing message" banner, and + submitting replaces the text in place, at its original position, for bob as + well. +- Pressing the up arrow in an empty composer opens her most recent editable + message; closing the banner cancels without changing anything. +- Deleting after confirming removes the message from both alice's and bob's view + of the room. + +### US-025 — React to a message + +As carol, I want to react with an emoji, so that I can respond without writing a +message. + +Acceptance: + +- The quick reaction button opens an emoji picker, and choosing one adds a + reaction pill under the message for everyone in the room. +- When bob and carol react with the same emoji it shows as one pill with a count + of 2. +- Clicking her own reaction again removes it. +- On a narrow viewport, tapping a pill she hasn't joined shows the list of + reactors instead of adding her own. + +### US-026 — Pin a message and browse pins + +As admin, I want to pin key messages, so that important information stays +findable. + +Acceptance: + +- "Pin Message" confirms with a toast and raises a pinned banner at the top of + the room showing the message. +- "View all pinned" lists the message with a working "Jump to message". +- Unpinning from that list removes both the entry and the banner. + +### US-027 — Find a past message and jump to it + +As a room member, I want to search a room and follow message links, so that I +can get back to something without scrolling. + +Acceptance: + +- Room search returns matching messages grouped into Last 24 Hours / Last 7 Days + / Older, and a term with no matches shows "No results found". +- Clicking a result closes search and scrolls the timeline to the message, + highlighted in view. +- Opening a permalink url for a specific message lands on that message directly, + with a "scroll to bottom" control shown since the view is no longer at the + newest message. + +### US-028 — Share a message somewhere else + +As alice, I want to forward a message into another room in the same space, so +that I can pass it along without retyping it. + +Acceptance: + +- "Share" on a message inside a space opens a picker of that space's rooms. +- Choosing a destination navigates there with the composer pre-filled with a + quote of the shared message. +- Sending posts the quoted message in the destination, visible to bob there. + +## Direct messages + +### US-029 — Start a one-on-one chat + +As alice, I want to start a private conversation by searching for someone, so +that I can message them directly. + +Acceptance: + +- "Start New Chat" suggests bob as she types his name, and selecting him plus + "Create Chat" opens a conversation with a usable composer. +- The conversation appears at the top of her list, labeled with bob's name. +- Bob's profile page offers a "Message" button that opens the same conversation; + her own profile offers none. + +### US-030 — Start a group chat + +As alice, I want to message several people at once, so that we can all talk in +one place. + +Acceptance: + +- Selecting both bob and carol in the start-a-chat dialog opens a single + conversation containing both. +- The header shows a combined label rather than one name. +- Opening the conversation's member list shows both participants, and clicking + one opens their profile. + +### US-031 — Direct messaging has to be switched on + +As alice, I want to be told when messaging isn't set up — mine or my recipient's +— so that I understand why a conversation looks stuck. + +Acceptance: + +- Entering chat before ever enabling DMs shows an "Enable direct messaging?" + dialog instead of the chat UI; enabling proceeds to the flow she started and + the prompt does not return. +- Opening a conversation with bob, who has never enabled DMs, shows a banner + naming him and leaves the composer disabled. +- After bob enables DMs and alice reopens the conversation, the banner is gone + and the composer works. + +### US-032 — Exchange messages in a conversation + +As alice, I want to send messages to bob and read the thread easily, so that we +can hold a conversation. + +Acceptance: + +- Sending adds the message to alice's thread immediately, attributed to her, and + clears the composer. +- The message appears in bob's already-open conversation without a reload. +- Messages render chronologically with a date divider between days, and + consecutive messages from the same sender show the name and avatar once rather + than on each. + +### US-033 — Browse and search your conversations + +As alice, I want all my conversations in one list I can filter, so that I can +find the right one fast. + +Acceptance: + +- The chat list shows every conversation, most recently active first, each with + participant names, a last-message preview, and a timestamp. +- Typing bob's name narrows the list to conversations involving him, and + clearing it restores the full list. + +### US-034 — Track and clear unread conversations + +As alice, I want unread conversations to stand out and be clearable in bulk, so +that I always know what's new without opening everything. + +Acceptance: + +- A message bob sends while alice is elsewhere marks that conversation unread in + her list. +- Visiting the conversation and navigating away clears its indicator. +- "Mark all read" from the chat menu clears every conversation's indicator at + once, and they stay clear on return. + +### US-035 — Reply to, edit, and react to a direct message + +As alice, I want the same message-level actions in chat as in rooms, so that +conversations are as expressive as rooms are. + +Acceptance: + +- Choosing reply on bob's message shows a preview above the composer, and the + sent reply visibly quotes his message; the preview's close button clears it + without sending. +- "Edit Message" on alice's own recent message loads it into the composer with + an editing indicator, and submitting replaces that message's content for both + of them rather than adding a new one; pressing up in an empty composer starts + editing her most recent message. +- Choosing an emoji from the reaction picker shows the reaction under the + message for both, and clicking her own reaction again removes it. + +### US-036 — Receive a new conversation live + +As alice, I want a conversation someone starts with me to appear on its own, so +that I never miss a first message. + +Acceptance: + +- With alice's chat list open and bob starting a new chat from his own session, + a conversation with bob appears in her list without a reload. +- Opening it shows his message. + +## Articles & threads + +### US-037 — Write and publish an article + +As alice, I want to publish a long-form article to a space, so that members can +read something longer than a chat message. + +Acceptance: + +- Submitting with no title, or with a title and no body, shows an error toast + and publishes nothing. +- Publishing a complete article closes the modal and puts it at the top of the + space's Articles list. +- Closing the composer without publishing preserves the entered title and body + when it is reopened; after a successful publish the composer starts blank. + +### US-038 — Browse, filter, and read articles + +As bob, I want to narrow a space's articles and read one in full, so that I can +find and consume what interests me. + +Acceptance: + +- The Articles page lists each article with its author, published date, and a + preview; clicking an author or a topic filters the list, and combining both + narrows to articles matching both. +- Opening an article shows its title, cover image, summary, published date, and + full content, with the title matching its list card. +- Markdown in the body renders as real headings, bold text, and bullet lists, + and a raw npub reference renders as an "@displayname" link. + +### US-039 — Comment on an article + +As bob and carol, we want to comment on an article and reply to each other, so +that we can discuss it under the piece itself. + +Acceptance: + +- Bob's comment on alice's article appears listed under it. +- Carol's reply to bob renders nested beneath his comment rather than as a + top-level comment. +- Alice sees both and can add her own comment at the root, optionally attaching + an image to it. + +### US-040 — React to a post with an emoji + +As bob, I want to react to articles, comments, thread posts, and notes, so that +I can respond without writing. + +Acceptance: + +- Clicking an emoji on an article records bob's reaction with a count of 1, + highlighted as his own. +- Clicking the same emoji again removes it and the count returns to 0. +- The same behavior applies to a comment on the article, to a post inside a + thread, and to a note on someone's profile. + +### US-041 — Publish an article from a room + +As alice, I want an article I write inside a room to be announced there, so that +the room sees it without me posting a second time. + +Acceptance: + +- Publishing from a room's compose menu posts a quote of the new article into + that room's chat. +- The article's detail page and list card show a "Posted in #room" badge. +- Clicking that badge navigates to the room it names. + +### US-042 — Start a thread and see it filed under its room + +As alice, I want threads grouped by the room they belong to, so that the Threads +page stays organized. + +Acceptance: + +- Creating a thread from a room's compose menu files it under that room's board + and posts a quote of it into the room's chat. +- Creating a thread from the top-level Threads page files it under a "General" + board. +- Each board row shows the topic title, author, reply count, and last-post time. + +### US-043 — Reply to a thread and to a specific post + +As bob and carol, we want to answer a thread as a whole or one post inside it, +so that responses connect to the right thing. + +Acceptance: + +- "Reply to thread" opens a composer with no quoted parent, and submitting + appends a post and increments the reply count in the header. +- "Reply" on a single post shows "Replying to" that post, which can be cleared + before submitting. +- The thread's opening post carries an "OP" badge on every page. + +### US-044 — Navigate a long thread + +As bob, I want a long thread paginated and its posts individually linkable, so +that I can move around it and point people at one message. + +Acceptance: + +- A thread with more than 20 posts shows pagination controls, and the page + number, next/prev, and first/last controls each move to the matching slice + with the "Page X of Y" indicator updating. +- "Permalink" on a post copies a link to that post. +- Opening that link as carol loads the thread, navigates to the page holding + that post, and scrolls it into view. + +### US-045 — Turn a chat message into a thread + +As bob, I want to promote an interesting message to a thread, so that the +discussion can continue somewhere more structured. + +Acceptance: + +- "Create a Thread" from a room message's menu opens the thread composer + pre-filled with a quote of that message. +- Publishing files the thread under that room's board. +- Opening the thread shows the quoted original message as part of the opening + post. + +## Community features + +### US-046 — Create and browse a calendar event + +As alice, I want to put an event on the space's calendar, so that members can +see when it is happening. + +Acceptance: + +- The create form requires a title and a start/end time. +- Submitting closes the modal and lists the event under its date on the Calendar + page. +- The calendar opens scrolled to today or the next upcoming event. + +### US-047 — Manage your own calendar event + +As alice, I want to correct or cancel an event I created, so that the calendar +stays accurate. + +Acceptance: + +- Clicking an event opens a detail page showing title, date, location, and host. +- As the author, alice sees Edit, which pre-fills the form; saving updates the + detail page. +- Deleting the event after confirming removes it from the calendar list. + +### US-048 — Create a poll and vote on it + +As alice, I want to run a poll, so that the room can decide something together. + +Acceptance: + +- Creating a poll requires a question and at least two options, which can be + reordered by dragging. +- Selecting an option casts alice's vote and updates that option's count and + progress bar. +- bob, in a separate session, sees alice's vote reflected and his own vote + appears in her view without a reload. +- On a multiple-choice poll, two options can be selected together and both + publish once the delay elapses; unchecking one before then keeps it out of the + vote. + +### US-049 — A closed poll shows final results only + +As alice, I want a poll past its end time to stop taking votes, so that the +outcome is unambiguous. + +Acceptance: + +- A poll whose end date has passed shows "Ended" with the time, in place of + voting controls. +- Counts and progress bars still render from the votes already cast. +- No radio or checkbox inputs appear on its options. + +### US-050 — Create a funding goal and track its progress + +As alice, I want to raise money for a purpose and show how it's going, so that +members can see the momentum. + +Acceptance: + +- Creating a goal requires a title and details; the sats target defaults to 1000 + and can be set by field or slider. +- The goal's detail page shows the amount funded against its target on a + progress bar. +- A goal with contributions shows a contributor count and how long it has been + running. + +### US-051 — Post, edit, and close out a classified listing + +As alice, I want to list something for sale and mark it sold when it's gone, so +that nobody chases a listing that's finished. + +Acceptance: + +- Creating a listing requires a title and description; price defaults to 0 with + a selectable currency, images attach, and topics come from a multi-select. +- The listing appears in the Classifieds list with its title and price. +- Editing pre-fills the form with current values, and saving with status "Sold" + shows a Sold badge on both the listing's detail page and its list item. + +### US-052 — Comment on and react to community posts + +As bob, I want to react and comment on events, polls, and goals, so that I can +register interest and ask questions. + +Acceptance: + +- Adding an emoji reaction to alice's calendar event records it under his name. +- His comment appears in the replies thread on the event, and alice sees it. +- With more than four replies, a "show all" control expands the rest. + +### US-053 — Browse and search the library + +As alice, I want to search curated links and open a shelf, so that I can find +resources members have collected. + +Acceptance: + +- Typing a term filters the shelf list to shelves whose title, description, or + topics match. +- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message + instead. +- As a non-admin, alice sees no "Create Shelf" or "Add a link" controls. + +### US-054 — Curate the library + +As admin, I want to organize shelves and the links on them, so that members find +good material first. + +Acceptance: + +- Creating a shelf with a title and description adds it to the list and + navigates into it; editing and deleting it from its menu update the list. +- Adding a pin with an external url shows it as a content card, and adding one + with a nostr note link renders that note as a rich embed. +- "Add to Library" on a post (for example a poll) with a shelf selected adds it + as a pin visible in that shelf; with no shelves yet, the dialog prompts to + create one. + +### US-055 — Create community content from a room + +As alice, I want to start a poll, event, goal, or listing straight from the room +composer, so that the room sees it immediately. + +Acceptance: + +- The composer's "+" menu lists the content types that can be created and shared + into the room. +- Choosing one opens its creation form, and submitting posts a quoted link to + the new item into the room. +- Clicking that quote navigates to the item's detail page. + +## Composer & media + +### US-056 — Autocomplete a mention or a room reference + +As alice, I want to pull people and rooms into a message as I type, so that +readers can follow them. + +Acceptance: + +- Typing "@" plus a few letters opens a dropdown of matching profiles, ranking + room and space members first, and narrowing as she types. +- Selecting bob inserts a mention that renders his name and avatar in the + composer and in the sent message. +- Typing "~" opens a list of rooms; selecting one inserts a reference that + renders as a clickable link once sent. + +### US-057 — Attach and send an image + +As alice, I want to put a picture in a message, so that I can share something +visual. + +Acceptance: + +- Choosing an image from the upload button shows a spinner and disables sending + until the upload finishes. +- After sending, the image renders inline in the timeline for bob as well; a DM + behaves the same for its recipient. +- Dropping an image onto the composer, or pasting one from the clipboard, + attaches it the same way. +- A file type outside the supported list shows an error toast and attaches + nothing. + +### US-058 — Drafts survive navigating away + +As alice, I want an unsent message to still be there when I come back, so that I +don't lose what I was writing. + +Acceptance: + +- Typing partial text in a room, navigating to another room, and returning shows + the text still in the composer. +- The same holds for a conversation in chat. +- Sending the message clears its draft, so the composer is empty on the next + visit. + +### US-059 — Cancel a reply or edit in progress + +As alice, I want to back out of a reply or an edit cleanly, so that a false +start doesn't get published. + +Acceptance: + +- The reply banner and the "Editing message" banner each close via their X or + Escape. +- Dismissing leaves the original message unchanged and sends nothing. +- The composer returns to composing a new message with its previous draft + intact. + +## Rich content & media rendering + +### US-060 — Reveal a flagged sensitive message + +As alice, I want a warning before flagged content is shown, so that I can choose +whether to look. + +Acceptance: + +- A message bob tags with a content warning renders as a card naming the reason, + with a "Show anyway" button, in place of his text. +- Clicking "Show anyway" reveals his message content in the same spot. + +### US-061 — Expand a long post + +As carol, I want long posts truncated with a way to read the rest, so that one +post doesn't take the whole screen. + +Acceptance: + +- A post past the truncation threshold renders cut short with a "Read more" + control instead of the full text. +- Clicking it reveals the full text in place, without navigating anywhere. + +### US-062 — See images and video inline + +As alice, I want shared media to play and display where it was posted, so that I +don't have to open links. + +Acceptance: + +- A message that is only an image url renders the image inline rather than as a + link. +- Tapping the image opens a fullscreen lightbox, and tapping it again (or going + back) returns to the room. +- A message that is only a video url renders an embedded player with visible + controls. + +### US-063 — Preview a shared link + +As alice, I want a link shared on its own to expand into a preview while a link +mid-sentence stays small, so that the timeline reads well either way. + +Acceptance: + +- A standalone url whose preview resolves shows a card with title, description, + and image after a brief loading state. +- A standalone url with nothing usable shows a card explaining the preview + couldn't be loaded, naming the url. +- The same url embedded in a sentence renders as a compact inline link with no + card. + +### US-064 — Follow a link to another space + +As alice, I want a link to one of this app's own spaces to be recognized, so +that it takes me there instead of out of the app. + +Acceptance: + +- A message containing the relay url of a second space renders as a named + reference to that space rather than a raw url. +- Clicking it navigates into that space in the app, with no new tab opened. + +### US-065 — See quoted and embedded content + +As alice, I want quoted messages and posts rendered in place, so that I can +follow context and jump to the original. + +Acceptance: + +- Bob's reply to alice shows her original message embedded as a compact bordered + quote strip. +- Clicking that strip takes alice to (or scrolls her to) her original message. +- A room message quoting a thread post renders as a bordered card naming the + author and content, briefly showing a loading state first. +- Clicking that card opens the quoted post. + +### US-066 — See distinctive inline tokens + +As alice, I want hashtags, mentions, emoji, code, and addresses to look +different from prose, so that a message's structure is readable at a glance. + +Acceptance: + +- A "#hashtag" renders with distinct highlighting, and a registered custom emoji + shortcode renders as a small inline image instead of the raw text. +- Single-backtick text renders as an inline monospace snippet and a fenced block + renders as a full-width code block. +- A mention renders as a clickable "@displayname" that opens that person's + profile, and a plain email address renders as a mailto link. + +### US-067 — Copy a shared invoice or token + +As alice, I want a Lightning invoice or Cashu token in chat to be copyable +rather than a wall of text, so that I can use it elsewhere. + +Acceptance: + +- A message containing a bolt11 invoice renders as a compact chip with an icon + and truncated value. +- Tapping the chip copies the full string and shows a "Copied to clipboard!" + toast. +- A cashu token renders and copies the same way. + +## Message delivery & reliability + +### US-068 — Watch a delayed send, and cancel it + +As alice, I want a window to take a message back before it leaves, so that I can +catch a mistake. + +Acceptance: + +- With a send delay configured in settings, sending a room message shows the + bubble immediately with a "Sending..." label; once the relay confirms, the + label clears and a "Message sent!" toast appears and dismisses itself. +- With the delay at 0, the message publishes immediately with no confirmation + toast. +- Clicking Cancel during the delay removes the message from alice's own view, + and bob never sees it even after the delay window passes. +- The same cancel works on a direct message, which never reaches bob's thread. + +### US-069 — See why a message failed to deliver + +As alice, I want a failed send flagged with the relay and the reason, so that I +understand what went wrong. + +Acceptance: + +- A message the relay rejects shows "Failed to send!" beneath its bubble once + publishing completes, with the text still visible to alice and never visible + to bob. +- Tapping the indicator opens a popover listing each target relay with a success + or failure icon and a rejection reason, falling back to "request timed out" or + "no details received". +- A DM that reaches one of bob's messaging relays but not the other shows + "Partial delivery 1/2 relays" with one green check and one failure and reason. + +### US-070 — Retry a failed relay + +As alice, I want to retry only the relay that failed, so that I don't resend the +whole message. + +Acceptance: + +- Retry beside a failed relay re-publishes to that relay alone. +- For a chat message, a "Sending..." toast appears and becomes "Message sent!" + once the relay confirms; a failed retry leaves no success message and can be + retried again. +- For an article or comment, a successful retry clears the "Failed to send!" + indicator and restores the normal reaction and menu actions, with no toast. + +### US-071 — Content posts show delivery status in place + +As alice, I want articles and comments to report their status where they sit, so +that publishing feels the same as chatting without a popup. + +Acceptance: + +- A newly submitted article, thread, comment, poll, goal, event, or listing + shows "Sending..." with a Cancel link in its actions row until the relay + confirms. +- On confirmation the status disappears silently and the normal reaction and + menu actions take its place. +- Cancelling removes the post entirely from the list or thread it was posted + into, and bob never sees it. +- A failed post shows "Failed to send!" in the same row, opening the same + per-relay detail popover. + +### US-072 — A deleted post is marked deleted + +As alice, I want a post I deleted to read as deleted, so that it isn't confused +with one still sending. + +Acceptance: + +- After alice deletes her own comment, article, thread, poll, goal, event, or + listing, its row shows a "Deleted" pill. +- The pill replaces the reaction summary and menu actions. +- It takes priority over any pending or failed send status that would otherwise + show. + +### US-073 — A multi-part message reports one status + +As alice, I want a message with text and an image to show a single status, so +that one send doesn't look like two. + +Acceptance: + +- Sending a DM with both a text portion and an image shows one "Sending..." + toast, not two. +- It becomes "Message sent!" only once both parts have reached every relay. +- Each resulting bubble still carries its own failure indicator if that part + fails. + +## People & social + +### US-074 — Find a person + +As alice, I want to search for people by name, so that I can get to someone's +profile. + +Acceptance: + +- Typing a name on the people search filters results as she types, each showing + avatar, display name, and about text. +- Scrolling to the bottom loads more matches. +- Clicking a result opens that person's profile. + +### US-075 — View someone's profile + +As alice, I want to see bob's public identity in one place, so that I know who +I'm dealing with. + +Acceptance: + +- His profile shows display name, avatar, banner, about text, and a shortened + npub whose copy button confirms the copy. +- A website he has set renders as a link. +- A Spaces panel lists the spaces he belongs to with a count, marks any alice + also belongs to as "Member", and navigates to a space when clicked; with none, + it shows an empty state. + +### US-076 — Follow and unfollow + +As alice, I want to follow and unfollow bob from his profile, so that I control +who I keep up with. + +Acceptance: + +- His profile shows "Follow" while she doesn't follow him. +- Clicking it switches to "Unfollow" without a reload. +- Clicking "Unfollow" switches it back. + +### US-077 — See web-of-trust standing build up + +As alice, I want to see how known someone is in my network, so that I can judge +whether to engage. + +Acceptance: + +- Before anyone she follows follows bob, his reputation panel shows a low score + and "not well known in your network". +- After alice follows carol and carol follows bob, his profile reads "Followed + by 1+ people in your network" and lists carol. +- The trust ring shown beside his name elsewhere in the app fills in further + than before. + +### US-078 — Edit your own profile + +As alice, I want to update my name, bio, and pictures, so that people see my +current identity. + +Acceptance: + +- Her own profile shows "Edit profile" instead of Follow and Message, and the + form is pre-filled with her current name, about text, avatar, and nostr + address. +- Changing the name and about text and saving shows a success toast and updates + the profile immediately. +- Uploading a new avatar updates the avatar shown, and changing the banner + updates the banner at the top of her profile. + +### US-079 — Read a person's notes + +As bob, I want to browse alice's notes on her profile, so that I can catch up on +what she has posted. + +Acceptance: + +- Her profile lists her notes below her header, newest first, each showing her + name, avatar, content, and a relative timestamp; replies to other people's + notes are excluded. +- A note she has pinned appears above newer unpinned notes. +- Scrolling to the bottom loads older notes automatically; a profile with no + notes shows a loading indicator that resolves to "No notes found for this + profile." +- A note alice publishes from her own session appears at the top of bob's open + view without a reload. + +### US-080 — Preview a profile from anywhere + +As alice, I want to click a name or avatar wherever it appears, so that I can +check who someone is without losing my place. + +Acceptance: + +- Clicking bob's avatar or mention in a member list or message opens a popover + with his avatar, name, about text, and badges. +- The popover offers "View Full Profile", which navigates to his profile page. +- Closing the popover leaves alice where she was. + +### US-081 — Inspect and share a profile + +As alice, I want a profile's raw details and a shareable code, so that I can +verify an identity or pass it on. + +Acceptance: + +- "Profile Info" shows account creation date, a copyable nprofile link, the + npub, and the raw profile event, with copy buttons that confirm. +- "Share" shows a scannable QR code and a copyable profile link. + +### US-082 — Mute an account + +As alice, I want a muted person's content hidden until I ask for it, so that +muting actually declutters my feeds. + +Acceptance: + +- Searching for bob in Muted Accounts under Settings › Content adds a removable + badge with his name, and saving persists it across a reload. +- His notes then render as "You have muted this person." with a "Show anyway" + link instead of their content, and revealing one leaves the others collapsed. +- Removing his badge and saving makes his notes display normally again. + +## Settings & preferences + +### US-083 — Manage inbox and outbox relays + +As alice, I want to choose where I read and publish, so that my messages reach +the right places. + +Acceptance: + +- The relays page shows separate Inbox and Outbox cards, each with a current + count. +- Adding a relay by url to the Inbox list shows it there and increments the + count on the settings page. +- Removing a relay from the Outbox list drops it immediately, and a list with + only one relay shows a warning icon rather than a check. + +### US-084 — Manage DM, search, and blocked relays + +As bob, I want separate relay lists for messaging, search, and relays I never +want used, so that each feature uses relays suited to it. + +Acceptance: + +- A relay added to DM Relays appears there and not in Search Relays. +- The search-relay picker only offers relays advertising NIP-50 search support, + and removing one updates the count on the relays page. +- A relay added to Blocked Relays appears in that list and stops being offered + as a suggestion for the others. + +### US-085 — Fix relay misconfiguration from the health check + +As a user whose relay lists are wrong, I want the app to detect and fix it, so +that I don't have to know the specifics. + +Acceptance: + +- With only one outbox relay, the relays page shows a health check reporting + "Missing Outbox Relays". +- Applying that recommendation publishes a new relay list and the issue leaves + the pending list. +- "Apply All Recommendations" clears multiple issues at once, after which the + card shows an all-clear state. + +### US-086 — Configure alerts + +As alice, I want to choose what notifies me and how, so that alerts match how I +want to be interrupted. + +Acceptance: + +- Settings › Alerts opens with toggles reflecting the stored preferences; saving + shows a confirmation toast and the values survive a reload. +- With every channel off, the alert-type section (activity, mentions, messages) + appears disabled. +- Turning on push notifications triggers a browser permission request; if + permission is refused, an error toast reports it and the push toggle settles + off, leaving badge and sound settings untouched. +- "Discard Changes" reverts unsaved edits to the last saved values. + +### US-087 — Configure content display + +As alice, I want to control whether sensitive content and media are shown, so +that my feeds match my comfort level. + +Acceptance: + +- Turning off "Hide sensitive content" and saving persists across a reload. +- With "Show media" off, a message that is only an image url renders as a plain + compact link instead of an inline image. +- With "Show media" off, a standalone external link renders as a plain compact + link with no preview card or spinner. + +### US-088 — Adjust send delay and media servers + +As alice, I want to set how long sends are held and where uploads go, so that +the editor behaves the way I want. + +Acceptance: + +- Moving the send-delay slider updates the displayed seconds live, and the value + persists after a reload. +- "Add Server" appends an editable media server entry, and removing one drops it + from the list. +- Saving shows a success toast. + +### US-089 — Configure privacy preferences + +As alice, I want control over relay authentication and usage reporting, so that +I can manage the tradeoffs myself. + +Acceptance: + +- Turning on "Authenticate with unknown relays" and saving persists across a + reload. +- Turning off "Report usage" and saving persists across a reload. +- "Discard Changes" reverts unsaved toggles. + +### US-090 — Change the app's appearance + +As alice, I want to set color scheme, visual theme, and font size, so that the +app looks and reads the way I want. + +Acceptance: + +- Selecting "Dark" applies the dark theme to the document immediately, and + "System" follows the OS color-scheme preference. +- Choosing a different app theme applies it and persists after a reload. +- Dragging the font-size slider updates the displayed percentage and saves + without a separate save button. + +### US-091 — Set up how people zap you + +As alice, I want a lightning address on my profile and sensible preset amounts, +so that zapping works without a connected wallet. + +Acceptance: + +- The wallet page shows "Not set" with no lightning address; entering one and + saving updates the profile and the page, and clearing it returns to "Not set". +- "Add amount" appends an editable preset, and saving a zero or negative amount + shows an error and does not save. +- The last remaining preset cannot be removed, and saved amounts persist across + a reload. + +## Admin, moderation & hosting + +### US-092 — Edit a space's profile and featured content + +As admin, I want to keep a space's public details and highlights current, so +that visitors see the right thing first. + +Acceptance: + +- "Edit this Space" updates name, description, and icon, shows a success + message, and the About page reflects the change. +- Adding items in the Featured section renders them on the About page for every + visitor. +- bob sees no edit or featured-content controls on the same space. + +### US-093 — Create roles and assign them + +As admin, I want custom roles I can hand out, so that members' responsibilities +are visible. + +Acceptance: + +- "Manage Roles" creates a role with a label, description, and color, which then + appears in the roles list; editing its label updates the list. +- Adding members to a role, or checking it under a member's "Edit roles", shows + the role's badge on that member's card in the directory; unchecking removes + the badge. +- Searching the directory for the role's name filters to members holding it. +- Deleting a role warns that members keep their space membership, and confirming + removes it from the list; bob sees no "Manage Roles" option. + +### US-094 — Invite people to a space + +As admin, I want a shareable invite and a direct-add path, so that I can grow +the space. + +Acceptance: + +- "Create Invite" shows a QR code and a copyable link, and copying puts it on + the clipboard. +- Where the relay supports it, the invite dialog's member search adds someone + straight into the member list. +- A room-scoped invite link carries both the space and that room's join code. + +### US-095 — Remove, ban, and restore members + +As admin, I want to take someone out of a space and reverse it later, so that I +can enforce membership. + +Acceptance: + +- Removing bob from his member card, after confirming, takes him out of the + directory. +- Banning carol confirms with a success message and lists her under Banned + Members instead of the active directory. +- Restoring her from that list returns her to the active member list. +- bob sees none of these controls on other members. + +### US-096 — Moderate messages and posts + +As a space, we want authors to remove their own content, everyone else to report +it, and admin to remove anything, so that moderation has a clear chain. + +Acceptance: + +- alice sees a delete option on her own article, message, or post; bob sees + "Report Content" on hers instead, and reporting requires choosing a reason + before it submits. +- admin sees a delete option on bob's message and on any member's article, + thread post, or poll, even without being the author. +- Content admin deletes disappears for other viewers. + +### US-097 — Work through the action-items queue + +As admin, I want reports and join requests in one queue with a badge, so that I +don't have to go looking for them. + +Acceptance: + +- With nothing pending, the space menu shows no action-items badge; after bob + reports a message or carol requests to join a closed room, admin sees a badge + and an "Action Items (n)" count, which a non-admin never sees. +- The queue lists each report with its reported message and reporter, and each + join request with the requester and target room. +- Accepting a join request adds that member to the room, and dismissing one + clears it without granting membership. +- "Remove Content" on a report deletes the reported message and clears the item; + dismissing clears the item and leaves the content alone. + +### US-098 — Browse and create hosted spaces + +As a space owner, I want to see the spaces I host and spin up new ones, so that +I can run a community without operating a relay. + +Acceptance: + +- With no hosted spaces, Settings › Hosting shows an empty state prompting + creation; with some, each is listed with name, host, status badge, plan badge, + and a Manage link. +- Creating a space with a name (which auto-fills the subdomain) on the free plan + lands on the new space's admin page and adds it under "Your Spaces". +- Choosing a paid plan instead prompts for a payment method or an invoice + immediately after creation. + +### US-099 — Configure a hosted relay + +As a space owner, I want to edit my relay's details and policies, so that it +behaves the way my community needs. + +Acceptance: + +- "Edit details" updates the relay's name and description on its detail card, + and the activity feed lists the relay's creation event. +- Toggling "Public read" on persists across a reload. +- On the free plan, the Blossom and LiveKit toggles are disabled with a "Not + available on your current plan" explanation. +- bob, a member but not the owner, visiting the same admin url sees a "Not a + Coracle-hosted space" card linking to Settings › Hosting. + +### US-100 — Change a hosted relay's plan + +As a space owner, I want to move between plans, so that I can pay for what I +need. + +Acceptance: + +- Selecting a paid plan from "Change plan" and saving updates the relay's plan + badge. +- Upgrading with no payment method configured opens payment setup or an open + invoice right after saving. +- Downgrading to free turns the plan-gated Blossom and LiveKit toggles off + automatically. + +### US-101 — Point a custom domain at a hosted relay + +As a space owner, I want members to connect through my own domain, so that the +space carries my branding. + +Acceptance: + +- Saving a domain under "Manage" shows it on the relay card with a "Pending" + badge and the CNAME record to configure, whose copy button puts the target on + the clipboard. +- "Verify DNS record" flips the badge to "Verified" once the backend reports it. +- The relay's displayed address then switches to the custom domain. + +### US-102 — Pause a relay and settle the bill + +As a tenant, I want to take a space offline temporarily and keep my account paid +up, so that hosting stays under my control. + +Acceptance: + +- "Deactivate" confirms with a warning about dropped connections and flips the + status badge to inactive; "Reactivate" returns it to active. +- With an unpaid invoice, Settings › Hosting shows a banner whose "Pay now" + opens a dialog with the amount and a Lightning QR code and bolt11 string; + "Check payment" after the backend reports it paid shows a confirmed state and + the banner disappears. +- Connecting a wallet through "Set up autopay" with an NWC url shows "Wallet + connected" and marks Lightning as connected in the payment-methods list. +- Payment history lists past invoices with amount and billing period, most + recent first. + +## Notifications & navigation + +### US-103 — See and clear unread indicators + +As bob, I want unread activity marked where I'll see it, so that I know what to +check first. + +Acceptance: + +- After alice posts in a room bob hasn't opened, an unread dot appears on that + room and on its space in his sidebar, and on the space's row in `/spaces`, + without a reload. +- Opening the room clears its dot, and the dot stays cleared when he returns to + the space list. + +### US-104 — Mute a room or a whole space + +As alice, I want to silence one noisy room or an entire space, so that I only +get pinged for what I care about. + +Acceptance: + +- Turning off Notifications in a room's detail panel shows a muted-bell icon on + that room only, and new messages there produce no unread dot while a sibling + room's still do. +- "Turn off notifications" in the space menu relabels itself to "Turn on + notifications" and shows a muted-bell beside the space name, after which no + room in it raises an unread dot. +- Turning either back on restores unread indicators for subsequent activity. + +### US-105 — Land on the home page + +As a new user, I want the home page to route me somewhere useful, so that I'm +never staring at a blank screen. + +Acceptance: + +- On a build with a configured platform space, `/home` opens that space. +- With none configured, it shows a welcome screen offering "Add a space" and + "Start a conversation". +- Those options navigate to the spaces directory and the chat view respectively. + +### US-106 — Share text into the app + +As alice, I want to hand text to Flotilla and choose where it lands, so that I +can forward things from elsewhere. + +Acceptance: + +- Opening `/share` with text shows a Share dialog listing her rooms and + conversations, searchable by name. +- Selecting a room and confirming navigates there with the shared text + pre-loaded in the composer. +- The composer's content matches the text that was shared. + +### US-107 — Open a nostr link + +As alice, I want npub and nevent links to resolve to the right thing, so that +shared links just work. + +Acceptance: + +- Copying an event's link from its info panel and navigating to that url opens + the matching message. +- Navigating to a profile's npub url opens that person's profile. +- An unresolvable nostr link redirects to the app's home rather than showing a + broken page. + +## Out of scope + +Features the e2e suite cannot exercise, and what stops it. + +**Lightning payments and wallets.** Sending a zap on a message, article, thread +post, comment, or note; contributing to a funding goal; connecting a wallet over +WebLN or Nostr Wallet Connect; the wallet page's connection status and balance; +disconnecting a wallet; paying and receiving invoices. The harness mocks zapper +_discovery_ (Dufflepud's `/zapper/info`) but not the LNURL invoice callback or +the payment leg, and a connected wallet needs a real extension or an NWC +responder on its own relay. Existing zap receipts can be seeded, so a zap total +rendered on a message is testable; the send-and-settle flow is not. + +**Voice and video rooms.** Creating or joining a Voice room, the mic-preview and +device-picker dialog, mute/camera/screen-share controls, speaking indicators, +the video tile layout, the persistent call banner, and reconnect handling. The +LiveKit token endpoint is mockable, but the media itself is WebRTC negotiation +that the harness cannot intercept. + +**Native push and badges.** OS push delivery over the web Notification API, FCM, +or APNs; tapping a push to jump to content; the first-chat push permission +prompt; the app-icon badge count and its settings toggle; the new-activity +chime. These need a real push subscription and service worker, or a Capacitor +plugin with no browser equivalent. + +**Other native Capacitor surfaces.** Logging in with an Android NIP-55 signer +app; receiving an Android share intent; custom-scheme and universal deep links +dispatched by the OS; the native share sheet on a profile QR code; native +clipboard image paste; the Android video thumbnail poster; the iOS hosting +restriction. All are gated on a native platform the browser run never reports. + +**Camera and QR scanning.** Scanning a bunker link or an NWC connection secret +with the device camera. Requires real `getUserMedia` hardware with no mock seam. + +**Custodial (Pomade) accounts.** Email and password signup with its emailed +confirmation code, email login, one-time-code login, password reset, choosing +among accounts sharing an email, managing other sessions, and migrating from +custody to self-custody. All of it talks to the external Pomade signer network, +which has no mock seam; the harness only injects local-key sessions, and the +email option is hidden when `POMADE_SIGNERS` is unset. + +**Card payments.** Paying a hosting invoice by card navigates to Stripe's +hosted checkout and is confirmed by a backend webhook. The Lightning path for +the same invoice is covered by US-102. + +**External link-outs.** Self-hosted and third-party options on the +space-creation page, the About page's source, blog, podcast and support links, +and a note's timestamp permalink to another nostr client. The harness blocks +navigation off the app's origin, so nothing about the destination is observable. + +**Diagnostic log sending.** The privacy page's button that bundles client-side +logs into a DM to the platform's support contact. It targets a hardcoded pubkey +whose relays are not part of the sealed test network. + +**Internals with no user-visible surface.** The legacy session-storage format +migration, which has no observable difference and no supported way to seed the +old shape. `ProfileFeed` and `ProfileLatest` components that no route reaches. diff --git a/e2e/harness/app/boot.ts b/e2e/harness/app/boot.ts index 0ff377a8..707d36ba 100644 --- a/e2e/harness/app/boot.ts +++ b/e2e/harness/app/boot.ts @@ -20,11 +20,14 @@ export type BootOptions = { // What this user's client already has in local storage, e.g. their room list. events?: TrustedEvent[] path?: string + // VITE_ values the scenario sets for itself, applied over the relay-derived ones below. Anything + // named here still has to be something the test owns, or the app will reach for it. + env?: Record } export const boot = async ( context: BrowserContext, - {relays, spaces = [], user, events = [], path = "/"}: BootOptions, + {relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions, ) => { const urls = relays.join(",") @@ -55,6 +58,7 @@ export const boot = async ( // 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/", + ...env, }, ] as const, ) diff --git a/e2e/harness/app/nip07.ts b/e2e/harness/app/nip07.ts new file mode 100644 index 00000000..ed3b39a5 --- /dev/null +++ b/e2e/harness/app/nip07.ts @@ -0,0 +1,61 @@ +import type {BrowserContext} from "@playwright/test" +import type {StampedEvent} from "@welshman/util" +import type {TestUser} from "../keys" + +// The function playwright installs on window for the shim below to call into. A binding is the +// only way across: the keys live in node, and a signer built in the page would be a different +// thing from the one seeding signs with. +const TEST_NIP07_KEY = "__TEST_NIP07__" + +type Nip07Call = + | {method: "getPublicKey"} + | {method: "signEvent"; template: StampedEvent} + | {method: "encrypt" | "decrypt"; scheme: "nip04" | "nip44"; pubkey: string; message: string} + +/** + * A NIP-07 provider backed by a test identity's real signer, so an extension login produces + * signatures the relays accept — including the NIP-42 auth events a members-only relay demands. + * + * Both halves have to be installed before the page navigates: LogIn.svelte reads `window.nostr` + * while it renders to decide whether to offer the button at all. + */ +export const injectNip07 = async (context: BrowserContext, user: TestUser) => { + await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => { + if (call.method === "getPublicKey") { + return user.pubkey + } + + if (call.method === "signEvent") { + return user.signer.sign(call.template) + } + + const {encrypt, decrypt} = call.scheme === "nip04" ? user.signer.nip04 : user.signer.nip44 + + return call.method === "encrypt" + ? encrypt(call.pubkey, call.message) + : decrypt(call.pubkey, call.message) + }) + + await context.addInitScript(key => { + const call = (request: Nip07Call) => Reflect.get(window, key)(request) + + Object.assign(window, { + nostr: { + getPublicKey: () => call({method: "getPublicKey"}), + signEvent: (template: StampedEvent) => call({method: "signEvent", template}), + nip04: { + encrypt: (pubkey: string, message: string) => + call({method: "encrypt", scheme: "nip04", pubkey, message}), + decrypt: (pubkey: string, message: string) => + call({method: "decrypt", scheme: "nip04", pubkey, message}), + }, + nip44: { + encrypt: (pubkey: string, message: string) => + call({method: "encrypt", scheme: "nip44", pubkey, message}), + decrypt: (pubkey: string, message: string) => + call({method: "decrypt", scheme: "nip44", pubkey, message}), + }, + }, + }) + }, TEST_NIP07_KEY) +} diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index 6db81b91..19aeb232 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -10,22 +10,32 @@ import { mockHosting, mockImages, mockPushServer, + mockRelayInfo, } from "./net/http" +import type {HostingFixtures, RelayInfoOverrides} from "./net/http" import {assertNoLeaks, installWebSocketRoutes} from "./net/websocket" import {boot} from "./app/boot" +import {injectNip07} from "./app/nip07" import {seed} from "./seed/scenario" import type {Scenario, SeedTools} from "./seed/scenario" import type {TestUser} from "./keys" export {expect} -export {users} from "./keys" +export {makeTestUser, users} from "./keys" export type {TestUser} from "./keys" export type {Scenario} from "./seed/scenario" -export type {SeededSpace} from "./seed/space" +export type {SeededRumor, 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" +export { + assertNoBlockedRequests, + getHosting, + mockBlossom, + mockDufflepud, + mockLivekit, +} from "./net/http" +export type {DufflepudFixtures, HostingFixtures, HostingHandle, HostingRecord} 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. @@ -40,19 +50,40 @@ export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}` export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}` +// What a page is opened with, over and above the scenario's own relays. +export type PageOptions = { + // Overrides the project's context options, for a spec that needs a viewport, a colour scheme or + // a permission of its own. + context?: BrowserContextOptions + // VITE_ values applied over the ones derived from the scenario's relays, e.g. a platform space + // or the domain hosted relays are created under. Whatever is named here has to be something the + // scenario owns, or the app will reach for it and the test will fail on a leak. + env?: Record + // A NIP-07 provider signing as this user, for a login that goes through an extension. + nip07?: TestUser + // Fields merged over a relay's own nip-11 document, keyed by relay url. + relayInfo?: RelayInfoOverrides + // What the hosting backend already knows about this user. Read `getHosting(page.context())` for + // the handle that changes it mid-test. + hosting?: HostingFixtures +} + export type Harness = { zooid: Zooid seed(build: (tools: SeedTools) => MaybeAsync): Promise // A logged-in page for a user: its own browser context, its own storage, its own sockets into - // the relays every other user is talking to. `options` overrides the project's context options, - // for a spec that needs a viewport or colour scheme of its own. - as(user: TestUser, path?: string, options?: BrowserContextOptions): Promise + // the relays every other user is talking to. + as(user: TestUser, path?: string, options?: PageOptions): Promise + // The same page with no session injected — the app as someone who has never logged in sees it, + // and the only way to watch a login, a reload or a logout happen. + visit(path?: string, options?: PageOptions): Promise } export type HarnessFixtures = { harness: Harness seed: Harness["seed"] as: Harness["as"] + visit: Harness["visit"] } export type HarnessWorkerFixtures = { @@ -63,11 +94,13 @@ export const test = base.extend({ // Playwright's own context — and the `page` fixture built on it — is unrouted: no websocket // interception, no http block-all, no injected env, and nothing collecting leaks from it. A page // born there boots the app against the relays baked into .env, which is the one thing this suite - // exists to prevent, so it is refused outright and `as()` is the only way to get a page. + // exists to prevent, so it is refused outright and `as()`/`visit()` are the only ways 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.", + "harness's `as(user, path)` or `visit(path)` fixture, which install interception before " + + "they navigate.", ) }, // Playwright builds this one with `playwright.request.newContext()`, so it is an http client in @@ -111,7 +144,7 @@ export const test = base.extend({ throw new Error("Seed a scenario before opening a page for a user") } - const as = async (user: TestUser, path = "/", options: BrowserContextOptions = {}) => { + const open = async (path: string, options: PageOptions, user?: TestUser) => { 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. @@ -123,7 +156,7 @@ export const test = base.extend({ const context = await browser.newContext({ ...testInfo.project.use, serviceWorkers: "block", - ...options, + ...options.context, }) contexts.push(context) @@ -133,18 +166,31 @@ export const test = base.extend({ // recognize, so the mocks have to be registered last to be reachable at all. await installHttpRoutes(context) await installWebSocketRoutes(context, zooid) + await mockRelayInfo(context, options.relayInfo ?? {}) await mockAnalytics(context) await mockDufflepud(context) - await mockHosting(context) + await mockHosting(context, options.hosting) await mockPushServer(context) await mockImages(context) - return boot(context, {user, path, relays: urls, spaces: urls, events: cache(user)}) + if (options.nip07) { + await injectNip07(context, options.nip07) + } + + return boot(context, { + user, + path, + env: options.env, + relays: urls, + spaces: urls, + events: user ? cache(user) : [], + }) } await use({ zooid, - as, + as: (user, path = "/", options = {}) => open(path, options, user), + visit: (path = "/", options = {}) => open(path, options), seed: async build => { scenario = await seed(zooid, build) @@ -162,4 +208,5 @@ export const test = base.extend({ }, seed: async ({harness}, use) => use(harness.seed), as: async ({harness}, use) => use(harness.as), + visit: async ({harness}, use) => use(harness.visit), }) diff --git a/e2e/harness/keys.ts b/e2e/harness/keys.ts index d17e7a69..55c10053 100644 --- a/e2e/harness/keys.ts +++ b/e2e/harness/keys.ts @@ -1,3 +1,4 @@ +import {createHash} from "node:crypto" import {getPubkey} from "@welshman/util" import {Nip01Signer} from "@welshman/signer" @@ -8,12 +9,17 @@ export type TestUser = { signer: Nip01Signer } -const makeUser = (name: string, secret: string): TestUser => ({ - name, - secret, - pubkey: getPubkey(secret), - signer: new Nip01Signer(secret), -}) +// Every identity this process can sign as. zooid authenticates each write and refuses an event +// whose author is not the authenticated pubkey, so seeding looks a fixture's author up here. +export const testUsersByPubkey = new Map() + +const makeUser = (name: string, secret: string): TestUser => { + const user = {name, secret, pubkey: getPubkey(secret), signer: new Nip01Signer(secret)} + + testUsersByPubkey.set(user.pubkey, user) + + return user +} // 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 @@ -24,3 +30,19 @@ export const users = { carol: makeUser("carol", "ca20100000000000000000000000000000000000000000000000000000000003"), admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"), } + +// secp256k1's group order. A secret is a scalar in [1, n), and a hash lands outside that range +// only astronomically rarely, but reducing rather than rejecting keeps the derivation total. +const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") + +/** + * A fifth, sixth, hundredth identity, named rather than listed. The secret is derived from the + * name, so the pubkey is as stable across runs as the four above, and minting one registers it — + * a scenario can seed a profile, a message or a follow for it like any other test user. + */ +export const makeTestUser = (name: string) => { + const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex")) + const secret = ((digest % (CURVE_ORDER - 1n)) + 1n).toString(16).padStart(64, "0") + + return makeUser(name, secret) +} diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts index 5c14b8d6..8970063c 100644 --- a/e2e/harness/net/http.ts +++ b/e2e/harness/net/http.ts @@ -1,6 +1,6 @@ import {createHash} from "node:crypto" import type {BrowserContext} from "@playwright/test" -import {now} from "@welshman/lib" +import {HOUR, int, now, omit} from "@welshman/lib" import type {Handle} from "@welshman/util" import type {ZapperValues} from "@welshman/domain" import {tenantByUrl} from "../zooid/config" @@ -16,6 +16,10 @@ 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" +// Where the hosting api sends a browser to pay. Nothing serves it — `.test` resolves nowhere and +// the block-all aborts the navigation — so a spec sees the redirect without one leaving. +const CHECKOUT_ORIGIN = "https://checkout.test" + // Relay-hosted livekit lives under a well-known path rather than an origin of its own. const LIVEKIT_PATH = "/.well-known/nip29/livekit" @@ -31,6 +35,13 @@ const PNG = Buffer.from( export const isDevServerUrl = (url: URL) => url.port === "1847" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) +// A relay is reached over wss and its own http origin is where nip-11 and nip-86 live. +const relayOrigin = (url: string) => new URL(url.replace(/^ws/, "http")).origin + +const hostByOrigin = new Map( + Array.from(tenantByUrl, ([url, host]): [string, string] => [relayOrigin(url), host]), +) + export type BlockedRequest = { method: string url: string @@ -57,12 +68,6 @@ export const getBlockedRequests = (context: BrowserContext) => { */ 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) @@ -114,6 +119,56 @@ export const assertNoBlockedRequests = (context: BrowserContext) => { } } +// Fields to merge over a relay's own nip-11 document, keyed by its url. +export type RelayInfoOverrides = Record + +/** + * A relay's real document with a few fields replaced: a `redirect_to`, a `limitation`, a NIP the + * relay does not implement. Merged rather than fabricated, because `self`, `pubkey`, `name` and + * `supported_nips` are what room state is trusted from — a hand-written document breaks every + * room on the page. + * + * Install it before the page navigates: the document is read at startup and cached from then on. + */ +export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => { + const overrideByOrigin = new Map( + Object.entries(overrides).map(([url, override]): [string, object] => [ + relayOrigin(url), + override, + ]), + ) + + return context.route( + url => url.pathname === "/" && overrideByOrigin.has(url.origin), + async route => { + const request = route.request() + const {origin} = new URL(request.url()) + const host = hostByOrigin.get(origin) + const override = overrideByOrigin.get(origin) + + // The nip-86 management api posts to this same path, and what it answers decides what the + // admin ui offers, so only the document request is touched. + if (request.method() === "GET" && host && override) { + const {status, headers, body} = await requestZooid(host, "GET", "/", { + ...request.headers(), + // The merge has to read the document, and khatru will compress it if invited to. + "accept-encoding": "identity", + }) + + return route.fulfill({ + status, + // khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's own + // headers are kept — all but the length of a body that is about to change. + headers: omit(["content-length"], headers), + body: JSON.stringify({...JSON.parse(body.toString()), ...override}), + }) + } + + return route.fallback() + }, + ) +} + /** * 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 @@ -135,6 +190,12 @@ export type DufflepudFixtures = { zappers?: {lnurl: string; info?: Omit}[] } +/** + * Dufflepud, whose origin is the one service url the app hard-codes rather than reading from a + * `VITE_` value. `as()` installs it with no fixtures, so a spec that needs one — a zapper for a zap + * receipt, a link preview — calls this again with its own: playwright matches the most recently + * registered route first, so the spec's answers win over the empty defaults. + */ export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) => context.route(`${DUFFLEPUD_ORIGIN}/**`, route => { const {pathname} = new URL(route.request().url()) @@ -232,48 +293,211 @@ export const mockPushServer = (context: BrowserContext) => 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. +// One record straight off the hosting api, whose shapes live in src/app/hosting.ts. They're left +// as loose 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. A relay record may also carry `members` and +// `activity`, and an invoice `items` and `bolt11`, which is where those endpoints answer from. +export type HostingRecord = Record + +// The backend's state when the page opens. Everything after that is what the app wrote, plus +// whatever the handle below changed. export type HostingFixtures = { - plans?: object[] + plans?: HostingRecord[] // 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 + tenant?: HostingRecord + relays?: HostingRecord[] + invoices?: HostingRecord[] + draftInvoice?: HostingRecord } -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) +// The backend changing its mind between two of the user's clicks — a custom domain that verifies, +// an invoice that gets paid — which is the half of those flows no click can reach. +export type HostingHandle = { + setTenant(patch: HostingRecord): void + setRelay(id: string, patch: HostingRecord): void + setInvoice(id: string, patch: HostingRecord): void +} + +const hostingByContext = new WeakMap() + +export const getHosting = (context: BrowserContext) => { + const handle = hostingByContext.get(context) + + if (handle) { + return handle + } + + throw new Error("mockHosting was never called for this browser context") +} + +/** + * The hosting api as a small stateful fake: a write mutates the record it names and the next read + * sees it, which is what makes editing a space's details, deactivating it, changing its plan or + * saving a custom domain observable at all. Each browser context gets its own store, so one user's + * spaces are not another's. + */ +export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => { + const plans = fixtures.plans ?? [] + const relays = new Map((fixtures.relays ?? []).map(relay => [String(relay.id), relay])) + const invoices = new Map((fixtures.invoices ?? []).map(invoice => [String(invoice.id), invoice])) + + let tenant: HostingRecord = { + pubkey: "", + return_url: "", + nwc_is_set: false, + stripe_customer_id: "", + created_at: now(), + ...fixtures.tenant, + } + + const handle: HostingHandle = { + setTenant: patch => { + tenant = {...tenant, ...patch} + }, + setRelay: (id, patch) => { + relays.set(id, {...relays.get(id), ...patch}) + }, + setInvoice: (id, patch) => { + invoices.set(id, {...invoices.get(id), ...patch}) + }, + } + + hostingByContext.set(context, handle) + + await context.route(`${HOSTING_ORIGIN}/**`, route => { + const request = route.request() + const method = request.method() + const body: HostingRecord = request.postDataJSON() ?? {} + const [resource, id, sub, detail] = new URL(request.url()).pathname.split("/").filter(Boolean) + + const missing = (what: string) => route.fulfill({status: 404, json: {error: `No such ${what}`}}) if (resource === "plans") { - return route.fulfill({json: {data: fixtures.plans ?? []}}) + return route.fulfill({json: {data: 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}}) - } + // Provisioning is the one tenant route with no pubkey in the path. + if (id) { + tenant = {...tenant, pubkey: id} } else { - return route.fulfill({json: {data: {pubkey: id, ...fixtures.tenant}}}) + tenant = {...tenant, ...body} + + return route.fulfill({json: {data: tenant}}) } + + if (sub === "relays") { + return route.fulfill({json: {data: Array.from(relays.values())}}) + } + + if (sub === "invoices") { + const data = detail === "draft" ? fixtures.draftInvoice : Array.from(invoices.values()) + + return route.fulfill({json: {data}}) + } + + if (sub === "stripe") { + return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/portal/${id}`}}}) + } + + // A wallet is the only thing the app updates a tenant with, so saving one is what sets it. + if (method === "PUT") { + tenant = {...tenant, ...body, nwc_is_set: Boolean(body.nwc_url)} + } + + // GET and reconcile both answer with the tenant as it now stands. + return route.fulfill({json: {data: tenant}}) + } + + if (resource === "relays") { + if (!id) { + const relay = { + id: `relay-${relays.size + 1}`, + status: "active", + sync_error: "", + synced: now(), + custom_domain: "", + custom_domain_verified: 0, + ...body, + // A created space is opened straight away, so its url has to be one the container + // serves. The client names the domain from VITE_HOSTING_RELAY_DOMAIN. + zooid_domain: body.zooid_domain || "test", + } + + relays.set(String(relay.id), relay) + + return route.fulfill({json: {data: relay}}) + } + + const relay = relays.get(id) + + if (!relay) { + return missing(`relay ${id}`) + } + + if (sub === "members") { + return route.fulfill({json: {data: {members: relay.members ?? []}}}) + } + + if (sub === "activity") { + return route.fulfill({json: {data: {activity: relay.activity ?? []}}}) + } + + const updated = {...relay, ...(method === "PUT" ? body : {})} + + if (sub === "deactivate") { + updated.status = "inactive" + } + + if (sub === "reactivate") { + updated.status = "active" + } + + relays.set(id, updated) + + return route.fulfill({json: {data: updated}}) + } + + if (resource === "invoices") { + const invoice = invoices.get(id) + + if (!invoice) { + return missing(`invoice ${id}`) + } + + if (sub === "items") { + return route.fulfill({json: {data: invoice.items ?? []}}) + } + + if (sub === "bolt11") { + const bolt11 = invoice.bolt11 ?? { + id: `bolt11-${id}`, + invoice_id: id, + lnbc: `lnbc${id}`, + msats: 0, + created_at: now(), + expires_at: now() + int(1, HOUR), + } + + return route.fulfill({json: {data: bolt11}}) + } + + if (sub === "checkout") { + return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/invoices/${id}`}}}) + } + + // GET and reconcile both answer with the invoice as it now stands, which is how a spec + // marks one paid: flip `paid_at` with the handle and let the dialog's next poll find it. + return route.fulfill({json: {data: invoice}}) } return route.fallback() }) + return handle +} + 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. diff --git a/e2e/harness/seed/space.ts b/e2e/harness/seed/space.ts index d4cb9992..bedd9b55 100644 --- a/e2e/harness/seed/space.ts +++ b/e2e/harness/seed/space.ts @@ -5,13 +5,15 @@ import { ROOM_ADD_MEMBER, Resolver, makeEvent, + prep, 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 {EventTemplate, HashedEvent, SignedEvent, StampedEvent} from "@welshman/util" +import {Nip59} from "@welshman/signer" +import {DirectMessage, EventWriter, Profile} from "@welshman/domain" +import type {BaseEventReader, ConfiguredKind, KindFactory} from "@welshman/domain" import type {RoomOptions, TestRelay} from "../zooid/types" import type {Zooid} from "../zooid/relay" import type {TenantName} from "../zooid/config" @@ -30,6 +32,18 @@ export type SeededEvent = { readonly id: string } +// The kind-14 a direct message really is. It is never published — each participant gets it inside +// a gift wrap — so this, rather than anything on the wire, is what a spec asserts on. +export type SeededRumor = { + readonly rumor: HashedEvent + readonly id: string +} + +// An event to seed, either already rendered or built when its turn comes up. A domain writer needs +// a relay url to resolve its hints against, and this space has none until the queue has drained, +// so anything built by one has to be deferred. +export type SeededTemplate = StampedEvent | (() => MaybeAsync) + export type ProfileValues = { name?: string about?: string @@ -61,7 +75,16 @@ export type SeededSpace = { 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 + event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent + // A nip-17 conversation: one kind-14 rumor, gift-wrapped once per participant — the sender + // included, since their own copy is the half of the thread their client reads back. + dm(from: TestUser, to: TestUser[], content: string, createdAt?: number): SeededRumor + // This space's domain kinds, bound to a resolver that answers with its url, so a writer built + // here renders its relay hints as this space. For everything `event()` takes a template for: + // `space.event(user, () => space.kind(Article).writer().setTitle("x").renderTemplate())`. + kind>( + factory: KindFactory, + ): ConfiguredKind } export type SeedSpaceOptions = { @@ -91,18 +114,24 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): // Every fixture is published to this space, so a relay hint always resolves to its url. const context = {resolver: new Resolver(() => [relay().url])} - const publish = (write: () => Promise): SeededEvent => { - let signed: Maybe + // Queues a write and hands back a getter for whatever it produced, which only reads once the + // scenario's queue has drained. + const seeded = (write: () => Promise) => { + let value: Maybe - const event = () => { - if (signed) return signed + enqueue(async () => { + value = await write() + }) + + return () => { + if (value) return value throw new Error(`An event seeded into "${name}" was read before seed() published it`) } + } - enqueue(async () => { - signed = await write() - }) + const publish = (write: () => Promise): SeededEvent => { + const event = seeded(write) return { get event() { @@ -122,7 +151,14 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): const member = (user: TestUser, h?: string) => enqueue(() => relay().member(user, h, startedAt)) - const event = (user: TestUser, template: StampedEvent) => publishTemplate(user, () => template) + const event = (user: TestUser, template: SeededTemplate, createdAt = startedAt) => + publishTemplate(user, async () => { + if (typeof template === "function") { + return {...(await template()), created_at: createdAt} + } + + return template + }) const join = (user: TestUser, ...roomIds: string[]) => { memberships.push({user, rooms: roomIds}) @@ -174,6 +210,41 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): created_at: createdAt, })) + const kind = >(factory: KindFactory) => + factory.configure(context) + + // Every wrap is published over the sender's own connection: a gift wrap is signed by an + // ephemeral key, so its author is nobody this process can authenticate as. zooid stores it + // anyway, because it authorizes a kind-1059 by the member named in its p tag. + const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => { + const rumor = seeded(async () => { + const writer = kind(DirectMessage).writer().setContent(content) + + for (const user of to) { + writer.addRecipient(user.pubkey) + } + + const template = {...(await writer.renderTemplate()), created_at: createdAt} + const nip59 = Nip59.fromSigner(from.signer) + + for (const {pubkey} of [from, ...to]) { + await relay().publish(await nip59.wrap(pubkey, template), {as: from}) + } + + // The same template the wraps were built from, so this is the id they decrypt to. + return prep(template, from.pubkey) + }) + + return { + get rumor() { + return rumor() + }, + get id() { + return rumor().id + }, + } + } + return { name, get url() { @@ -187,5 +258,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): reply, profile, event, + dm, + kind, } } diff --git a/e2e/harness/zooid/config.ts b/e2e/harness/zooid/config.ts index ed92de58..893fa728 100644 --- a/e2e/harness/zooid/config.ts +++ b/e2e/harness/zooid/config.ts @@ -14,6 +14,10 @@ export const tenants = { space: "space.test", other: "other.test", + // Policy that space.toml cannot express at the same time: one that refuses a join without an + // invite, and one that serves events with their signatures stripped. + closed: "closed.test", + unsigned: "unsigned.test", } as const export type TenantName = keyof typeof tenants diff --git a/e2e/harness/zooid/docker/compose.yaml b/e2e/harness/zooid/docker/compose.yaml index 4e708166..1f215a55 100644 --- a/e2e/harness/zooid/docker/compose.yaml +++ b/e2e/harness/zooid/docker/compose.yaml @@ -21,10 +21,14 @@ services: DATA: /app/data MEDIA: /app/media volumes: + # A copy of ./config that relay.ts stages outside the repo and refreshes on every recreate, + # mounted writable because zooid saves a relay's toml back when a nip-86 call edits its name, + # description or icon. There is no default: the harness is the only thing that should bring + # this container up, and mounting ./config itself would leave the repo's fixtures rewritten + # by whatever the last test did. - type: bind - source: ./config + source: "${ZOOID_CONFIG:?staged by e2e/harness/zooid/relay.ts, which starts this container}" target: /app/config - read_only: true - type: tmpfs target: /app/data tmpfs: diff --git a/e2e/harness/zooid/docker/config/closed.toml b/e2e/harness/zooid/docker/config/closed.toml new file mode 100644 index 00000000..aafa650e --- /dev/null +++ b/e2e/harness/zooid/docker/config/closed.toml @@ -0,0 +1,48 @@ +# A relay that will not let anyone join on their own say-so: `public_join = false` makes zooid +# refuse a kind-28934 that carries no claim, which is what turns the join dialog into "Request +# Access". Everything else matches space.toml. +# +# Seeding a membership here is therefore not possible — `join()` and `member()` publish a claimless +# RELAY_JOIN and the relay refuses it — so a scenario using this relay seeds admin-created rooms +# and lets the spec drive the join itself. +# +# `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 = "closed.test" +schema = "e2e_closed" +secret = "c105ed0000000000000000000000000000000000000000000000000000000006" + +[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 = "closed" +pubkey = "9b78ed2f0054d1f6ce8307c81e7c4f5aefc78f2396416da05299caac2fb8662e" +description = "Throwaway relay for Flotilla's end-to-end suite." + +[policy] +public_read = false +public_write = false +public_join = false +strip_signatures = false + +[groups] +enabled = true + +[management] +enabled = true + +# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have +# the relay or the client talk to a service the test did not create. +[blossom] +enabled = false + +[push] +enabled = false + +[roles.admin] +pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"] +can_manage = true diff --git a/e2e/harness/zooid/docker/config/unsigned.toml b/e2e/harness/zooid/docker/config/unsigned.toml new file mode 100644 index 00000000..59a42310 --- /dev/null +++ b/e2e/harness/zooid/docker/config/unsigned.toml @@ -0,0 +1,43 @@ +# A relay that serves events with their signatures stripped, which is what makes the client buffer +# them and ask whether the space is trusted. Everything else matches space.toml. +# +# `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 = "unsigned.test" +schema = "e2e_unsigned" +secret = "5169ed0000000000000000000000000000000000000000000000000000000007" + +[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 = "unsigned" +pubkey = "a3708596a61d275e9c825c09e616ff075eca5d276053dcb5e720f8863b1d84a9" +description = "Throwaway relay for Flotilla's end-to-end suite." + +[policy] +public_read = false +public_write = false +public_join = true +strip_signatures = true + +[groups] +enabled = true + +[management] +enabled = true + +# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have +# the relay or the client talk to a service the test did not create. +[blossom] +enabled = false + +[push] +enabled = false + +[roles.admin] +pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"] +can_manage = true diff --git a/e2e/harness/zooid/relay.ts b/e2e/harness/zooid/relay.ts index fb419da2..20760fd9 100644 --- a/e2e/harness/zooid/relay.ts +++ b/e2e/harness/zooid/relay.ts @@ -1,16 +1,21 @@ import {execFile} from "node:child_process" +import {chmod, cp, readdir, rm} from "node:fs/promises" +import {tmpdir} from "node:os" +import {join} from "node:path" import {fileURLToPath} from "node:url" import {promisify} from "node:util" -import {ms, sleep} from "@welshman/lib" +import {MINUTE, int, 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 {testUsersByPubkey} from "../keys" +import type {TestUser} from "../keys" import {tenantNames, tenantUrl, tenants} from "./config" import type {TenantName} from "./config" import {makeTestRelay} from "./testRelay" +import type {PublishOptions} from "./types" import {connectToZooid, requestZooid} from "./transport" import type {ZooidConnection} from "./transport" @@ -18,7 +23,14 @@ 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 configSource = fileURLToPath(new URL("docker/config", import.meta.url)) + +// What the container mounts as /app/config. zooid saves a relay's toml back whenever a nip-86 call +// edits that relay's name, description or icon, so it is handed a copy rather than the repo's own +// directory: a read-only mount fails those calls, and a writable one would leave the fixtures every +// other test reads rewritten by whatever the last one did. The copy is staged here rather than in +// an entrypoint because the image is distroless — there is no shell in it to copy anything with. +const configDir = join(tmpdir(), "flotilla-e2e-zooid-config") const execFileAsync = promisify(execFile) @@ -27,7 +39,11 @@ const execFileAsync = promisify(execFile) // 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) +// Every invocation carries ZOOID_CONFIG, `down` included: the compose file names it without a +// default, so a call that left it out would fail to interpolate rather than quietly mounting +// something else. +const docker = (...args: string[]) => + execFileAsync(dockerCommand, args, {env: {...process.env, ZOOID_CONFIG: configDir}}) const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args) @@ -58,6 +74,49 @@ const probeDocker = async () => { } } +// How far the container's clock is from this host's, in seconds, read off the Date header of the +// relay's own http answer. Positive means the container is behind, which is what makes an event the +// harness has just signed look like it comes from the future. +const getClockDrift = async (host: string) => { + const {headers} = await requestZooid(host, "GET", "/", {accept: "application/nostr+json"}) + + if (headers.date) { + return Math.round((Date.now() - new Date(headers.date).getTime()) / 1000) + } +} + +// nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go), and the two +// clocks here belong to different machines: the harness signs with this host's, the container +// validates with the vm's. A vm whose clock stopped while the machine slept is the usual reason +// they diverge, and the relay's own one-line detail says nothing about how to fix it. +const describeClockDrift = (drift: number) => { + const [minutes, direction] = + drift > 0 ? [drift / 60, "behind"] : [Math.abs(drift) / 60, "ahead of"] + + return ( + `The zooid container's clock is ${Math.round(minutes)} minutes ${direction} this host. ` + + "nip-42 allows ten either way, so the relay refuses every event the harness signs, and " + + "will go on refusing them until the two agree. A container runtime's vm loses time while " + + "the machine is asleep; restarting it resyncs the clock:\n\n" + + " podman: podman machine stop && podman machine start\n" + + " docker: restart Docker Desktop, or " + + "`docker run --rm --privileged alpine hwclock -s`\n\n" + + "Then confirm they agree — `podman machine ssh date -u`, or " + + "`docker info --format '{{.SystemTime}}'`, against `date -u`." + ) +} + +const getTestUser = (pubkey: string) => { + const user = testUsersByPubkey.get(pubkey) + + if (user) return 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", + ) +} + let problem: Maybe>> // Asked once a worker: docker does not come and go mid-run, and the answer is also written to the @@ -125,17 +184,17 @@ export class Zooid { makeTestRelay({ name, url: tenantUrl(name), - publish: event => this.publish(tenantUrl(name), event), + publish: (event, options) => this.publish(tenantUrl(name), event, options), }) - publish = async (url: string, event: SignedEvent) => { + publish = async (url: string, event: SignedEvent, {as}: PublishOptions = {}) => { 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 connection = await this.authenticate(relay.host, as ?? getTestUser(event.pubkey)) const {ok, detail} = await this.send(connection, [ClientMessageType.Event, event], event.id) if (!ok) { @@ -176,6 +235,19 @@ export class Zooid { // 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 () => { + // A fresh copy on every recreate is what makes a reset restore pristine relay metadata after a + // test has edited some through nip-86. The modes are permissive because the container runs as + // uid 65532 while the copy belongs to whoever ran the suite, and a runtime that keeps host + // ownership — rootless podman, docker on linux — would otherwise leave zooid unable to write + // the file it was told to save. + await rm(configDir, {recursive: true, force: true}) + await cp(configSource, configDir, {recursive: true}) + await chmod(configDir, 0o777) + + for (const name of await readdir(configDir)) { + await chmod(join(configDir, name), 0o666) + } + try { await compose("up", "-d", "--force-recreate", "--wait") } catch (error) { @@ -199,6 +271,12 @@ export class Zooid { ).then(results => results.every(Boolean)) if (isUp) { + const drift = await getClockDrift(tenants[tenantNames[0]]) + + if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) { + throw new Error(describeClockDrift(drift)) + } + return } @@ -215,21 +293,12 @@ export class Zooid { // 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}` + private authenticate = async (host: string, user: TestUser) => { + const key = `${host} ${user.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)) @@ -241,7 +310,16 @@ export class Zooid { return connection } - throw new Error(`Failed to authenticate as ${user.name} on ${host}: ${detail}`) + const summary = `Failed to authenticate as ${user.name} on ${host}: ${detail}` + const drift = await getClockDrift(host).catch(() => undefined) + + // Nearly always the clock rather than the key: every identity here is one zooid's toml names, + // and the signature is checked last, after the timestamp the relay compares against its own. + if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) { + throw new Error(`${summary}\n\n${describeClockDrift(drift)}`) + } + + throw new Error(summary) } // Both halves of seeding — the auth that opens a connection and every event written over it — diff --git a/e2e/harness/zooid/testRelay.ts b/e2e/harness/zooid/testRelay.ts index 741aaa58..b9a730c8 100644 --- a/e2e/harness/zooid/testRelay.ts +++ b/e2e/harness/zooid/testRelay.ts @@ -9,12 +9,12 @@ import { import type {SignedEvent, StampedEvent} from "@welshman/util" import {users} from "../keys" import type {TestUser} from "../keys" -import type {TestRelay} from "./types" +import type {PublishOptions, TestRelay} from "./types" export type TestRelayOptions = { name: string url: string - publish: (event: SignedEvent) => Promise + publish: (event: SignedEvent, options?: PublishOptions) => Promise } // Seeding signs the events a real client would send and publishes them over a socket. Room @@ -36,6 +36,7 @@ export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay name, url, event, + publish, 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. diff --git a/e2e/harness/zooid/types.ts b/e2e/harness/zooid/types.ts index 021b688c..c85c57c1 100644 --- a/e2e/harness/zooid/types.ts +++ b/e2e/harness/zooid/types.ts @@ -2,6 +2,13 @@ import type {SignedEvent, StampedEvent} from "@welshman/util" import type {ClientMessage, RelayMessage} from "@welshman/net" import type {TestUser} from "../keys" +export type PublishOptions = { + // Which identity the seeding connection authenticates as. Defaults to the event's own author, + // which then has to be a test identity — a gift wrap is signed by an ephemeral key, so nothing + // in this process can sign as its author and the connection has to belong to the sender. + as?: TestUser +} + export type RoomOptions = { name?: string about?: string @@ -23,6 +30,9 @@ export type TestRelay = { member(user: TestUser, h: string | undefined, createdAt: number): Promise // Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs. event(user: TestUser, event: StampedEvent): Promise + // An event this process did not sign, sent over `as`'s connection — a gift wrap, whose author + // is the ephemeral key that wrapped it. + publish(event: SignedEvent, options: PublishOptions): Promise } // One client's connection to a relay. Every connection to a url reaches the same container, which diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts new file mode 100644 index 00000000..235a254f --- /dev/null +++ b/e2e/specs/admin.spec.ts @@ -0,0 +1,1120 @@ +import type {Locator, Page} from "@playwright/test" +import {DAY, HOUR, MINUTE, MONTH, now} from "@welshman/lib" +import {Article} from "@welshman/domain" +import { + expect, + getHosting, + makeTestUser, + mockBlossom, + roomPath, + spacePath, + test, + users, +} from "../harness" + +// Where an icon upload lands. SpaceEdit and RelayForm both call uploadFileOrFallback with no relay +// of their own, so the space's blossom probe never runs and VITE_DEFAULT_BLOSSOM_SERVERS is what's +// left. +const BLOSSOM_ORIGIN = "https://blossom.primal.net" + +// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched +// instead of re-encoding it through a canvas, so what the mock hashes is what was picked. +const ICON = { + name: "icon.gif", + mimeType: "image/gif", + buffer: Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"), +} + +// The plans the hosting backend offers. `free` is what RelayForm starts on, and `basic` is the paid +// one every upgrade story moves to. PricingTable names a plan by its member limit, which is the +// only part of a card that differs between the two. +const PLANS = [ + {id: "free", name: "Free", amount: 0, hidden: false, members: 50, blossom: false, livekit: false}, + { + id: "basic", + name: "Basic", + amount: 500, + hidden: false, + // Plan["members"] is number | null upstream; null is how the hosting API spells "unlimited". + // eslint-disable-next-line no-restricted-syntax + members: null, + blossom: true, + livekit: true, + }, +] + +// One relay as the hosting api serves it. Every field RelayDetailCard reads is present, since it +// calls .trim() and .replace() on several of them. +const hostedRelay = (overrides: Record = {}) => ({ + id: "relay-1", + tenant_pubkey: users.alice.pubkey, + subdomain: "space", + zooid_domain: "test", + plan_id: "free", + status: "active", + sync_error: "", + synced: now(), + custom_domain: "", + custom_domain_verified: 0, + info_name: "Space", + info_icon: "", + info_description: "", + policy_public_read: 0, + policy_public_write: 0, + policy_public_join: 1, + policy_strip_signatures: 0, + groups_enabled: 1, + management_enabled: 1, + blossom_enabled: 0, + livekit_enabled: 0, + push_enabled: 1, + ...overrides, +}) + +// The panel of the modal carrying a given title. `.dialog` is on both the backdrop wrapper and the +// panel inside it, so the last match is the panel. +const dialog = (page: Page, title: string) => + page + .locator(".dialog") + .filter({has: page.getByRole("heading", {name: title, exact: true})}) + .last() + +// Tippy mounts a menu the first time it is opened and leaves it in the dom when it hides, so a page +// that has opened two of them holds both — only the one on screen is visible. Exact, because +// "Edit role" and "Edit roles" are two different menus in the same directory. +const menuItem = (page: Page, name: string) => + page.getByRole("button", {name, exact: true}).filter({visible: true}) + +// Every space-level admin action hangs off the space menu, which opens from the header button in +// the secondary nav. That button is labeled with the space's name and its host, and only the host +// survives a rename. +const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.test/}).click() + +// SpaceMember covers its card with a button whose aria-label is baked from the profile display at +// first render — before the profile has loaded — so the card is found by the name it shows rather +// than by that label. It is the only interactive card in the directory, and the member's own menu +// is the last button inside it. +const memberCard = (page: Page, name: string) => + page.locator(".card-interactive").filter({hasText: name}) + +// RoomItem gives its hover actions no accessible names — every one is an icon — and the menu is +// the last of them. +const openMessageMenu = (page: Page, text: string) => + page + .locator(".room__item") + .filter({hasText: text}) + .locator(".room__item-actions button") + .last() + .click() + +// EventActions renders zap, emoji and menu into one join, so the menu is the last button in it. +const openEventMenu = (card: Locator) => card.locator(".join").getByRole("button").last().click() + +const articleCard = (page: Page, title: string) => + page.locator('[data-component="ArticleItem"]').filter({hasText: title}) + +// A FieldInline puts its control in the div immediately after its label, which is how one row of +// the hosting card is told apart from the others in the same grid. +const setting = (page: Page, label: string) => + page.locator("label").filter({hasText: label}).locator("xpath=following-sibling::div") + +const settingToggle = (page: Page, label: string) => setting(page, label).getByRole("checkbox") + +// A hosted relay's status and its plan each render as a badge beside its name. +const badge = (page: Page, text: string) => page.locator(".badge").filter({hasText: text}) + +test("US-092 edit a space's profile and featured content", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.bob, "general") + }) + + const {url} = scenario.space("space") + const admin = await as(users.admin, spacePath(url) + "/about") + + await mockBlossom(admin.context(), {server: BLOSSOM_ORIGIN}) + + await openSpaceMenu(admin) + await menuItem(admin, "Edit Space").click() + + const editor = dialog(admin, "Edit this Space") + + await expect(editor).toBeVisible() + + // The icon picker's file input is hidden inside its own label, so the chooser is opened by + // clicking the label rather than by writing to the input. + const chooser = admin.waitForEvent("filechooser") + + await editor.locator('label:has(input[type="file"])').click() + await (await chooser).setFiles(ICON) + + await expect(editor.getByText("Selected:")).toBeVisible() + + await editor.locator('input[type="text"]').fill("Harbor") + await editor.locator("textarea").fill("Where the fleet ties up.") + await editor.getByRole("button", {name: "Save Changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Your changes have been saved!") + + // All three come back off the relay's own nip-11 document + await expect(admin.getByRole("heading", {name: "Harbor"})).toBeVisible() + await expect(admin.getByText("Where the fleet ties up.")).toBeVisible() + await expect(admin.locator(`img[src^="${BLOSSOM_ORIGIN}"]`).first()).toBeVisible() + + const featured = admin + .locator(".card") + .filter({has: admin.getByRole("heading", {name: "Featured"})}) + + await expect(featured.getByText("No featured content yet.")).toBeVisible() + + await featured.getByRole("button").first().click() + + const featuredEditor = dialog(admin, "Featured Content") + + await featuredEditor.getByRole("button", {name: "Add content"}).click() + await featuredEditor.getByPlaceholder("URL or nevent...").fill("Read the harbor rules first") + await featuredEditor.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Featured content updated!") + await expect(featured.getByText("Read the harbor rules first")).toBeVisible() + + // What admin published reaches every visitor, without any of the controls that produced it + const bob = await as(users.bob, spacePath(url) + "/about") + + await expect(bob.getByRole("heading", {name: "Harbor"})).toBeVisible() + + const bobsFeatured = bob + .locator(".card") + .filter({has: bob.getByRole("heading", {name: "Featured"})}) + + await expect(bobsFeatured.getByText("Read the harbor rules first")).toBeVisible() + await expect(bobsFeatured.getByRole("button")).toHaveCount(0) + + await openSpaceMenu(bob) + + await expect(menuItem(bob, "Create Invite")).toBeVisible() + await expect(bob.getByRole("button", {name: "Edit Space", exact: true})).toHaveCount(0) +}) + +test("US-093 create roles and assign them", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.bob, "general") + space.join(user.carol, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + space.profile(user.carol, {name: "Carol Cutter"}) + }) + + const {url} = scenario.space("space") + const admin = await as(users.admin, spacePath(url) + "/directory") + + await expect(memberCard(admin, "Bob Barnacle")).toBeVisible() + + await admin.getByRole("button", {name: "More options"}).click() + await menuItem(admin, "Manage Roles").click() + + const roles = dialog(admin, "Manage Roles") + const emptyState = roles.getByText("No roles yet. Create one to start organizing members.") + + await expect(emptyState).toBeVisible() + + await roles.getByRole("button", {name: "Create Role"}).click() + + const roleForm = dialog(admin, "Create Role") + + await roleForm.getByPlaceholder("Moderator").fill("Deckhand") + await roleForm.locator("textarea").fill("Keeps the decks in order.") + await roleForm.getByRole("slider").fill("200") + await roleForm.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Role created!") + await expect(roles.locator(".role-badge")).toHaveText("Deckhand") + + // Editing the label rewrites the role rather than adding a second one + await roles.locator(".card").filter({hasText: "Deckhand"}).getByRole("button").last().click() + await menuItem(admin, "Edit role").click() + + const roleEditor = dialog(admin, "Edit Role") + + await roleEditor.getByPlaceholder("Moderator").fill("Bosun") + await roleEditor.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Role updated!") + await expect(roles.locator(".role-badge")).toHaveText("Bosun") + + // Handed out from the role's own menu... + await roles.locator(".card").filter({hasText: "Bosun"}).getByRole("button").last().click() + await menuItem(admin, "Add members").click() + + const addMembers = dialog(admin, "Add to Bosun") + + await addMembers.getByPlaceholder("Search for profiles...").fill("Bob") + await admin.locator(".tiptap-suggestions").getByRole("button", {name: users.bob.pubkey}).click() + await addMembers.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Members assigned!") + + await roles.getByRole("button", {name: "Go back"}).click() + + const bobsBadge = memberCard(admin, "Bob Barnacle").locator(".role-badge") + + await expect(bobsBadge).toHaveText("Bosun") + await expect(bobsBadge).toHaveAttribute("style", /--role-color: hsl\(200/) + + // ...or from a member's own card, which is also how it comes back off + await memberCard(admin, "Carol Cutter").getByRole("button").last().click() + await menuItem(admin, "Edit roles").click() + + const carolsRoles = dialog(admin, "Edit Member") + + await carolsRoles.getByRole("checkbox").check() + await carolsRoles.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Roles updated!") + await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveText("Bosun") + + // The directory's search covers the roles a member holds as well as their name + const term = admin.getByPlaceholder("Search people or roles...") + + await term.fill("Bosun") + + await expect(admin.locator(".card-interactive")).toHaveCount(2) + await expect(memberCard(admin, "Bob Barnacle")).toBeVisible() + await expect(memberCard(admin, "Carol Cutter")).toBeVisible() + + await term.fill("") + + await memberCard(admin, "Carol Cutter").getByRole("button").last().click() + await menuItem(admin, "Edit roles").click() + await carolsRoles.getByRole("checkbox").uncheck() + await carolsRoles.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Roles updated!") + await expect(memberCard(admin, "Carol Cutter")).toBeVisible() + await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveCount(0) + + await admin.getByRole("button", {name: "More options"}).click() + await menuItem(admin, "Manage Roles").click() + await roles.locator(".card").filter({hasText: "Bosun"}).getByRole("button").last().click() + await menuItem(admin, "Delete role").click() + + const confirm = dialog(admin, "Delete Role") + + await expect( + confirm.getByText('Delete the "Bosun" role? Members will keep their space membership.'), + ).toBeVisible() + + await confirm.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Role deleted!") + await expect(emptyState).toBeVisible() + + await roles.getByRole("button", {name: "Go back"}).click() + + await expect(memberCard(admin, "Bob Barnacle")).toBeVisible() + await expect(memberCard(admin, "Bob Barnacle").locator(".role-badge")).toHaveCount(0) + + const bob = await as(users.bob, spacePath(url) + "/directory") + + await expect(memberCard(bob, "Carol Cutter")).toBeVisible() + await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0) +}) + +test("US-094 invite people to a space", async ({seed, as}) => { + const newcomer = makeTestUser("newcomer") + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.bob, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + + // Somebody who belongs to a different space, so adding her here is a change to this space's + // member list rather than something the scenario already seeded. + other.room("lounge", {name: "Lounge"}) + other.join(user.admin, "lounge") + other.join(newcomer, "lounge") + other.profile(newcomer, {name: "Nadia Newcomer"}) + other.message(newcomer, "lounge", "hello from the lounge", at(2, HOUR)) + }) + + const other = scenario.space("other") + + // Arriving through the other space is what loads Nadia's profile: the invite dialog's search + // reads the profiles this client already holds. + const page = await as(users.admin, roomPath(other.url, "lounge"), { + context: {permissions: ["clipboard-read", "clipboard-write"]}, + }) + + await expect(page.getByText("Nadia Newcomer").first()).toBeVisible() + + // In-app navigation rather than a reload, which would empty the repository she was loaded into + await page.locator('.primary-nav [data-tip^="space"]').click() + await openSpaceMenu(page) + await menuItem(page, "Create Invite").click() + + const invite = dialog(page, "Create an Invite") + const link = invite.locator("input[readonly]") + + await expect(invite.locator("canvas")).toBeVisible() + await expect(link).toHaveValue(/\/join\?r=space\.test&c=.+/) + + await invite.locator("label:has(input[readonly])").getByRole("button").click() + + await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(await link.inputValue()) + + await invite.getByPlaceholder("Search for profiles...").fill("Nadia") + await page.locator(".tiptap-suggestions").getByRole("button", {name: newcomer.pubkey}).click() + await invite.getByRole("button", {name: "Save", exact: true}).click() + + await expect(page.getByRole("alert")).toContainText("Members have successfully been added!") + + await page.locator(".secondary-nav").getByRole("link", {name: "Directory"}).click() + + await expect(memberCard(page, "Nadia Newcomer")).toBeVisible() + + // A room's own invite carries the space's claim and that room's join code alongside it + await page.locator(".space-menu__scroll").getByRole("link", {name: "General"}).click() + await page.locator('[data-component="PageBar"]').getByRole("button").last().click() + await page.getByRole("button", {name: "Create invite"}).click() + + const roomInvite = dialog(page, "Create a Room Invite") + + await expect(roomInvite.locator("canvas")).toBeVisible() + await expect(roomInvite.locator("input[readonly]")).toHaveValue( + /\/join\?r=space\.test&c=[^&]+&h=general&code=.+/, + ) +}) + +test("US-095 remove, ban, and restore members", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.bob, "general") + space.join(user.carol, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + space.profile(user.carol, {name: "Carol Cutter"}) + }) + + const {url} = scenario.space("space") + + // An ordinary member reads the same directory without any of the controls that manage it + const bob = await as(users.bob, spacePath(url) + "/directory") + const carolsCard = memberCard(bob, "Carol Cutter") + + await expect(carolsCard).toBeVisible() + await expect(carolsCard.getByRole("button")).toHaveCount(1) + await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0) + + const admin = await as(users.admin, spacePath(url) + "/directory") + + await memberCard(admin, "Bob Barnacle").getByRole("button").last().click() + await menuItem(admin, "Remove member").click() + + const removal = dialog(admin, "Remove Member") + + await expect(removal.getByText("Remove @Bob Barnacle from the space?")).toBeVisible() + + await removal.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Member has successfully been removed!") + await expect(memberCard(admin, "Bob Barnacle")).toHaveCount(0) + + await memberCard(admin, "Carol Cutter").getByRole("button").last().click() + await menuItem(admin, "Ban member").click() + + const ban = dialog(admin, "Ban Member") + + await expect(ban.getByText("Ban @Carol Cutter from the space?")).toBeVisible() + + await ban.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Member has successfully been banned!") + await expect(memberCard(admin, "Carol Cutter")).toHaveCount(0) + + await admin.getByRole("button", {name: "More options"}).click() + await menuItem(admin, "Banned Members").click() + + const banned = dialog(admin, "Banned users") + + await expect(banned.getByText("Carol Cutter")).toBeVisible() + + await banned.locator(".card").filter({hasText: "Carol Cutter"}).getByRole("button").last().click() + await menuItem(admin, "Restore User").click() + + await expect(admin.getByRole("alert")).toContainText( + "User has successfully been restored to membership!", + ) + await expect(memberCard(admin, "Carol Cutter")).toBeVisible() +}) + +test("US-096 moderate messages and posts", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.alice, "general") + space.join(user.bob, "general") + space.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anchor"}) + space.profile(user.bob, {name: "Bob Barnacle"}) + space.message(user.alice, "general", "the hull is patched", at(2, HOUR)) + space.message(user.bob, "general", "aye captain", at(90, MINUTE)) + + space.event( + user.bob, + () => + space + .kind(Article) + .writer() + .setIdentifier("charting-the-reef") + .setTitle("Charting the Reef") + .setSummary("Where the shallows are.") + .setPublishedAt(at(3, HOUR)) + .setContent("Mind the coral.") + .renderTemplate(), + at(3, HOUR), + ) + + // A second article, so a page with the deleted one missing is still a page that loaded + space.event( + user.alice, + () => + space + .kind(Article) + .writer() + .setIdentifier("tides-and-timetables") + .setTitle("Tides and Timetables") + .setSummary("When to sail.") + .setPublishedAt(at(4, HOUR)) + .setContent("Slack water is best.") + .renderTemplate(), + at(4, HOUR), + ) + }) + + const {url} = scenario.space("space") + + // An author gets a delete on their own content, and nothing to report + const alice = await as(users.alice, roomPath(url, "general")) + + await expect(alice.getByText("the hull is patched")).toBeVisible() + + await openMessageMenu(alice, "the hull is patched") + + await expect(menuItem(alice, "Delete Message")).toBeVisible() + await expect(alice.getByRole("button", {name: "Report Content", exact: true})).toHaveCount(0) + + await alice.goto(spacePath(url) + "/articles") + await openEventMenu(articleCard(alice, "Tides and Timetables")) + + await expect(menuItem(alice, "Delete Article")).toBeVisible() + + // Everybody else gets a report instead, and the report needs a reason + const bob = await as(users.bob, roomPath(url, "general")) + + await openMessageMenu(bob, "the hull is patched") + + await expect(menuItem(bob, "Report Content")).toBeVisible() + await expect(bob.getByRole("button", {name: "Delete Message", exact: true})).toHaveCount(0) + + await menuItem(bob, "Report Content").click() + + const report = dialog(bob, "Report Content") + + await report.getByRole("button", {name: "Send Report"}).click() + + await expect(bob.getByRole("alert")).toContainText("Please select a reason for your report.") + await expect(report).toBeVisible() + + await report.getByRole("combobox").selectOption("Spam") + await report.getByRole("button", {name: "Send Report"}).click() + + await expect(bob.getByRole("alert")).toContainText("Your report has been sent!") + + // An admin gets a delete on anything, authored by them or not + const admin = await as(users.admin, roomPath(url, "general")) + + await expect(admin.getByText("aye captain")).toBeVisible() + + await openMessageMenu(admin, "aye captain") + await menuItem(admin, "Delete Message").click() + + const messageConfirm = dialog(admin, "Delete Message") + + await expect( + messageConfirm.getByText("Are you sure you want to delete this message from the space?"), + ).toBeVisible() + + await messageConfirm.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!") + + await admin.goto(spacePath(url) + "/articles") + await openEventMenu(articleCard(admin, "Charting the Reef")) + await menuItem(admin, "Delete Article").click() + + const articleConfirm = dialog(admin, "Delete Article") + + await expect( + articleConfirm.getByText("Are you sure you want to delete this article from the space?"), + ).toBeVisible() + + await articleConfirm.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!") + + // A viewer arriving after the fact reads the space as the relay now holds it + const carol = await as(users.carol, roomPath(url, "general")) + + await expect(carol.getByText("the hull is patched")).toBeVisible() + await expect(carol.getByText("aye captain")).toHaveCount(0) + + await carol.goto(spacePath(url) + "/articles") + + await expect(articleCard(carol, "Tides and Timetables")).toBeVisible() + await expect(articleCard(carol, "Charting the Reef")).toHaveCount(0) +}) + +test("US-097 work through the action-items queue", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + // A room the relay will not admit anyone to on their own say-so, so a join request stays + // pending instead of being granted the moment it lands. + space.room("vault", {name: "Vault", closed: true, private: true}) + space.join(user.admin, "general", "vault") + space.join(user.alice, "general") + space.join(user.bob, "general") + space.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anchor"}) + space.profile(user.bob, {name: "Bob Barnacle"}) + space.profile(user.carol, {name: "Carol Cutter"}) + space.message(user.alice, "general", "the hull is patched", at(2, HOUR)) + space.message(user.alice, "general", "the sails are torn", at(90, MINUTE)) + }) + + const {url} = scenario.space("space") + const admin = await as(users.admin, spacePath(url) + "/about") + const queueButton = admin.getByRole("button", {name: /^Action Items/}) + + await openSpaceMenu(admin) + + await expect(queueButton).toHaveText("Action Items (0)") + await expect(queueButton.locator(".bg-primary")).toHaveCount(0) + + await admin.keyboard.press("Escape") + + const report = async (page: Page, text: string, reason: string) => { + await openMessageMenu(page, text) + await menuItem(page, "Report Content").click() + + const form = dialog(page, "Report Content") + + await form.getByRole("combobox").selectOption(reason) + await form.getByRole("button", {name: "Send Report"}).click() + + await expect(page.getByRole("alert")).toContainText("Your report has been sent!") + } + + const askToJoinVault = async (page: Page) => { + await page.goto(roomPath(url, "vault")) + await page.getByRole("button", {name: "Join Room"}).click() + + await expect(page.getByRole("button", {name: "Access Pending"})).toBeVisible() + } + + const bob = await as(users.bob, roomPath(url, "general")) + + await expect(bob.getByText("the hull is patched")).toBeVisible() + + await report(bob, "the hull is patched", "Spam") + await askToJoinVault(bob) + + const carol = await as(users.carol, roomPath(url, "general")) + + await expect(carol.getByText("the sails are torn")).toBeVisible() + + await report(carol, "the sails are torn", "Other") + await askToJoinVault(carol) + + await openSpaceMenu(admin) + + await expect(queueButton).toHaveText("Action Items (4)") + await expect(queueButton.locator(".bg-primary")).toHaveCount(1) + + await queueButton.click() + + const queue = dialog(admin, "Action Items") + const item = (text: string, kind: string) => + queue.locator(".card").filter({hasText: text}).filter({hasText: kind}) + + // Each report names its reporter, its reason and the message it was filed against + const spamReport = item("Bob Barnacle", "Reported this event") + + await expect(spamReport).toContainText('as "spam"') + await expect(spamReport).toContainText("the hull is patched") + + const otherReport = item("Carol Cutter", "Reported this event") + + await expect(otherReport).toContainText('as "other"') + await expect(otherReport).toContainText("the sails are torn") + + // ...and each join request names its requester and the room they asked for + const carolsRequest = item("Carol Cutter", "requested membership") + const bobsRequest = item("Bob Barnacle", "requested membership") + + await expect(carolsRequest).toContainText("requested membership in #Vault") + await expect(bobsRequest).toContainText("requested membership in #Vault") + + await carolsRequest.getByRole("button", {name: "Accept"}).click() + + await expect(admin.getByRole("alert")).toContainText("Member has been added to the room!") + await expect(carolsRequest).toHaveCount(0) + + await bobsRequest.getByRole("button", {name: "Dismiss"}).click() + + await expect(admin.getByRole("alert")).toContainText("Join request has been dismissed.") + await expect(bobsRequest).toHaveCount(0) + + // Removing content deletes the message the report was filed against... + await spamReport.getByRole("button").first().click() + await menuItem(admin, "Remove Content").click() + + const removal = dialog(admin, "Remove Content") + + await expect( + removal.getByText("Are you sure you want to delete this content from the space?"), + ).toBeVisible() + + await removal.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Content has successfully been deleted!") + await expect(spamReport).toHaveCount(0) + + // ...while dismissing only clears the report + await otherReport.getByRole("button").first().click() + await menuItem(admin, "Dismiss Report").click() + + await expect(otherReport).toHaveCount(0) + + // Accepting granted membership, dismissing did not + await admin.goto(roomPath(url, "vault")) + await admin.locator('[data-component="PageBar"]').getByRole("button").last().click() + await admin.getByRole("button", {name: "View All"}).click() + + const members = dialog(admin, "Members") + + await expect(members.getByText("Carol Cutter")).toBeVisible() + await expect(members.getByText("Bob Barnacle")).toHaveCount(0) + + // The removed message is gone from the space, the dismissed one is not + const alice = await as(users.alice, roomPath(url, "general")) + + await expect(alice.getByText("the sails are torn")).toBeVisible() + await expect(alice.getByText("the hull is patched")).toHaveCount(0) + + // A member never sees the queue at all + const bobsSpace = await as(users.bob, spacePath(url) + "/about") + + await openSpaceMenu(bobsSpace) + + await expect(menuItem(bobsSpace, "Create Invite")).toBeVisible() + await expect(bobsSpace.getByRole("button", {name: /^Action Items/})).toHaveCount(0) +}) + +test("US-098 browse and create hosted spaces", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + other.room("lounge", {name: "Lounge"}) + other.join(user.alice, "lounge") + }) + + const page = await as(users.alice, "/settings/hosting", { + hosting: {plans: PLANS}, + // A space created here is opened at wss://./, so the domain has to be one + // the container serves and the subdomain has to name one of its tenants. + env: {VITE_HOSTING_RELAY_DOMAIN: "test"}, + }) + + await expect(page.getByText("You don't host any spaces yet.")).toBeVisible() + await expect(page.getByText("Create a space to get started.")).toBeVisible() + + await page.getByRole("button", {name: "Create a Space"}).click() + + const form = dialog(page, "New Space") + + await form.getByPlaceholder("My Space").fill("Space") + + // The subdomain follows the name until it is edited + await expect(form.getByPlaceholder("my-space")).toHaveValue("space") + + await form.getByRole("button").filter({hasText: "Up to 50 members"}).click() + await form.getByRole("button", {name: "Create Space", exact: true}).click() + + await expect(page).toHaveURL(/\/spaces\/space\.test\/admin/) + await expect(page.getByRole("heading", {name: "Space", exact: true})).toBeVisible() + + await page.goto("/settings/hosting") + + const listed = page.locator(".card").filter({hasText: "space.test"}).first() + + await expect(listed).toContainText("Space") + await expect(listed).toContainText("Active") + await expect(listed).toContainText("Free") + await expect(listed.getByRole("link", {name: "Manage"})).toBeVisible() + + // A paid plan has to be paid for, so creation hands straight over to payment + await page.getByRole("button", {name: "Create a Space"}).click() + await form.getByPlaceholder("My Space").fill("Other") + + await expect(form.getByPlaceholder("my-space")).toHaveValue("other") + + await form.getByRole("button").filter({hasText: "Unlimited members"}).click() + await form.getByRole("button", {name: "Create Space", exact: true}).click() + + await expect(page).toHaveURL(/\/spaces\/other\.test\/admin/) + await expect(page.getByRole("heading", {name: "Set Up Payments"})).toBeVisible() +}) + +test("US-099 configure a hosted relay", 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 relayRecord = hostedRelay({ + activity: [ + { + id: "activity-1", + tenant_pubkey: users.alice.pubkey, + created_at: scenario.at(1, DAY), + activity_type: "create_relay", + resource_type: "relay", + resource_id: "relay-1", + }, + ], + }) + + const alice = await as(users.alice, spacePath(url) + "/admin", { + hosting: {plans: PLANS, relays: [relayRecord]}, + }) + + await expect(alice.getByRole("heading", {name: "Space", exact: true})).toBeVisible() + await expect(alice.getByText("Relay created")).toBeVisible() + + await alice.getByRole("button", {name: "Relay actions"}).click() + await menuItem(alice, "Edit details").click() + + const editor = dialog(alice, "Edit Space") + + await editor.getByPlaceholder("My Space").fill("Harbor") + await editor.locator("textarea").fill("Where the fleet ties up.") + await editor.getByRole("button", {name: "Save Changes"}).click() + + await expect(alice.getByRole("heading", {name: "Harbor", exact: true})).toBeVisible() + await expect(alice.getByText("Where the fleet ties up.")).toBeVisible() + + const publicRead = settingToggle(alice, "Public read") + + await expect(publicRead).not.toBeChecked() + + await publicRead.click() + + await expect(publicRead).toBeChecked() + + await alice.reload() + + await expect(settingToggle(alice, "Public read")).toBeChecked() + + // The free plan doesn't include either of these, and says so rather than failing on save + const blossom = settingToggle(alice, "Media storage") + + await expect(blossom).toBeDisabled() + await expect(settingToggle(alice, "LiveKit support")).toBeDisabled() + + // The tooltip hangs off a wrapper rather than the input, so it still shows while it is disabled + await blossom.locator("xpath=..").hover() + + await expect(alice.getByText("Not available on your current plan").first()).toBeVisible() + + // A member of the space who doesn't own it has nothing to manage here + const bob = await as(users.bob, spacePath(url) + "/admin") + + await expect(bob.getByRole("heading", {name: "Not a Coracle-hosted space"})).toBeVisible() + await expect(bob.getByRole("link", {name: "Go to Hosting settings"})).toHaveAttribute( + "href", + "/settings/hosting", + ) +}) + +test("US-100 change a hosted relay's plan", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, spacePath(url) + "/admin", { + hosting: {plans: PLANS, relays: [hostedRelay()]}, + }) + + await expect(setting(page, "Current plan")).toHaveText("Free") + + await page.getByRole("button", {name: "Relay actions"}).click() + await menuItem(page, "Change plan").click() + + const plans = dialog(page, "Change plan") + + await plans.getByRole("button").filter({hasText: "Unlimited members"}).click() + await plans.getByRole("button", {name: "Save", exact: true}).click() + + await expect(page.getByRole("alert")).toContainText("Plan updated.") + + // Nothing is on file to charge, so the upgrade hands over to payment setup + await expect(page.getByRole("heading", {name: "Set Up Payments"})).toBeVisible() + + await page.getByRole("button", {name: "Set up later"}).click() + + await expect(badge(page, "Basic")).toBeVisible() + await expect(setting(page, "Current plan")).toHaveText("Basic") + + // The paid plan unlocks both gated features + const blossom = settingToggle(page, "Media storage") + const livekit = settingToggle(page, "LiveKit support") + + await expect(blossom).toBeEnabled() + + await blossom.click() + await livekit.click() + + await expect(blossom).toBeChecked() + await expect(livekit).toBeChecked() + + await page.getByRole("button", {name: "Relay actions"}).click() + await menuItem(page, "Change plan").click() + await plans.getByRole("button").filter({hasText: "Up to 50 members"}).click() + await plans.getByRole("button", {name: "Save", exact: true}).click() + + await expect(page.getByRole("alert")).toContainText("Plan updated.") + await expect(badge(page, "Free")).toBeVisible() + await expect(setting(page, "Current plan")).toHaveText("Free") + await expect(settingToggle(page, "Media storage")).not.toBeChecked() + await expect(settingToggle(page, "LiveKit support")).not.toBeChecked() +}) + +test("US-101 point a custom domain at a hosted relay", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + // Where the space ends up once the domain verifies, so the client has somewhere real to follow + // it to. + other.room("general", {name: "General"}) + other.join(user.alice, "general") + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, spacePath(url) + "/admin", { + hosting: {plans: PLANS, relays: [hostedRelay()]}, + context: {permissions: ["clipboard-read", "clipboard-write"]}, + }) + + await expect(page.getByText("Not configured")).toBeVisible() + + await page.getByRole("button", {name: "Add domain"}).click() + + const domainForm = dialog(page, "Custom domain") + + await domainForm.getByPlaceholder("relay.example.com").fill("other.test") + await domainForm.getByRole("button", {name: "Save", exact: true}).click() + + await expect(page.getByRole("alert")).toContainText("Custom domain saved.") + await expect(page.getByText("other.test", {exact: true})).toBeVisible() + await expect(page.getByText("Pending", {exact: true})).toBeVisible() + await expect(page.getByText("other.test CNAME space.test")).toBeVisible() + + await page.locator('[data-tip="Copy CNAME target"]').click() + + await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("space.test") + + // The backend hasn't seen the record yet, so verifying says so and leaves the badge alone + await page.getByRole("button", {name: "Verify DNS record"}).click() + + await expect(page.getByRole("alert")).toContainText( + "Not verified yet. DNS changes can take a while to propagate.", + ) + await expect(page.getByText("Pending", {exact: true})).toBeVisible() + + getHosting(page.context()).setRelay("relay-1", {custom_domain_verified: 1}) + + await page.getByRole("button", {name: "Verify DNS record"}).click() + + await expect(page.getByRole("alert")).toContainText("Custom domain verified.") + await expect(page.getByText("Verified", {exact: true})).toBeVisible() + + // The space is now reached through the domain its owner controls + await expect(page.getByRole("link", {name: "wss://other.test"})).toBeVisible() + await expect(page).toHaveURL(/\/spaces\/other\.test\/admin/) +}) + +test("US-102 pause a relay and settle the bill", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const {url} = scenario.space("space") + const paidPeriod = {start: scenario.at(2, MONTH), end: scenario.at(1, MONTH)} + const openPeriod = {start: scenario.at(1, MONTH), end: scenario.startedAt} + + const page = await as(users.alice, spacePath(url) + "/admin", { + hosting: { + plans: PLANS, + relays: [hostedRelay({plan_id: "basic"})], + // Declared oldest first, which is not the order payment history reads in. Invoice's + // paid_at/voided_at/method are number | null / InvoiceMethod | null upstream, so null is + // how the hosting API spells "hasn't happened yet". + /* eslint-disable no-restricted-syntax */ + invoices: [ + { + id: "invoice-paid", + tenant_pubkey: users.alice.pubkey, + amount: 500, + period_start: paidPeriod.start, + period_end: paidPeriod.end, + created_at: paidPeriod.start, + paid_at: paidPeriod.end, + voided_at: null, + method: "nwc", + }, + { + id: "invoice-open", + tenant_pubkey: users.alice.pubkey, + amount: 900, + period_start: openPeriod.start, + period_end: openPeriod.end, + created_at: openPeriod.start, + paid_at: null, + voided_at: null, + method: null, + }, + ], + /* eslint-enable no-restricted-syntax */ + }, + }) + + await expect(page.getByText("Active", {exact: true})).toBeVisible() + + await page.getByRole("button", {name: "Relay actions"}).click() + await menuItem(page, "Deactivate").click() + + const deactivate = dialog(page, "Deactivate relay?") + + await expect(deactivate.getByText(/All client connections are dropped/)).toBeVisible() + + await deactivate.getByRole("button", {name: "Confirm"}).click() + + await expect(page.getByText("Inactive", {exact: true})).toBeVisible() + + await page.getByRole("button", {name: "Relay actions"}).click() + await menuItem(page, "Reactivate").click() + + await dialog(page, "Reactivate relay?").getByRole("button", {name: "Confirm"}).click() + + await expect(page.getByText("Active", {exact: true})).toBeVisible() + + await page.goto("/settings/hosting") + + const banner = page.getByText( + "You have an unpaid invoice. Pay it now to keep your relays running.", + ) + + await expect(banner).toBeVisible() + + await page.getByRole("button", {name: "Pay now"}).first().click() + + const payment = dialog(page, "Pay invoice") + + await expect(payment.getByText("$9.00")).toBeVisible() + await expect(payment.locator("canvas")).toBeVisible() + await expect(payment.locator("input[readonly]")).toHaveValue("lnbcinvoice-open") + + await payment.getByRole("button", {name: "Check payment"}).click() + + await expect(page.getByRole("alert")).toContainText( + "Payment not yet confirmed. Please try again after sending.", + ) + + getHosting(page.context()).setInvoice("invoice-open", {paid_at: now(), method: "nwc"}) + + await payment.getByRole("button", {name: "Check payment"}).click() + + await expect(payment.getByText("Payment confirmed!")).toBeVisible() + + await payment.getByRole("button", {name: "Done"}).click() + + await expect(banner).toHaveCount(0) + + // With the balance settled, the standing prompt is to keep it that way + await page.getByRole("button", {name: "Set up autopay"}).click() + + const autopay = dialog(page, "Set Up Payments") + + await autopay + .getByPlaceholder("nostr+walletconnect://") + .fill(`nostr+walletconnect://${users.alice.pubkey}?relay=${url}&secret=abc`) + await autopay.getByRole("button", {name: "Save", exact: true}).click() + + await expect(autopay.getByText("Wallet connected!")).toBeVisible() + + await autopay.getByRole("button", {name: "Done"}).click() + + await expect(page.getByRole("listitem").filter({hasText: "Lightning (NWC)"})).toContainText( + "Connected", + ) + + // Payment history, newest first + const history = page.locator(".card").filter({hasText: "Payment History"}).first() + const invoices = history.getByRole("listitem") + + // The whole period, not just its start: the two invoices meet at a month boundary, so either + // date on its own reads the same on both of them. + const period = ({start, end}: {start: number; end: number}) => + `${new Date(start * 1000).toLocaleDateString()} – ${new Date(end * 1000).toLocaleDateString()}` + + await expect(invoices.first()).toContainText("$9.00") + await expect(invoices.first()).toContainText(period(openPeriod)) + await expect(invoices.nth(1)).toContainText("$5.00") + await expect(invoices.nth(1)).toContainText(period(paidPeriod)) +}) diff --git a/e2e/specs/articles-threads.spec.ts b/e2e/specs/articles-threads.spec.ts new file mode 100644 index 00000000..61bc4540 --- /dev/null +++ b/e2e/specs/articles-threads.spec.ts @@ -0,0 +1,947 @@ +import * as nip19 from "nostr-tools/nip19" +import {HOUR, MINUTE} from "@welshman/lib" +import {LONG_FORM, MESSAGE, NOTE, makeEvent} from "@welshman/util" +import type {SignedEvent, TrustedEvent} from "@welshman/util" +import {ClientMessageType} from "@welshman/net" +import {Article, Comment, RelayList, Thread} from "@welshman/domain" +import type {Locator, Page} from "@playwright/test" +import {expect, getTranscript, mockBlossom, roomPath, spacePath, test, users} from "../harness" + +// A handle to a seeded event, which only reads once seed() has drained its queue. +type Seeded = {readonly id: string; readonly event: SignedEvent} + +// Where an upload lands. getBlossomServer probes the space's own origin first — blossom is off in +// every tenant's toml, so that probe is meant to fail — then the user's kind-10063 list, and +// VITE_DEFAULT_BLOSSOM_SERVERS is what is left. +const BLOSSOM_ORIGIN = "https://blossom.primal.net" + +// A real 1x1 gif. Gif rather than png because compressFileForUpload passes gif through untouched +// instead of re-encoding it through a canvas. +const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64") + +const PARTY = "🎉" + +// Every event of a kind this page put on the wire, oldest first. +const publishedEvents = (page: Page, kind: number): TrustedEvent[] => + getTranscript(page.context()) + .filter( + ({direction, message}) => + direction === "toRelay" && + message[0] === ClientMessageType.Event && + message[1].kind === kind, + ) + .map(({message}) => message[1]) + +// A modal is mounted alongside the page it covers, so the "Create Thread" in a room's compose menu +// and the "Create Thread" that submits the composer are both reachable at once. Everything inside +// a composer is scoped to the modal's own form to say which one is meant. +const modal = (page: Page, title: string) => + page.locator("form").filter({has: page.getByRole("heading", {name: title})}) + +// The comment and thread-reply composers are the only forms on their pages carrying a rich text +// editor. +const composerForm = (page: Page) => + page.locator("form").filter({has: page.locator(".note-editor")}) + +const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]") + +const pageBar = (page: Page) => page.locator('[data-component="PageBar"]') + +// EventActions renders zap, emoji and menu into one join, in that order, and every one of them is +// an icon with no accessible name. +const emojiButton = (scope: Locator) => scope.locator(".join").getByRole("button").nth(1) + +// RoomCompose's join is the upload button and then the compose menu, which is where an article or +// a thread written from inside a room is started. +const openComposeMenu = (page: Page) => + page + .locator("form") + .filter({has: page.locator(".chat-editor")}) + .locator(".join") + .getByRole("button") + .nth(1) + .click() + +// The picker is a web component with an open shadow root, so its search field and its results are +// reachable through it. Searching rather than browsing avoids depending on which category tab an +// emoji happens to live under. +const pickParty = async (page: Page, opener: Locator) => { + await opener.click() + + const picker = page.locator("emoji-picker").filter({visible: true}) + + // Tippy keeps a hidden popover mounted through its fade — a quarter of a second during which the + // picker from the last card is still visible alongside this one — so wait for there to be one + // rather than reaching into whichever resolves first. + await expect(picker).toHaveCount(1) + + // A result's label is the emoji's name, its annotation and every shortcode joined together, so + // the annotation is matched rather than the whole of it. + await picker.locator("input.search").fill("party popper") + await picker + .getByRole("option", {name: /party popper/}) + .first() + .click() +} + +// One reaction, recorded as the reader's own, and taken back off again. +const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locator) => { + const pill = scope.getByRole("button", {name: new RegExp(PARTY)}) + + await pickParty(page, opener) + await expect(pill).toHaveCount(1) + await expect(pill).toHaveClass(/button-primary/) + + await pill.click() + await expect(pill).toHaveCount(0) +} + +// formatTimestamp renders a short date and a short time in the environment's own locale, which node +// shares, so the date half of it is what a spec can name without pinning a format. +const shortDate = (seconds: number) => + new Intl.DateTimeFormat(undefined, {dateStyle: "short"}).format(new Date(seconds * 1000)) + +// The card is a div carrying an overlay link, so it is found by its component rather than by a +// role — its own contents include a profile button and the room and action links. +const articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]') + +const openArticle = (page: Page, title: string) => + articleCards(page).filter({hasText: title}).getByRole("link", {name: title, exact: true}).click() + +test("US-037 write and publish an article", async ({seed, as}) => { + 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.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + + // An older article, so "at the top of the list" is a statement about order. + space.event( + user.bob, + () => + space + .kind(Article) + .writer() + .setIdentifier("old-news") + .setTitle("Old News") + .setSummary("Something published a while back.") + .setPublishedAt(at(3, HOUR)) + .setContent("Yesterday's headlines.") + .renderTemplate(), + at(3, HOUR), + ) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, `${spacePath(url)}/articles`) + + await expect(articleCards(page)).toHaveCount(1) + + await pageBar(page).getByRole("button", {name: "Write"}).click() + + const composer = modal(page, "Write an Article") + const title = composer.getByPlaceholder("What is this article about?") + const body = editorOf(composer) + + await expect(composer).toBeVisible() + + await composer.getByRole("button", {name: "Publish Article"}).click() + await expect(page.getByRole("alert")).toContainText("Please provide a title for your article.") + + await title.fill("Half Baked") + await composer.getByRole("button", {name: "Publish Article"}).click() + await expect(page.getByRole("alert")).toContainText("Please write something for your article.") + + await body.pressSequentially("Only the beginning.") + + // Neither refusal put anything on the wire. + expect(publishedEvents(page, LONG_FORM)).toEqual([]) + + await composer.getByRole("button", {name: "Go back"}).click() + await expect(page.getByRole("heading", {name: "Write an Article"})).toHaveCount(0) + + // Reopening picks up where the composer was closed rather than starting over. + await pageBar(page).getByRole("button", {name: "Write"}).click() + await expect(title).toHaveValue("Half Baked") + await expect(body).toContainText("Only the beginning.") + + await title.fill("Signals in the Noise") + await composer.getByRole("button", {name: "Publish Article"}).click() + + await expect(page.getByRole("heading", {name: "Write an Article"})).toHaveCount(0) + await expect(articleCards(page)).toHaveCount(2) + await expect(articleCards(page).first()).toContainText("Signals in the Noise") + expect(publishedEvents(page, LONG_FORM)).toHaveLength(1) + + // A published article leaves the composer empty for the next one. + await pageBar(page).getByRole("button", {name: "Write"}).click() + await expect(title).toHaveValue("") + await expect(body).toHaveText("") +}) + +test("US-038 browse, filter, and read articles", async ({seed, as}) => { + const body = [ + "## Why gardens matter", + "", + "Gardens are **worth the trouble**.", + "", + "- Soil", + "- Water", + "- Sunlight", + "", + `Ask ${nip19.npubEncode(users.carol.pubkey)} what she thinks.`, + ].join("\n") + + 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.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + space.profile(user.carol, {name: "Carol Chen"}) + + space.event( + user.alice, + () => + space + .kind(Article) + .writer() + .setIdentifier("tending-the-garden") + .setTitle("Tending the Garden") + .setSummary("A short teaser about gardens.") + .setImage("https://images.test/garden.jpg") + .setTopics(["gardening"]) + .setPublishedAt(at(4, HOUR)) + .setContent(body) + .renderTemplate(), + at(4, HOUR), + ) + + space.event( + user.alice, + () => + space + .kind(Article) + .writer() + .setIdentifier("cooking-with-fire") + .setTitle("Cooking with Fire") + .setSummary("Heat, and what to do with it.") + .setTopics(["cooking"]) + .setPublishedAt(at(3, HOUR)) + .setContent("Build the fire first.") + .renderTemplate(), + at(3, HOUR), + ) + + space.event( + user.bob, + () => + space + .kind(Article) + .writer() + .setIdentifier("notes-on-foraging") + .setTitle("Notes on Foraging") + .setSummary("What grows where nobody planted it.") + .setTopics(["gardening"]) + .setPublishedAt(at(2, HOUR)) + .setContent("Look under the hedges.") + .renderTemplate(), + at(2, HOUR), + ) + + space.event( + user.carol, + () => + space + .kind(Article) + .writer() + .setIdentifier("winter-reading") + .setTitle("Winter Reading") + .setSummary("Six books for the dark months.") + .setTopics(["books"]) + .setPublishedAt(at(1, HOUR)) + .setContent("Start with the shortest one.") + .renderTemplate(), + at(1, HOUR), + ) + }) + + const {at} = scenario + const {url} = scenario.space("space") + const page = await as(users.bob, `${spacePath(url)}/articles`) + + const garden = articleCards(page).filter({hasText: "Tending the Garden"}) + + await expect(articleCards(page)).toHaveCount(4) + + // Author, published date and a preview, all on the card itself. + await expect(garden).toContainText("Written by") + await expect(garden).toContainText("Alice Anderson") + await expect(garden).toContainText("A short teaser about gardens.") + await expect(garden).toContainText(shortDate(at(4, HOUR))) + + const authors = page + .locator("section") + .filter({has: page.getByRole("heading", {name: "Authors"})}) + const topics = page.locator("section").filter({has: page.getByRole("heading", {name: "Topics"})}) + + await expect(authors.getByRole("button", {name: /Alice Anderson/})).toBeVisible() + await expect(authors.getByRole("button", {name: /Carol Chen/})).toBeVisible() + + await topics.getByRole("button", {name: /^#gardening/}).click() + await expect(articleCards(page)).toHaveCount(2) + await expect(articleCards(page).filter({hasText: "Notes on Foraging"})).toBeVisible() + + // Author and topic together are narrower than either on its own. + await authors.getByRole("button", {name: /Alice Anderson/}).click() + await expect(articleCards(page)).toHaveCount(1) + await expect(articleCards(page).first()).toContainText("Tending the Garden") + + await openArticle(page, "Tending the Garden") + + await expect(page.getByRole("heading", {name: "Tending the Garden"}).first()).toBeVisible() + await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible() + await expect(page.getByText("A short teaser about gardens.")).toBeVisible() + await expect(page.getByText(/^Published /)).toContainText(shortDate(at(4, HOUR))) + + const markdown = page.locator(".content-markdown") + + await expect(markdown.getByRole("heading", {name: "Why gardens matter"})).toBeVisible() + await expect(markdown.locator("strong")).toHaveText("worth the trouble") + await expect(markdown.locator("li")).toHaveText(["Soil", "Water", "Sunlight"]) + await expect(markdown.getByRole("link", {name: "@Carol Chen"})).toBeVisible() +}) + +test("US-039 comment on an article", async ({seed, as}) => { + 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.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + space.profile(user.carol, {name: "Carol Chen"}) + + space.event( + user.alice, + () => + space + .kind(Article) + .writer() + .setIdentifier("tending-the-garden") + .setTitle("Tending the Garden") + .setSummary("A short teaser about gardens.") + .setPublishedAt(at(4, HOUR)) + .setContent("Gardens are worth the trouble.") + .renderTemplate(), + at(4, HOUR), + ) + }) + + const {url} = scenario.space("space") + const articlesPath = `${spacePath(url)}/articles` + + const bob = await as(users.bob, articlesPath) + + await openArticle(bob, "Tending the Garden") + await bob.getByRole("button", {name: "Add a comment"}).click() + await editorOf(composerForm(bob)).pressSequentially("The soil chapter is the good one.") + await composerForm(bob).getByRole("button", {name: "Comment"}).click() + + // The comment renders from the optimistic write, but the composer holds what was typed until the + // relay confirms it, so for a moment the page carries this text twice. Match the rendered card. + await expect( + bob.locator(".card.z-feature").filter({hasText: "The soil chapter is the good one."}), + ).toBeVisible() + + const carol = await as(users.carol, articlesPath) + + await openArticle(carol, "Tending the Garden") + + const bobsComment = carol + .locator(".card.z-feature") + .filter({hasText: "The soil chapter is the good one."}) + + await expect(bobsComment).toBeVisible() + + await bobsComment.getByRole("button", {name: "Reply", exact: true}).click() + await editorOf(composerForm(carol)).pressSequentially( + "Only because you skipped the water chapter.", + ) + await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click() + + await expect( + carol + .locator(".card.z-feature") + .filter({hasText: "Only because you skipped the water chapter."}), + ).toBeVisible() + + const alice = await as(users.alice, articlesPath) + + await mockBlossom(alice.context(), {server: BLOSSOM_ORIGIN}) + await openArticle(alice, "Tending the Garden") + + await expect(alice.getByText("The soil chapter is the good one.")).toBeVisible() + await expect(alice.getByText("Only because you skipped the water chapter.")).toBeVisible() + + // A reply is nested inside the comment it answers; a top level comment is not. + const nested = alice.locator(".border-l.ml-4.pl-4") + + await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible() + await expect(nested.getByText("The soil chapter is the good one.")).toHaveCount(0) + + await alice.getByRole("button", {name: "Add a comment"}).click() + + const composer = composerForm(alice) + + await editorOf(composer).pressSequentially("Thanks both, here's the bed I meant.") + await editorOf(composer).press("Enter") + + const chooser = alice.waitForEvent("filechooser") + + await composer.locator('[data-tip="Add an image"]').click() + await (await chooser).setFiles({name: "bed.gif", mimeType: "image/gif", buffer: GIF}) + + // The attachment carries an uploading marker from the moment the request goes out until the + // blossom descriptor comes back and replaces its blob url. + await expect(composer.locator(".tiptap-object")).not.toHaveClass(/tiptap-uploading/) + + await composer.getByRole("button", {name: "Comment"}).click() + + const alicesComment = alice + .locator(".card.z-feature") + .filter({hasText: "Thanks both, here's the bed I meant."}) + + await expect(alicesComment).toBeVisible() + await expect(alicesComment.locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible() +}) + +test("US-040 react to a post with an emoji", async ({seed, as}) => { + let thread!: Seeded + + 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.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + + // A profile's notes are loaded through its author's outbox relays, so alice needs a relay list + // for her note to be findable at all. + space.event(user.alice, () => + space + .kind(RelayList) + .writer() + .setReadUrls([space.url]) + .setWriteUrls([space.url]) + .renderTemplate(), + ) + + const article = space.event( + user.alice, + () => + space + .kind(Article) + .writer() + .setIdentifier("tending-the-garden") + .setTitle("Tending the Garden") + .setPublishedAt(at(4, HOUR)) + .setContent("Gardens are worth the trouble.") + .renderTemplate(), + at(4, HOUR), + ) + + space.event( + user.alice, + () => + space + .kind(Comment) + .writer() + .setRootFromEvent(article.event) + .setParentFromEvent(article.event) + .setContent("A note about the soil chapter.") + .renderTemplate(), + at(3, HOUR), + ) + + const topic = space.event( + user.alice, + () => + space + .kind(Thread) + .writer() + .setRoom(space.url, "general") + .setTitle("Bed rotation") + .setContent("How often do you move things around?") + .renderTemplate(), + at(3, HOUR), + ) + + space.event( + user.alice, + () => + space + .kind(Comment) + .writer() + .setRootFromEvent(topic.event) + .setParentFromEvent(topic.event) + .setContent("Every other season, usually.") + .renderTemplate(), + at(2, HOUR), + ) + + space.event( + user.alice, + makeEvent(NOTE, {content: "A short note from the garden.", created_at: at(2, HOUR)}), + ) + + thread = topic + }) + + const {url} = scenario.space("space") + const page = await as(users.bob, `${spacePath(url)}/articles`) + + await openArticle(page, "Tending the Garden") + + const articleCard = page.locator(".card.z-feature").filter({hasText: "Gardens are worth"}) + const commentCard = page.locator(".card.z-feature").filter({hasText: "A note about the soil"}) + + await expect(articleCard).toBeVisible() + await expectReactionRoundTrip(page, articleCard, emojiButton(articleCard)) + + await expect(commentCard).toBeVisible() + await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard)) + + await page.goto(`${spacePath(url)}/threads/${thread.id}`) + + const threadPost = page.locator("article").filter({hasText: "Every other season, usually."}) + + await expect(threadPost).toBeVisible() + await expectReactionRoundTrip(page, threadPost, emojiButton(threadPost)) + + await page.goto(`/people/${nip19.npubEncode(users.alice.pubkey)}`) + + const noteCard = page + .locator(".card.card-interactive") + .filter({hasText: "A short note from the garden."}) + + await expect(noteCard).toBeVisible() + + // NoteItem's picker is the last control on the card rather than part of an EventActions join. + await expectReactionRoundTrip(page, noteCard, noteCard.getByRole("button").last()) +}) + +test("US-041 publish an article from a room", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("lounge", {name: "Lounge"}) + space.join(user.alice, "lounge") + space.profile(user.alice, {name: "Alice Anderson"}) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "lounge")) + + await openComposeMenu(page) + await page.getByRole("button", {name: "Write an Article"}).click() + + const composer = modal(page, "Write an Article") + + await composer.getByPlaceholder("What is this article about?").fill("Repotting in Winter") + await editorOf(composer).pressSequentially("Wait for a warm week.") + await composer.getByRole("button", {name: "Publish Article"}).click() + + await expect(page.getByRole("heading", {name: "Write an Article"})).toHaveCount(0) + + // The room hears about the article without alice posting it a second time. + await expect(page.getByText("Repotting in Winter")).toBeVisible() + + await page.goto(`${spacePath(url)}/articles`) + + const card = articleCards(page).filter({hasText: "Repotting in Winter"}) + + await expect(card).toBeVisible() + await expect(card.getByRole("link", {name: /#\s*Lounge/})).toBeVisible() + + await openArticle(page, "Repotting in Winter") + + const badge = page.getByRole("link", {name: /Posted in #\s*Lounge/}) + + await expect(badge).toBeVisible() + + await badge.click() + + await expect(page).toHaveURL(new RegExp(`${roomPath(url, "lounge")}$`)) + await expect(page.locator(".chat-editor")).toBeVisible() +}) + +test("US-042 start a thread and see it filed under its room", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("lounge", {name: "Lounge"}) + space.join(user.alice, "lounge") + space.join(user.bob, "lounge") + space.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + + // An older topic with replies, so the board's reply count and last-post time are statements + // about the thread rather than about an empty row. + const topic = space.event( + user.bob, + () => + space + .kind(Thread) + .writer() + .setRoom(space.url, "lounge") + .setTitle("Chair procurement") + .setContent("The old ones are done for.") + .renderTemplate(), + at(5, HOUR), + ) + + const replies = ["Mesh, obviously.", "Anything but mesh."] + + replies.forEach((content, index) => { + space.event( + index === 0 ? user.alice : user.bob, + () => + space + .kind(Comment) + .writer() + .setRootFromEvent(topic.event) + .setParentFromEvent(topic.event) + .setContent(content) + .renderTemplate(), + at(4, HOUR) + index * 60, + ) + }) + }) + + const {at} = scenario + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "lounge")) + + await openComposeMenu(page) + await page.getByRole("button", {name: "Create Thread"}).click() + + const fromRoom = modal(page, "Create a Thread") + + await fromRoom.getByPlaceholder("What is this thread about?").fill("Bike shed colors") + await editorOf(fromRoom).pressSequentially("Blue, surely?") + await fromRoom.getByRole("button", {name: "Create Thread"}).click() + + await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0) + + // The room gets a quote of the thread rather than a second post written by hand. + await expect(page.getByText("Bike shed colors")).toBeVisible() + + await page.goto(`${spacePath(url)}/threads`) + + const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})}) + const chairs = lounge.getByRole("row").filter({hasText: "Chair procurement"}) + + await expect(lounge.getByRole("row").filter({hasText: "Bike shed colors"})).toBeVisible() + + // Topic, author, reply count and last post, in that order. + await expect(chairs.getByRole("cell").nth(0)).toContainText("Chair procurement") + await expect(chairs.getByRole("cell").nth(1)).toContainText("Bob Barker") + await expect(chairs.getByRole("cell").nth(2)).toHaveText("2") + await expect(chairs.getByRole("cell").nth(3)).toContainText(shortDate(at(4, HOUR) + 60)) + + await pageBar(page).getByRole("button", {name: "Create", exact: true}).click() + + const fromThreads = modal(page, "Create a Thread") + + await fromThreads.getByPlaceholder("What is this thread about?").fill("Open floor") + await editorOf(fromThreads).pressSequentially("Anything goes in here.") + await fromThreads.getByRole("button", {name: "Create Thread"}).click() + + await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0) + + const general = page + .locator("section") + .filter({has: page.getByRole("heading", {name: "General", exact: true})}) + + await expect(general.getByRole("row").filter({hasText: "Open floor"})).toBeVisible() +}) + +test("US-043 reply to a thread and to a specific post", async ({seed, as}) => { + let thread!: Seeded + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("lounge", {name: "Lounge"}) + space.join(user.alice, "lounge") + space.join(user.bob, "lounge") + space.join(user.carol, "lounge") + space.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + space.profile(user.carol, {name: "Carol Chen"}) + + const topic = space.event( + user.alice, + () => + space + .kind(Thread) + .writer() + .setRoom(space.url, "lounge") + .setTitle("Bed rotation") + .setContent("How often do you move things around?") + .renderTemplate(), + at(3, HOUR), + ) + + // Twenty replies plus the opening post is one post past the first page, and the last of them + // is alice's, so her OP badge has to survive the page change. + for (let i = 1; i <= 20; i++) { + space.event( + i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob, + () => + space + .kind(Comment) + .writer() + .setRootFromEvent(topic.event) + .setParentFromEvent(topic.event) + .setContent(`Reply ${String(i).padStart(2, "0")}`) + .renderTemplate(), + at(3, HOUR) + i * 60, + ) + } + + thread = topic + }) + + const {url} = scenario.space("space") + const threadPath = `${spacePath(url)}/threads/${thread.id}` + const bob = await as(users.bob, threadPath) + + const openingPost = bob.locator(`[data-event="${thread.id}"]`) + + await expect(openingPost).toBeVisible() + await expect(openingPost.getByText("OP", {exact: true})).toBeVisible() + await expect(bob.getByText("20 replies")).toBeVisible() + + await bob.getByRole("button", {name: "Reply to thread"}).click() + + const threadReply = composerForm(bob) + + await expect(threadReply).toBeVisible() + await expect(threadReply.getByText(/^Replying to/)).toHaveCount(0) + + await editorOf(threadReply).pressSequentially("Twice a year here.") + await threadReply.getByRole("button", {name: "Post Reply"}).click() + + await expect(bob.getByText("21 replies")).toBeVisible() + + // The thread's author is marked OP wherever their posts turn up, second page included. + await bob.getByRole("button", {name: "2", exact: true}).click() + await expect(bob.getByText("Page 2 of 2")).toBeVisible() + + const alicesLastPost = bob.locator("article").filter({hasText: "Reply 20"}) + + await expect(alicesLastPost.getByText("OP", {exact: true})).toBeVisible() + await expect(bob.getByText("Twice a year here.")).toBeVisible() + + const carol = await as(users.carol, threadPath) + const bobsFirstPost = carol.locator("article").filter({hasText: "Reply 01"}) + + await expect(bobsFirstPost).toBeVisible() + await bobsFirstPost.getByRole("button", {name: "Reply", exact: true}).click() + + const postReply = composerForm(carol) + const replyingTo = postReply.locator(".border-l-2") + + await expect(replyingTo).toContainText("Replying to @Bob Barker") + await expect(replyingTo).toContainText("Reply 01") + + await replyingTo.getByRole("button").last().click() + + await expect(postReply.getByText(/^Replying to/)).toHaveCount(0) + + await editorOf(postReply).pressSequentially("Answering the thread instead.") + await postReply.getByRole("button", {name: "Post Reply"}).click() + + // Twenty one replies before hers is already a page and a bit, so her post is appended to the + // last one rather than to the page she wrote it from. The last control in the join is "go last", + // which is the same button whatever the page count turns out to be. + await carol + .getByText(/^Page \d+ of \d+$/) + .locator("xpath=..") + .locator(".join") + .getByRole("button") + .last() + .click() + + await expect(carol.getByText("Answering the thread instead.")).toBeVisible() +}) + +test("US-044 navigate a long thread", async ({seed, as}) => { + let thread!: Seeded + let lastPost!: Seeded + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("lounge", {name: "Lounge"}) + space.join(user.alice, "lounge") + space.join(user.bob, "lounge") + space.join(user.carol, "lounge") + space.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + space.profile(user.carol, {name: "Carol Chen"}) + + const topic = space.event( + user.alice, + () => + space + .kind(Thread) + .writer() + .setRoom(space.url, "lounge") + .setTitle("The long one") + .setContent("Settle in.") + .renderTemplate(), + at(4, HOUR), + ) + + // Forty one replies plus the opening post is three pages of twenty. + for (let i = 1; i <= 41; i++) { + const reply = space.event( + i % 2 === 0 ? user.alice : user.bob, + () => + space + .kind(Comment) + .writer() + .setRootFromEvent(topic.event) + .setParentFromEvent(topic.event) + .setContent(`Reply ${String(i).padStart(2, "0")}`) + .renderTemplate(), + at(4, HOUR) + i * 60, + ) + + if (i === 41) { + lastPost = reply + } + } + + thread = topic + }) + + const {url} = scenario.space("space") + const threadPath = `${spacePath(url)}/threads/${thread.id}` + const bob = await as(users.bob, threadPath, { + context: {permissions: ["clipboard-read", "clipboard-write"]}, + }) + + const indicator = bob.getByText(/^Page \d+ of \d+$/) + + await expect(indicator).toHaveText("Page 1 of 3") + await expect(bob.getByText("Reply 19", {exact: true})).toBeVisible() + + // ThreadPagination is first, prev, a button per page, next, last, and only the page numbers + // carry an accessible name — the rest are icons — so with three pages the ends are 0, 1, 5, 6. + const controls = indicator.locator("xpath=..").locator(".join").getByRole("button") + const goFirst = controls.nth(0) + const goPrev = controls.nth(1) + const goNext = controls.nth(5) + const goLast = controls.nth(6) + + await goNext.click() + await expect(indicator).toHaveText("Page 2 of 3") + await expect(bob.getByText("Reply 20", {exact: true})).toBeVisible() + + await goLast.click() + await expect(indicator).toHaveText("Page 3 of 3") + await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible() + + await goPrev.click() + await expect(indicator).toHaveText("Page 2 of 3") + await expect(bob.getByText("Reply 20", {exact: true})).toBeVisible() + + await goFirst.click() + await expect(indicator).toHaveText("Page 1 of 3") + await expect(bob.getByText("Reply 19", {exact: true})).toBeVisible() + + await bob.getByRole("button", {name: "3", exact: true}).click() + await expect(indicator).toHaveText("Page 3 of 3") + + const finalPost = bob.locator(`[data-event="${lastPost.id}"]`) + + await finalPost.getByRole("button", {name: "Permalink"}).click() + await expect(bob.getByRole("alert")).toContainText("Copied to clipboard!") + + const permalink = await bob.evaluate(() => navigator.clipboard.readText()) + const {pathname, hash} = new URL(permalink) + + expect(pathname).toBe(threadPath) + expect(hash).toBe(`#${nip19.neventEncode({id: lastPost.id, relays: [url]})}`) + + const carol = await as(users.carol, pathname + hash) + const target = carol.locator(`[data-event="${lastPost.id}"]`) + + await expect(carol.getByText(/^Page \d+ of \d+$/)).toHaveText("Page 3 of 3") + await expect(target).toBeVisible() + await expect(target).toBeInViewport() +}) + +test("US-045 turn a chat message into a thread", async ({seed, as}) => { + let promoted!: Seeded + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("lounge", {name: "Lounge"}) + space.join(user.alice, "lounge") + space.join(user.bob, "lounge") + space.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + + promoted = space.message(user.alice, "lounge", "the deploy broke again", at(20, MINUTE)) + }) + + const {url} = scenario.space("space") + const page = await as(users.bob, roomPath(url, "lounge")) + + const message = page.locator(`[data-event="${promoted.id}"]`) + + await expect(message).toBeVisible() + await message.hover() + + // RoomItem's hover actions are zap, emoji, reply and menu, all of them icons. + await message.locator(".room__item-actions").getByRole("button").last().click() + await page.getByRole("button", {name: "Create a Thread"}).click() + + const composer = modal(page, "Create a Thread") + const nevent = nip19.neventEncode({id: promoted.id, kind: MESSAGE, relays: [url]}) + + await expect(editorOf(composer)).toContainText(`nostr:${nevent}`) + + await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures") + await composer.getByRole("button", {name: "Create Thread"}).click() + + await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0) + + await page.goto(`${spacePath(url)}/threads`) + + const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})}) + const row = lounge.getByRole("row").filter({hasText: "Deploy failures"}) + + await expect(row).toBeVisible() + + await row.click() + + await expect(page.getByRole("heading", {name: "Deploy failures"})).toBeVisible() + await expect(page.getByText("the deploy broke again")).toBeVisible() +}) diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts new file mode 100644 index 00000000..57df101b --- /dev/null +++ b/e2e/specs/community.spec.ts @@ -0,0 +1,1142 @@ +import * as nip19 from "nostr-tools/nip19" +import {DAY, HOUR, MINUTE, bech32ToHex, int} from "@welshman/lib" +import {MESSAGE, POLL_RESPONSE, getLnUrl, tagSpec, tagValues, toMsats} from "@welshman/util" +import {ClientMessageType} from "@welshman/net" +import { + Comment, + Poll, + PollResponse, + Profile, + TimeEvent, + ZapGoal, + ZapReceipt, + ZapRequest, +} from "@welshman/domain" +import type {Locator, Page} from "@playwright/test" +import { + expect, + getTranscript, + makeTestUser, + mockBlossom, + mockDufflepud, + roomPath, + spacePath, + test, + users, +} from "../harness" +import type {TestUser} from "../harness" + +// A handle to a seeded event, which only reads once seed() has drained its queue. +type Seeded = {readonly id: string} + +// A modal is mounted alongside the page it covers, so a page's own "Create" and the modal's submit +// are both in the dom at once. Form assertions are scoped to the modal's own form to say which one +// is meant. +const dialog = (page: Page, title: string) => + page.locator("form").filter({has: page.getByRole("heading", {name: title})}) + +// The library's modals are plain Modals rather than forms, so the dialog wrapper is what scopes +// them. Dialog nests two elements carrying the class; the inner one holds the content. +const panel = (page: Page, title: string) => + page + .locator(".dialog") + .filter({has: page.getByRole("heading", {name: title, exact: true})}) + .last() + +const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]") + +// EventActions renders zap, emoji and menu into one join, in that order. +const emojiOf = (scope: Locator) => scope.locator(".join").getByRole("button").nth(1) + +const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last() + +// A shelf is a card and its menu button side by side, so the menu is reached through the wrapper +// the two share. +const shelfCard = (page: Page, title: string) => + page.getByRole("button", {name: new RegExp(title)}).locator("xpath=..") + +// A list item is one big link, but Button swallows both the default and the propagation of every +// click it handles, and the middle of a card is usually one of those — a poll's radio, a goal's +// zap button, a listing's image. Opening a card by its title lands on inert text instead. +const openCard = (card: Locator, title: string) => card.getByText(title).click() + +// One option of a poll, which PollOption renders as a small card carrying its label, its count and +// its progress bar. +const pollOption = (page: Page, label: string) => page.locator(".card-sm").filter({hasText: label}) + +// dateFormatter in @welshman/lib resolves the environment's own locale, which node shares. +const longDate = (seconds: number) => + new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format( + new Date(seconds * 1000), + ) + +// The selections of every poll response this page put on the wire, oldest first. A multiple choice +// vote goes out once the delay window closes, so this is where "both publish" is visible. +const pollResponses = (page: Page, pollId: string) => + getTranscript(page.context()) + .filter( + ({direction, message}) => + direction === "toRelay" && + message[0] === ClientMessageType.Event && + message[1].kind === POLL_RESPONSE && + tagValues(tagSpec("e"), message[1].tags).includes(pollId), + ) + .map(({message}) => tagValues(tagSpec("response"), message[1].tags)) + +const PARTY = "🎉" + +// The picker is a web component with an open shadow root, so its search field and its results are +// reachable through it. Searching rather than browsing avoids depending on which category tab an +// emoji happens to live under. +const pickParty = async (page: Page, opener: Locator) => { + await opener.click() + + const picker = page.locator("emoji-picker").filter({visible: true}) + + // Tippy keeps a hidden popover mounted through its fade, so wait for there to be exactly one + // picker rather than reaching into whichever resolves first. + await expect(picker).toHaveCount(1) + + // A result's label is the emoji's name, its annotation and every shortcode joined together, so + // more than one result can carry the annotation being matched. + await picker.locator("input.search").fill("party popper") + await picker + .getByRole("option", {name: /party popper/}) + .first() + .click() +} + +// A one pixel gif. Gif goes to the uploader untouched by the compressor, so this is uploaded byte +// for byte and the blossom mock can hash it. +const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64") + +test("US-046 create and browse a calendar event", async ({seed, as}) => { + 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.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + + const addEvent = (title: string, start: number) => + space.event( + user.bob, + () => + space + .kind(TimeEvent) + .writer() + .setIdentifier(title.toLowerCase().replace(/\W+/g, "-")) + .setTitle(title) + .setStart(start) + .setEnd(start + int(1, HOUR)) + .setContent(`${title} happens here.`) + .renderTemplate(), + at(1, HOUR), + ) + + // Ten events that have already happened, so the list is taller than the viewport and "opens + // scrolled to the next upcoming event" is a statement about where it sits rather than about a + // list that fits on one screen anyway. + for (let i = 1; i <= 10; i++) { + addEvent(`Past Meetup ${String(i).padStart(2, "0")}`, at((11 - i) * 4, DAY)) + } + + // at() counts backwards from the moment the test started, so a negative count is the future. + addEvent("Autumn Fair", at(-3, DAY)) + addEvent("Winter Solstice", at(-10, DAY)) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, `${spacePath(url)}/calendar`) + + const cards = page.getByRole("link").filter({hasText: "Posted by"}) + const card = (title: string) => page.getByRole("link").filter({hasText: title}) + + await expect(cards).toHaveCount(12) + + // The calendar opens on the first event that hasn't happened yet, with the oldest one scrolled + // out of the way above it. + await expect(card("Autumn Fair")).toBeInViewport() + await expect(card("Past Meetup 01")).not.toBeInViewport() + + await page.getByRole("button", {name: "Create", exact: true}).click() + + const composer = dialog(page, "Create an Event") + + // Field renders its label and its input as siblings rather than wiring them together, so the + // form's two writable text fields are taken in document order: title, then location. The date + // range's own input is readonly, which is what leaves those two. + const textInputs = composer.locator('input[type="text"]:not([readonly])') + const title = textInputs.first() + const location = textInputs.last() + const dates = composer.getByPlaceholder("Select dates") + + await expect(composer).toBeVisible() + + await composer.getByRole("button", {name: "Save Event"}).click() + await expect(page.getByRole("alert")).toContainText("Please provide a title.") + + await title.fill("Winter Market") + await location.fill("Town Hall") + await composer.getByRole("button", {name: "Save Event"}).click() + await expect(page.getByRole("alert")).toContainText("Please provide start and end times.") + + // The picker takes two clicks for a range: the first is the start, the second the end, each at + // the time its own time input carries — noon and one, for a range that starts empty. + const target = new Date() + + target.setMonth(target.getMonth() + 1, 15) + target.setHours(12, 0, 0, 0) + + await dates.click() + await composer.getByRole("button", {name: "Next month"}).click() + await composer.getByRole("button", {name: "15", exact: true}).click() + await composer.getByRole("button", {name: "15", exact: true}).click() + + await expect(dates).not.toHaveValue("") + + await composer.getByRole("button", {name: "Save Event"}).click() + + await expect(page.getByRole("alert")).toContainText("Your event has been saved!") + await expect(page.getByRole("heading", {name: "Create an Event"})).toHaveCount(0) + + const market = card("Winter Market") + + await expect(market).toBeVisible() + await expect(market).toContainText(longDate(Math.floor(target.getTime() / 1000))) +}) + +test("US-047 manage your own calendar event", async ({seed, as}) => { + 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.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + + const addEvent = (author: TestUser, d: string, name: string, place: string) => + space.event( + author, + () => + space + .kind(TimeEvent) + .writer() + .setIdentifier(d) + .setTitle(name) + .setLocation(place) + .setStart(at(-2, DAY)) + .setEnd(at(-2, DAY) + int(2, HOUR)) + .setContent("Bring a dish to share.") + .renderTemplate(), + at(1, HOUR), + ) + + addEvent(user.alice, "harvest-supper", "Harvest Supper", "The Old Mill") + addEvent(user.bob, "quiz-night", "Quiz Night", "The Anchor") + }) + + const {at} = scenario + const {url} = scenario.space("space") + const calendarPath = `${spacePath(url)}/calendar` + const page = await as(users.alice, calendarPath) + + await openCard(page.getByRole("link").filter({hasText: "Harvest Supper"}), "Harvest Supper") + + // The event has no replies, so the page carries exactly one feature card. + const eventCard = page.locator(".card.z-feature") + + await expect(page.getByRole("heading", {name: "Harvest Supper", exact: true})).toBeVisible() + await expect(eventCard).toContainText(longDate(at(-2, DAY))) + await expect(eventCard).toContainText("The Old Mill") + await expect(eventCard).toContainText("Alice Anderson") + + await menuOf(eventCard).click() + await page.getByRole("button", {name: "Edit Event"}).click() + + const composer = dialog(page, "Edit this Event") + const textInputs = composer.locator('input[type="text"]:not([readonly])') + + await expect(textInputs.first()).toHaveValue("Harvest Supper") + await expect(textInputs.last()).toHaveValue("The Old Mill") + await expect(composer.getByPlaceholder("Select dates")).not.toHaveValue("") + + await textInputs.first().fill("Harvest Supper & Ceilidh") + await textInputs.last().fill("The Village Hall") + await composer.getByRole("button", {name: "Save Event"}).click() + + await expect(page.getByRole("heading", {name: "Edit this Event"})).toHaveCount(0) + await expect(page.getByRole("heading", {name: "Harvest Supper & Ceilidh"})).toBeVisible() + await expect(eventCard).toContainText("The Village Hall") + + await menuOf(eventCard).click() + await page.getByRole("button", {name: "Delete Event"}).click() + await page.getByRole("button", {name: "Confirm"}).click() + + // The detail page keeps a deleted event on screen and marks it, so this is what says the + // retraction landed before the calendar is asked for the list again. + await expect(eventCard.getByText("Deleted", {exact: true})).toBeVisible() + + await page.goto(calendarPath) + + await expect(page.getByRole("link").filter({hasText: "Quiz Night"})).toBeVisible() + await expect(page.getByRole("link").filter({hasText: "Harvest Supper"})).toHaveCount(0) +}) + +test("US-048 create a poll and vote on it", async ({seed, as}) => { + let snacks!: Seeded + let drinks!: Seeded + + 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.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + + // The multiple choice half of the story is about voting rather than about creating, and a + // wire assertion needs option ids known up front, so these two are fixtures. + snacks = space.event( + user.alice, + () => + space + .kind(Poll) + .writer() + .setTitle("Which snacks should we order?") + .setPollType("multiplechoice") + .addOption("Chips", "snack-chips") + .addOption("Salsa", "snack-salsa") + .addOption("Olives", "snack-olives") + .setUrls([space.url]) + .renderTemplate(), + at(2, HOUR), + ) + + drinks = space.event( + user.alice, + () => + space + .kind(Poll) + .writer() + .setTitle("Which drinks should we order?") + .setPollType("multiplechoice") + .addOption("Coffee", "drink-coffee") + .addOption("Tea", "drink-tea") + .setUrls([space.url]) + .renderTemplate(), + at(2, HOUR), + ) + }) + + const {url} = scenario.space("space") + const alice = await as(users.alice, `${spacePath(url)}/polls`) + + await alice.getByRole("button", {name: "Create", exact: true}).click() + + const composer = dialog(alice, "Create a Poll") + const question = composer.getByPlaceholder("What would you like to ask?") + + await composer.getByRole("button", {name: "Create Poll"}).click() + await expect(alice.getByRole("alert")).toContainText("Please provide a title for your poll.") + + await question.fill("Where should we meet?") + await composer.getByPlaceholder("Option 2").fill("") + await composer.getByRole("button", {name: "Create Poll"}).click() + await expect(alice.getByRole("alert")).toContainText("Please provide at least two options.") + + await composer.getByPlaceholder("Option 1").fill("The park") + await composer.getByPlaceholder("Option 2").fill("The pub") + await composer.getByRole("button", {name: "Add option"}).click() + await composer.getByPlaceholder("Option 3").fill("The pier") + + // Dragging the third option onto the second puts it there and pushes the pub down. + const rows = composer.getByRole("listitem") + + await rows.nth(2).dragTo(rows.nth(1)) + + await expect(composer.getByPlaceholder("Option 1")).toHaveValue("The park") + await expect(composer.getByPlaceholder("Option 2")).toHaveValue("The pier") + await expect(composer.getByPlaceholder("Option 3")).toHaveValue("The pub") + + await composer.getByRole("button", {name: "Create Poll"}).click() + + await expect(alice.getByRole("heading", {name: "Create a Poll"})).toHaveCount(0) + + const meetup = alice.getByRole("link").filter({hasText: "Where should we meet?"}) + + await expect(meetup).toBeVisible() + + await openCard(meetup, "Where should we meet?") + + await expect(alice.getByRole("heading", {name: "Where should we meet?"})).toBeVisible() + await expect(alice.locator(".card-sm")).toContainText(["The park", "The pier", "The pub"]) + + await pollOption(alice, "The park").getByRole("radio").check() + + await expect(pollOption(alice, "The park")).toContainText("1 vote") + await expect(pollOption(alice, "The park").locator("progress")).toHaveJSProperty("value", 1) + + const pollPath = new URL(alice.url()).pathname + const bob = await as(users.bob, pollPath) + + // Alice's vote reached bob over the wire, and his comes back to her without a reload. + await expect(pollOption(bob, "The park")).toContainText("1 vote") + + await pollOption(bob, "The pub").getByRole("radio").check() + + await expect(pollOption(alice, "The pub")).toContainText("1 vote") + await expect(alice.getByText("2 votes")).toBeVisible() + + // Two boxes checked inside the delay window go out as one vote for both. + await alice.goto(`${spacePath(url)}/polls/${snacks.id}`) + + await pollOption(alice, "Chips").getByRole("checkbox").check() + await pollOption(alice, "Salsa").getByRole("checkbox").check() + + await expect(pollOption(alice, "Chips")).toContainText("1 vote") + await expect(pollOption(alice, "Salsa")).toContainText("1 vote") + await expect(pollOption(alice, "Olives")).toContainText("0 votes") + await expect.poll(() => pollResponses(alice, snacks.id)).toEqual([["snack-chips", "snack-salsa"]]) + + // Unchecking one before the delay elapses keeps it out of the vote that is finally sent. + await alice.goto(`${spacePath(url)}/polls/${drinks.id}`) + + await pollOption(alice, "Coffee").getByRole("checkbox").check() + await pollOption(alice, "Tea").getByRole("checkbox").check() + await pollOption(alice, "Tea").getByRole("checkbox").uncheck() + + await expect(pollOption(alice, "Coffee")).toContainText("1 vote") + await expect(pollOption(alice, "Tea")).toContainText("0 votes") + await expect.poll(() => pollResponses(alice, drinks.id)).toEqual([["drink-coffee"]]) +}) + +test("US-049 a closed poll shows final results only", async ({seed, as}) => { + let closed!: Seeded + + 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.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + space.profile(user.carol, {name: "Carol Chen"}) + + const poll = space.event( + user.alice, + () => + space + .kind(Poll) + .writer() + .setTitle("What colour should the shed be?") + .setPollType("singlechoice") + .addOption("Blue", "shed-blue") + .addOption("Green", "shed-green") + .setEndsAt(at(3, HOUR)) + .setUrls([space.url]) + .renderTemplate(), + at(5, HOUR), + ) + + for (const voter of [user.bob, user.carol]) { + space.event( + voter, + () => + space + .kind(PollResponse) + .writer() + .setPollId(poll.id) + .addSelection("shed-blue") + .renderTemplate(), + at(4, HOUR), + ) + } + + closed = poll + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, `${spacePath(url)}/polls/${closed.id}`) + + const blue = pollOption(page, "Blue") + const green = pollOption(page, "Green") + + await expect(page.getByRole("heading", {name: "What colour should the shed be?"})).toBeVisible() + + // Voting is over, and the poll says so in place of the controls that would have taken a vote. + await expect(page.getByText(/Ended 3 hours ago/)).toBeVisible() + await expect(page.locator("input[type=radio]")).toHaveCount(0) + await expect(page.locator("input[type=checkbox]")).toHaveCount(0) + + // The votes already cast still count and still draw. + await expect(blue).toContainText("2 votes") + await expect(green).toContainText("0 votes") + await expect(blue.locator("progress")).toHaveJSProperty("value", 2) + await expect(blue.locator("progress")).toHaveJSProperty("max", 2) + await expect(green.locator("progress")).toHaveJSProperty("value", 0) +}) + +test("US-050 create a funding goal and track its progress", async ({seed, as}) => { + // The lightning address on bob's profile, and the lnurl endpoint the client resolves it to. A + // receipt is only counted once that endpoint answers with the zapper that signed it. + const lud16 = "bob@zap.test" + const lnurl = getLnUrl(lud16)! + // A zap receipt is signed by the recipient's lightning provider rather than by either party to + // the zap, so the provider is an identity of its own — and one zooid will take a write from. + const provider = makeTestUser("zapper") + + let running!: Seeded + let soundproofing!: Seeded + + 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.member(provider) + space.profile(user.alice, {name: "Alice Anderson"}) + space.event(user.bob, () => + space.kind(Profile).writer().update({name: "Bob Barker", lud16}).renderTemplate(), + ) + + // Two and a half days old, so "how long it has been running" rounds to three whatever second + // of the run this renders on. + running = space.event( + user.bob, + () => + space + .kind(ZapGoal) + .writer() + .setTitle("New PA system") + .setSummary("The old one hums through every song.") + .setAmount(50000) + .setUrls([space.url]) + .renderTemplate(), + at(60, HOUR), + ) + + soundproofing = space.event( + user.bob, + () => + space + .kind(ZapGoal) + .writer() + .setTitle("Soundproofing") + .setSummary("The neighbours have opinions about band practice.") + .setAmount(20000) + .setUrls([space.url]) + .renderTemplate(), + at(30, HOUR), + ) + + // What a wallet publishes once an invoice is paid: the payer's own zap request carried as the + // receipt's description, signed by the provider the recipient's lnurl names. A receipt whose + // invoice disagrees with the amount its request asked for is thrown away by the client, so the + // two are rendered from the same number. + const contribute = (from: TestUser, sats: number, createdAt: number) => + space.event( + provider, + async () => { + const request = await from.signer.sign({ + ...(await space + .kind(ZapRequest) + .writer() + .setAmount(toMsats(sats)) + .setLnurl(lnurl) + .setRecipient(user.bob.pubkey) + .setEventId(soundproofing.id) + .setUrls([space.url]) + .renderTemplate()), + created_at: createdAt, + }) + + // What was paid is read straight out of the invoice's human-readable part, where an `n` + // is a tenth of a sat, so that prefix is all of a bolt11 that has to be real. + return space + .kind(ZapReceipt) + .writer() + .setBolt11(`lnbc${sats * 10}n1p${from.name}`) + .setDescription(JSON.stringify(request)) + .setRecipient(user.bob.pubkey) + .setEventId(soundproofing.id) + .renderTemplate() + }, + createdAt, + ) + + contribute(user.alice, 1000, at(20, HOUR)) + contribute(user.carol, 500, at(10, HOUR)) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, `${spacePath(url)}/goals`) + + await page.getByRole("button", {name: "Create", exact: true}).click() + + const composer = dialog(page, "Create a Funding Goal") + const title = composer.getByPlaceholder("What do funds go towards?") + const amount = composer.locator('input[type="number"]') + const slider = composer.locator('input[type="range"]') + + await expect(amount).toHaveValue("1000") + await expect(slider).toHaveValue("1000") + + await composer.getByRole("button", {name: "Create Goal"}).click() + await expect(page.getByRole("alert")).toContainText( + "Please provide a title for your funding goal.", + ) + + await title.fill("Repair the roof") + await composer.getByRole("button", {name: "Create Goal"}).click() + await expect(page.getByRole("alert")).toContainText( + "Please provide details about your funding goal.", + ) + + await editorOf(composer).pressSequentially("Three tiles came off in the storm.") + + // The target answers to the field and to the slider alike. + await amount.fill("5000") + await expect(slider).toHaveValue("5000") + + await slider.press("ArrowRight") + await expect(amount).toHaveValue("6000") + + await composer.getByRole("button", {name: "Create Goal"}).click() + + await expect(page.getByRole("heading", {name: "Create a Funding Goal"})).toHaveCount(0) + + const roof = page.getByRole("link").filter({hasText: "Repair the roof"}) + + await expect(roof).toBeVisible() + + await openCard(roof, "Repair the roof") + + await expect(page.getByRole("heading", {name: "Repair the roof"})).toBeVisible() + + const goalCard = page.locator(".card.z-feature").filter({hasText: "funded of 6000 sats"}) + + // Exactly, since "funded of 6000 sats" carries "0 sats" inside it. + await expect(goalCard.getByText("0 sats", {exact: true})).toBeVisible() + await expect(goalCard.locator("progress")).toHaveJSProperty("value", 0) + await expect(goalCard.locator("progress")).toHaveJSProperty("max", 6000) + + await page.goto(`${spacePath(url)}/goals/${running.id}`) + + const paCard = page.locator(".card.z-feature").filter({hasText: "funded of 50000 sats"}) + + await expect(paCard.getByText("contributors", {exact: true}).locator("xpath=..")).toContainText( + "0", + ) + await expect(paCard.getByText("days old", {exact: true}).locator("xpath=..")).toContainText("3") + + // Registered after the page was opened, so it answers ahead of the empty dufflepud `as()` + // installs, and before the navigation below, since a zapper is looked up once per page load. + await mockDufflepud(page.context(), { + zappers: [ + { + lnurl: bech32ToHex(lnurl), + info: {pubkey: users.bob.pubkey, nostrPubkey: provider.pubkey, allowsNostr: true}, + }, + ], + }) + + await page.goto(`${spacePath(url)}/goals/${soundproofing.id}`) + + const soundCard = page.locator(".card.z-feature").filter({hasText: "funded of 20000 sats"}) + + await expect(soundCard.getByText("1500 sats", {exact: true})).toBeVisible() + await expect(soundCard.locator("progress")).toHaveJSProperty("value", 1500) + await expect( + soundCard.getByText("contributors", {exact: true}).locator("xpath=.."), + ).toContainText("2") + await expect(soundCard.getByText("days old", {exact: true}).locator("xpath=..")).toContainText( + "2", + ) +}) + +test("US-051 post, edit, and close out a classified listing", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.profile(user.alice, {name: "Alice Anderson"}) + }) + + const {url} = scenario.space("space") + const classifiedsPath = `${spacePath(url)}/classifieds` + const page = await as(users.alice, classifiedsPath) + + await mockBlossom(page.context(), {server: url.replace(/^wss:/, "https:")}) + + await page.getByRole("button", {name: "Create", exact: true}).click() + + const composer = dialog(page, "Create a Classified Listing") + const title = composer.getByPlaceholder("What is this listing for?") + const price = composer.locator('input[type="number"]') + const currency = composer.locator("button.input") + const topics = composer.getByPlaceholder("Add topics...") + + await expect(price).toHaveValue("0") + await expect(currency).toHaveText("SAT (Satoshi)") + + await composer.getByRole("button", {name: "Save Listing"}).click() + await expect(page.getByRole("alert")).toContainText("Please provide a title for your listing.") + + await title.fill("Vintage Road Bike") + await composer.getByRole("button", {name: "Save Listing"}).click() + await expect(page.getByRole("alert")).toContainText( + "Please provide a description for your listing.", + ) + + await editorOf(composer).pressSequentially("Steel frame, new tires, barely ridden.") + + await currency.click() + await currency.locator("input").fill("USD") + await page.getByRole("button", {name: "USD", exact: true}).click() + await expect(currency).toHaveText("USD (United States Dollar)") + + await price.fill("1200") + + await topics.fill("bicycles") + await topics.press("Enter") + + await expect(composer.getByText("#bicycles")).toBeVisible() + + await composer + .locator('input[type="file"]') + .setInputFiles({name: "bike.gif", mimeType: "image/gif", buffer: GIF}) + + await expect(composer.getByAltText("Upload preview")).toBeVisible() + + await composer.getByRole("button", {name: "Save Listing"}).click() + + await expect(page.getByRole("heading", {name: "Create a Classified Listing"})).toHaveCount(0) + + const listing = page.getByRole("link").filter({hasText: "Vintage Road Bike"}) + + await expect(listing).toBeVisible() + await expect(listing).toContainText("1200") + await expect(listing).toContainText("#bicycles") + await expect(listing.locator('img[src^="https://space.test/"]')).toBeVisible() + + await openCard(listing, "Vintage Road Bike") + + const detail = page.locator(".card.z-feature").filter({hasText: "Vintage Road Bike"}) + + await expect(detail).toContainText("1200") + + await menuOf(detail).click() + await page.getByRole("button", {name: "Edit Listing"}).click() + + const editor = dialog(page, "Edit this Listing") + + await expect(editor.getByPlaceholder("What is this listing for?")).toHaveValue( + "Vintage Road Bike", + ) + await expect(editor.locator('input[type="number"]')).toHaveValue("1200") + await expect(editor.locator("button.input")).toHaveText("USD (United States Dollar)") + await expect(editor.getByText("#bicycles")).toBeVisible() + await expect(editor.getByRole("combobox")).toHaveValue("active") + + await editor.getByRole("combobox").selectOption("sold") + await editor.getByRole("button", {name: "Save Listing"}).click() + + await expect(page.getByRole("heading", {name: "Edit this Listing"})).toHaveCount(0) + await expect(detail.getByText("Sold", {exact: true})).toBeVisible() + + await page.goto(classifiedsPath) + + await expect(listing.getByText("Sold", {exact: true})).toBeVisible() +}) + +test("US-052 comment on and react to community posts", async ({seed, as}) => { + 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.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anderson"}) + space.profile(user.bob, {name: "Bob Barker"}) + space.profile(user.carol, {name: "Carol Chen"}) + + const fair = space.event( + user.alice, + () => + space + .kind(TimeEvent) + .writer() + .setIdentifier("autumn-fair") + .setTitle("Autumn Fair") + .setLocation("The Green") + .setStart(at(-4, DAY)) + .setEnd(at(-4, DAY) + int(4, HOUR)) + .setContent("Stalls, cider and a tug of war.") + .renderTemplate(), + at(3, HOUR), + ) + + // Three comments already there, so bob's fills the four the page shows without asking and + // alice's tips it past them. + for (let i = 1; i <= 3; i++) { + space.event( + user.carol, + () => + space + .kind(Comment) + .writer() + .setRootFromEvent(fair.event) + .setParentFromEvent(fair.event) + .setContent(`Question ${String(i).padStart(2, "0")} for the organisers`) + .renderTemplate(), + at(3, HOUR) + i * 60, + ) + } + }) + + const {url} = scenario.space("space") + const calendarPath = `${spacePath(url)}/calendar` + const bob = await as(users.bob, calendarPath) + + await openCard(bob.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair") + + const eventCard = bob.locator(".card.z-feature").filter({hasText: "Stalls, cider"}) + + await expect(eventCard).toBeVisible() + + await pickParty(bob, emojiOf(eventCard)) + + const bobsPill = eventCard.getByRole("button", {name: PARTY}) + + await expect(bobsPill).toHaveCount(1) + await expect(bobsPill).toHaveClass(/button-primary/) + await expect(bobsPill).toHaveAttribute("data-tip", "Bob Barker reacted") + + await bob.getByRole("button", {name: "Leave comment"}).click() + + const reply = bob.locator("form").filter({has: bob.locator(".note-editor")}) + + await editorOf(reply).pressSequentially("Is there parking at The Green?") + await reply.getByRole("button", {name: "Post Reply"}).click() + + await expect(bob.getByText("Is there parking at The Green?")).toBeVisible() + + const alice = await as(users.alice, calendarPath) + + await openCard(alice.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair") + + const alicesView = alice.locator(".card.z-feature").filter({hasText: "Stalls, cider"}) + + await expect(alicesView.getByRole("button", {name: PARTY})).toHaveAttribute( + "data-tip", + "Bob Barker reacted", + ) + await expect(alice.getByText("Is there parking at The Green?")).toBeVisible() + + await alice.getByRole("button", {name: "Leave comment"}).click() + + const alicesReply = alice.locator("form").filter({has: alice.locator(".note-editor")}) + + await editorOf(alicesReply).pressSequentially("Yes, in the field behind the pub.") + await alicesReply.getByRole("button", {name: "Post Reply"}).click() + + // Past four replies the rest are folded away behind a control that names how many there are. + const showAll = alice.getByRole("button", {name: "Show all 5 replies"}) + + await expect(showAll).toBeVisible() + await expect(alice.getByText("Yes, in the field behind the pub.")).toHaveCount(0) + + await showAll.click() + + await expect(alice.getByText("Question 01 for the organisers")).toBeVisible() + await expect(alice.getByText("Is there parking at The Green?")).toBeVisible() + await expect(alice.getByText("Yes, in the field behind the pub.")).toBeVisible() +}) + +test("US-053 browse and search the library", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.alice, "general") + space.profile(user.admin, {name: "Ada Admin"}) + space.profile(user.alice, {name: "Alice Anderson"}) + space.message(user.admin, "general", "welcome to the space", at(2, HOUR)) + }) + + const {url} = scenario.space("space") + const libraryPath = `${spacePath(url)}/library` + + // A shelf is signed by the relay itself, so nothing this process holds a key for can seed one + // and the only way to stand one up is through the admin's own ui. + const admin = await as(users.admin, libraryPath) + + const createShelf = async (title: string, description: string) => { + await admin.getByRole("button", {name: "Create Shelf"}).click() + + const form = panel(admin, "Create Shelf") + + await form.getByPlaceholder("Shelf title").fill(title) + await form.getByPlaceholder("What's this shelf about?").fill(description) + await form.getByRole("button", {name: "Save changes"}).click() + await expect(admin.getByRole("alert")).toContainText("Shelf created!") + } + + await createShelf("Getting Started", "Reading for new members") + await createShelf("Recipes", "Food and drink from the kitchen") + + await expect(admin.getByRole("button", {name: /Getting Started/})).toBeVisible() + await expect(admin.getByRole("button", {name: /Recipes/})).toBeVisible() + + await shelfCard(admin, "Getting Started").getByRole("button", {name: "More options"}).click() + await admin.getByRole("button", {name: "Add link", exact: true}).click() + + const linkForm = panel(admin, "Add Link") + + await linkForm.getByPlaceholder("URL or nevent...").fill("https://handbook.test/start-here") + await linkForm.getByPlaceholder("Optional title").fill("The Handbook") + await linkForm.getByRole("button", {name: "Add link"}).click() + + await expect(admin.getByRole("alert")).toContainText("Link added!") + + const alice = await as(users.alice, libraryPath) + const term = alice.getByPlaceholder("Search library...") + + await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible() + await expect(alice.getByRole("button", {name: /Recipes/})).toBeVisible() + + // A term matches a shelf on its title... + await term.fill("recipes") + + await expect(alice.getByRole("button", {name: /Recipes/})).toBeVisible() + await expect(alice.getByRole("button", {name: /Getting Started/})).toHaveCount(0) + + // ...or on its description. + await term.fill("new members") + + await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible() + await expect(alice.getByRole("button", {name: /Recipes/})).toHaveCount(0) + + await term.fill("") + + await alice.getByRole("button", {name: /Getting Started/}).click() + + await expect(alice.getByText("The Handbook")).toBeVisible() + + await alice.getByRole("button", {name: /Recipes/}).click() + + await expect(alice.getByText("This shelf doesn't have any links yet.")).toBeVisible() + + // Curating is the admin's, so none of it is offered to an ordinary member — not even on the + // empty shelf where the admin is offered it directly. + await expect(alice.getByRole("button", {name: "Create Shelf"})).toHaveCount(0) + await expect(alice.getByRole("button", {name: "Add a link"})).toHaveCount(0) +}) + +test("US-054 curate the library", async ({seed, as}) => { + let poll!: Seeded + let message!: Seeded + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.bob, "general") + space.profile(user.admin, {name: "Ada Admin"}) + space.profile(user.bob, {name: "Bob Barker"}) + + message = space.message(user.bob, "general", "the deploy broke again", at(30, MINUTE)) + + poll = space.event( + user.bob, + () => + space + .kind(Poll) + .writer() + .setTitle("Should we move standup to 10?") + .setPollType("singlechoice") + .addOption("Yes", "standup-yes") + .addOption("No", "standup-no") + .setUrls([space.url]) + .renderTemplate(), + at(2, HOUR), + ) + }) + + const {url} = scenario.space("space") + const libraryPath = `${spacePath(url)}/library` + const pollPath = `${spacePath(url)}/polls/${poll.id}` + const page = await as(users.admin, pollPath) + + const pollCard = page + .locator(".card.z-feature") + .filter({hasText: "Should we move standup to 10?"}) + + // With nothing on the shelves yet, the dialog says so and points at where one is made. + await menuOf(pollCard).click() + await page.getByRole("button", {name: "Add to Library"}).click() + + await expect(page.getByText("This space doesn't have any shelves yet.")).toBeVisible() + await expect(page.getByRole("link", {name: "Go to Library"})).toBeVisible() + + await page.goto(libraryPath) + + await page.getByRole("button", {name: "Create Shelf"}).click() + + const shelfForm = panel(page, "Create Shelf") + + await shelfForm.getByPlaceholder("Shelf title").fill("Reading List") + await shelfForm.getByPlaceholder("What's this shelf about?").fill("Things worth reading") + await shelfForm.getByRole("button", {name: "Save changes"}).click() + + // Creating a shelf lists it and drops the admin straight into it. + await expect(page.getByRole("alert")).toContainText("Shelf created!") + await expect(page).toHaveURL(/[?&]board=/) + await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute( + "aria-pressed", + "true", + ) + await expect(page.getByText("This shelf doesn't have any links yet.")).toBeVisible() + + // An external url becomes a card of its own... + await page.getByRole("button", {name: "Add a link"}).click() + + const externalLink = panel(page, "Add Link") + + await externalLink.getByPlaceholder("URL or nevent...").fill("https://handbook.test/style-guide") + await externalLink.getByPlaceholder("Optional title").fill("The Style Guide") + await externalLink.getByRole("button", {name: "Add link"}).click() + + await expect(page.getByRole("alert")).toContainText("Link added!") + + const styleGuide = page.locator(".card").filter({hasText: "The Style Guide"}) + + await expect(styleGuide).toBeVisible() + await expect(styleGuide.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible() + + // ...and a nostr link becomes the note it points at. + const nevent = nip19.neventEncode({id: message.id, kind: MESSAGE, relays: [url]}) + + await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click() + await page.getByRole("button", {name: "Add link", exact: true}).click() + + const nostrLink = panel(page, "Add Link") + + await nostrLink.getByPlaceholder("URL or nevent...").fill(`nostr:${nevent}`) + await nostrLink.getByPlaceholder("Optional title").fill("Yesterday's incident") + await nostrLink.getByRole("button", {name: "Add link"}).click() + + const incident = page.locator(".card").filter({hasText: "Yesterday's incident"}) + + await expect(incident).toContainText("the deploy broke again") + await expect(incident).toContainText("Bob Barker") + + // Editing the shelf from its menu updates the list. + await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click() + await page.getByRole("button", {name: "Edit shelf"}).click() + + const shelfEdit = panel(page, "Edit Shelf") + + await shelfEdit.getByPlaceholder("What's this shelf about?").fill("Things worth reading twice") + await shelfEdit.getByRole("button", {name: "Save changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Shelf updated!") + await expect(page.getByRole("button", {name: /Reading List/})).toContainText( + "Things worth reading twice", + ) + + // A post filed from its own menu lands on the shelf that was picked for it. + await page.goto(pollPath) + await menuOf(pollCard).click() + await page.getByRole("button", {name: "Add to Library"}).click() + await page.getByRole("button", {name: /Reading List/}).click() + + const fromPoll = panel(page, "Add Link") + + await expect(fromPoll.getByPlaceholder("URL or nevent...")).not.toHaveValue("") + + await fromPoll.getByPlaceholder("Optional title").fill("Standup poll") + await fromPoll.getByRole("button", {name: "Add link"}).click() + + await expect(page.getByRole("alert")).toContainText("Link added!") + + await page.goto(libraryPath) + await page.getByRole("button", {name: /Reading List/}).click() + + await expect(page.getByText("Standup poll")).toBeVisible() + await expect(page.getByText("Should we move standup to 10?")).toBeVisible() + + // Deleting the shelf from its menu takes it off the list. + await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click() + await page.getByRole("button", {name: "Delete shelf"}).click() + await page.getByRole("button", {name: "Confirm"}).click() + + await expect(page.getByRole("alert")).toContainText("Shelf deleted!") + await expect(page.getByRole("button", {name: /Reading List/})).toHaveCount(0) + await expect(page.getByText("No shelves found.")).toBeVisible() +}) + +test("US-055 create community content from a room", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("lounge", {name: "Lounge"}) + space.join(user.alice, "lounge") + space.profile(user.alice, {name: "Alice Anderson"}) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "lounge")) + + const compose = page.locator("form").filter({has: page.locator(".chat-editor")}) + + await compose.locator(".join").getByRole("button").nth(1).click() + + // Everything the room can be given from the composer, named as the menu names it. + for (const name of [ + "Funding Goal", + "Calendar Event", + "Classified Listing", + "Create Thread", + "Write an Article", + "Ask a Question", + ]) { + await expect(page.getByRole("button", {name, exact: true})).toBeVisible() + } + + await page.getByRole("button", {name: "Ask a Question", exact: true}).click() + + const composer = dialog(page, "Create a Poll") + + await composer.getByPlaceholder("What would you like to ask?").fill("Pizza or tacos?") + await composer.getByPlaceholder("Option 1").fill("Pizza") + await composer.getByPlaceholder("Option 2").fill("Tacos") + await composer.getByRole("button", {name: "Create Poll"}).click() + + await expect(page.getByRole("heading", {name: "Create a Poll"})).toHaveCount(0) + + // The room gets a quote of the poll rather than a second post written by hand. A room item is + // itself a role=button tap target wrapping its content, so the quote is the inner of the two. + const quote = page.getByRole("button").filter({hasText: "Pizza or tacos?"}).last() + + await expect(quote).toBeVisible() + + await quote.click() + + await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/polls/[0-9a-f]{64}$`)) + await expect(page.getByRole("heading", {name: "Pizza or tacos?"})).toBeVisible() + await expect(pollOption(page, "Pizza")).toBeVisible() + await expect(pollOption(page, "Tacos")).toBeVisible() +}) diff --git a/e2e/specs/composer.spec.ts b/e2e/specs/composer.spec.ts new file mode 100644 index 00000000..30c00669 --- /dev/null +++ b/e2e/specs/composer.spec.ts @@ -0,0 +1,496 @@ +import {createHash} from "node:crypto" +import type {BrowserContext, Locator, Page} from "@playwright/test" +import {HOUR, now} from "@welshman/lib" +import {MessagingRelayList, RelayList} from "@welshman/domain" +import {expect, makeTestUser, roomPath, test, users} from "../harness" +import type {SeededSpace, TestUser} from "../harness" + +// The path the app builds for a conversation — see makeChatId in src/app/chats.ts. +const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}` + +// Where an upload lands: the space's own origin is probed first and blossom is off in every +// tenant's toml, so what is left is VITE_DEFAULT_BLOSSOM_SERVERS. +const BLOSSOM_ORIGIN = "https://blossom.primal.net" + +// A one pixel gif. Gif rather than png because compressFileForUpload passes it through untouched +// rather than re-encoding it through a canvas, so the mock stores the bytes the browser was handed. +const GIF = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + +const gifFile = (name: string) => ({ + name, + mimeType: "image/gif", + buffer: Buffer.from(GIF, "base64"), +}) + +// A blossom server whose blobs are shared by every context in a test, and whose next upload can be +// held open. The harness's own `mockBlossom` keeps blobs per browser context, which a conversation's +// image cannot use: it is uploaded encrypted and decrypted by the recipient, so the bytes alice puts +// in have to be the bytes bob reads back. Holding the upload makes the in-flight state a fact rather +// than a race against a mock that answers in a microtask. +const serveBlossom = () => { + const blobs = new Map() + + let held = Promise.resolve() + let open = () => {} + + return { + hold: () => { + held = new Promise(resolve => { + open = resolve + }) + }, + release: () => open(), + install: (context: BrowserContext) => + context.route(`${BLOSSOM_ORIGIN}/**`, async route => { + const request = route.request() + const method = request.method() + const {pathname} = new URL(request.url()) + + if (pathname === "/upload" && method === "PUT") { + await held + + 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}) + + const url = `${BLOSSOM_ORIGIN}/${sha256}` + + return route.fulfill({json: {sha256, type, url, 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() + }), + } +} + +// Outbox routing resolves everything about a person through their relay list, so a seeded profile +// is only loadable by somebody else once its author has one. +const seedRelayList = (space: SeededSpace, user: TestUser) => + space.event(user, () => + space + .kind(RelayList) + .writer() + .setReadUrls([space.url]) + .setWriteUrls([space.url]) + .renderTemplate(), + ) + +// The kind-10050 a conversation needs at both ends: the recipient's is what enables the composer, +// the sender's is what routes their own copy of the wrap back to them. +const seedMessagingRelays = (space: SeededSpace, user: TestUser) => + space.event(user, () => + space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(), + ) + +const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]") + +// The send button carries the shortcut it advertises, which differs by platform. +const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']") + +const suggestions = (page: Page) => page.locator(".tiptap-suggestions__item") + +const timeline = (page: Page) => page.locator(".room__content") + +// RoomCompose leads with a two button group: upload, then the create menu. +const roomUploadButton = (page: Page) => page.locator(".room__compose-inner .join-item").first() + +const chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']") + +// Both things the composer puts above itself — the message being replied to and the editing +// indicator — are the same bordered strip, each with a single button in it: its X. +const banner = (page: Page, text: string) => + page.locator(".room__compose .border-l-2").filter({hasText: text}) + +// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is +// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu. +const messageActions = (page: Page, text: string) => + page.locator(".room__item").filter({hasText: text}).locator(".room__item-actions button") + +const chooseFile = async ( + page: Page, + button: Locator, + file: {name: string; mimeType: string; buffer: Buffer}, +) => { + const chooser = page.waitForEvent("filechooser") + + await button.click() + await (await chooser).setFiles(file) +} + +// Both of these are DataTransfer dispatches rather than anything native: prosemirror reads the file +// straight off the event, and playwright's own dispatchEvent builds a plain Event, which would drop +// the dataTransfer the handler needs. +const dropImage = (editor: Locator, name: string) => + editor.evaluate( + (node, {name, data}) => { + const transfer = new DataTransfer() + const {left, top, width, height} = node.getBoundingClientRect() + + transfer.items.add( + new File([Uint8Array.from(atob(data), c => c.charCodeAt(0))], name, {type: "image/gif"}), + ) + + node.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + clientX: left + width / 2, + clientY: top + height / 2, + }), + ) + }, + {name, data: GIF}, + ) + +const pasteImage = (editor: Locator, name: string) => + editor.evaluate( + (node, {name, data}) => { + const transfer = new DataTransfer() + + transfer.items.add( + new File([Uint8Array.from(atob(data), c => c.charCodeAt(0))], name, {type: "image/gif"}), + ) + + node.dispatchEvent( + new ClipboardEvent("paste", {bubbles: true, cancelable: true, clipboardData: transfer}), + ) + }, + {name, data: GIF}, + ) + +test("US-056 autocomplete a mention or a room reference", async ({seed, as}) => { + const outsider = makeTestUser("bobbin") + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.room("random", {name: "Random"}) + space.join(user.alice, "general", "random") + space.join(user.bob, "general") + space.profile(user.bob, {name: "Bob Roberts", picture: "https://images.test/bob.png"}) + seedRelayList(space, user.bob) + space.message(user.bob, "general", "morning all", at(2, HOUR)) + + // Someone whose name matches the same term but who belongs to a different space, so the + // dropdown has a non-member to rank below this space's own members. + other.room("lounge", {name: "Lounge"}) + other.join(user.alice, "lounge") + other.join(outsider, "lounge") + other.profile(outsider, {name: "Bobbin Amaranth"}) + seedRelayList(other, outsider) + other.message(outsider, "lounge", "hello from the lounge", at(2, HOUR)) + }) + + const space = scenario.space("space") + const other = scenario.space("other") + + // Alice arrives through the other space so that the outsider's profile is in her client before + // she composes: a profile reaches her by being rendered, and his never renders in the space she + // is about to type in. + const page = await as(users.alice, roomPath(other.url, "lounge")) + + await expect(timeline(page).getByText("Bobbin Amaranth")).toBeVisible() + + // A space's nav item is labeled with the name its nip-11 document reports, and the two tenants + // report "space" and "other". + await page.locator('.primary-nav [data-tip="space"]').click() + await page.locator(".secondary-nav").getByRole("link", {name: "General"}).click() + + await expect(timeline(page).getByText("morning all")).toBeVisible() + + const editor = composer(page) + + await editor.click() + await editor.pressSequentially("@bob") + + // This space's own member first, the outsider from the other space under him. + await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"]) + + await editor.pressSequentially("bin") + + await expect(suggestions(page)).toContainText(["Bobbin Amaranth"]) + + // Back to "@bob", where both of them are on offer again. + await editor.press("Backspace") + await editor.press("Backspace") + await editor.press("Backspace") + + await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"]) + + await suggestions(page).filter({hasText: "Bob Roberts"}).click() + + const mention = editor.locator(".tiptap-object") + + await expect(mention).toHaveText("@Bob Roberts") + await expect(mention.locator("img")).toBeVisible() + + await editor.pressSequentially("are you around?") + await editor.press("Enter") + + const sent = page.locator(".room__item").filter({hasText: "are you around?"}) + const sentMention = sent.getByRole("button", {name: "@Bob Roberts"}) + + await expect(sentMention).toBeVisible() + await expect(sentMention.locator("img")).toBeVisible() + + await editor.pressSequentially("~gene") + + await expect(suggestions(page)).toContainText(["General"]) + + await suggestions(page).filter({hasText: "General"}).click() + + await expect(editor.locator(".tiptap-object")).toContainText("General") + + await editor.press("Enter") + + await expect(timeline(page).getByRole("link", {name: /#\s*General/})).toHaveAttribute( + "href", + roomPath(space.url, "general"), + ) +}) + +test("US-057 attach and send an image", async ({seed, as}) => { + 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.bob, "general", "morning all", at(2, HOUR)) + + for (const person of [user.alice, user.bob]) { + space.profile(person, {name: person.name}) + seedRelayList(space, person) + seedMessagingRelays(space, person) + } + }) + + const path = roomPath(scenario.space("space").url, "general") + const blossom = serveBlossom() + + const alice = await as(users.alice, path) + const bob = await as(users.bob, path) + + await blossom.install(alice.context()) + await blossom.install(bob.context()) + + await expect(timeline(alice).getByText("morning all")).toBeVisible() + + blossom.hold() + + await chooseFile(alice, roomUploadButton(alice), gifFile("photo.gif")) + + await expect(alice.locator(".room__compose-inner .spinner")).toBeVisible() + await expect(sendButton(alice)).toBeDisabled() + + blossom.release() + + await expect(sendButton(alice)).toBeEnabled() + await expect(composer(alice)).toContainText("photo.gif") + await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0) + + await composer(alice).press("Enter") + + await expect(timeline(alice).locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible() + await expect(timeline(bob).locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible() + + await dropImage(composer(alice), "dropped.gif") + + await expect(composer(alice)).toContainText("dropped.gif") + + await pasteImage(composer(alice), "pasted.gif") + + await expect(composer(alice)).toContainText("pasted.gif") + await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0) + + // The same thing in a conversation. Its composer stays disabled until the recipient's messaging + // relays have been read, which is what waiting on the send button waits out. + await alice.goto(chatPath(users.bob.pubkey)) + await bob.goto(chatPath(users.alice.pubkey)) + + await expect(sendButton(alice)).toBeEnabled() + + await chooseFile(alice, chatUploadButton(alice), gifFile("selfie.gif")) + + await expect(composer(alice)).toContainText("selfie.gif") + + // The file node appears the moment it is attached, which is also the moment `uploading` goes + // true — and submit returns without a word while it is. Encryption makes that window wide enough + // to press enter into, so wait the upload out rather than losing the message to it. + await expect(sendButton(alice)).toBeEnabled() + + await composer(alice).press("Enter") + + // A conversation's image is uploaded encrypted, so the recipient fetches the ciphertext and + // decrypts it into a blob url rather than pointing an at the server. + await expect(bob.locator('.chat-bubble img[src^="blob:"]')).toBeVisible() + + // Back to the room on a fresh page, so no toast the conversation raised is still standing and the + // one asserted below can only be the rejected upload's own. + await alice.goto(path) + + await expect(timeline(alice).getByText("morning all")).toBeVisible() + await expect(alice.getByRole("alert")).toHaveCount(0) + + await chooseFile(alice, roomUploadButton(alice), { + name: "notes.txt", + mimeType: "text/plain", + buffer: Buffer.from("not an image"), + }) + + await expect(alice.getByRole("alert")).toBeVisible() + await expect(composer(alice)).not.toContainText("notes.txt") +}) + +test("US-058 drafts survive navigating away", async ({seed, as}) => { + await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.room("random", {name: "Random"}) + space.join(user.alice, "general", "random") + space.join(user.bob, "general") + space.message(user.bob, "general", "morning all", at(2, HOUR)) + + // Alice's own kind-10050 is what lets the Messages nav item navigate rather than stop to ask + // her to enable chat; bob's is what enables the conversation's composer. + for (const person of [user.alice, user.bob]) { + space.profile(person, {name: person.name}) + seedRelayList(space, person) + seedMessagingRelays(space, person) + } + }) + + // Drafts live in memory, so every move here is an in-app navigation — a reload would clear them + // whether or not they were kept. + const page = await as(users.alice, chatPath(users.bob.pubkey)) + const editor = composer(page) + const rooms = page.locator(".secondary-nav") + + await expect(sendButton(page)).toBeEnabled() + + await editor.click() + await editor.pressSequentially("still thinking about this") + + await page.locator('.primary-nav [data-tip="space"]').click() + await rooms.getByRole("link", {name: "General"}).click() + + await expect(timeline(page).getByText("morning all")).toBeVisible() + await expect(editor).toHaveText("") + + await editor.click() + await editor.pressSequentially("half a thought") + + await rooms.getByRole("link", {name: "Random"}).click() + + await expect(editor).toHaveText("") + + await rooms.getByRole("link", {name: "General"}).click() + + await expect(editor).toHaveText("half a thought") + + await page.locator('.primary-nav [data-tip="Messages"]').click() + + await expect(editor).toHaveText("still thinking about this") + + await page.locator('.primary-nav [data-tip="space"]').click() + await rooms.getByRole("link", {name: "General"}).click() + + await expect(editor).toHaveText("half a thought") + + await editor.press("Enter") + + await expect(timeline(page).getByText("half a thought")).toBeVisible() + await expect(editor).toHaveText("") + + await rooms.getByRole("link", {name: "Random"}).click() + await rooms.getByRole("link", {name: "General"}).click() + + await expect(editor).toHaveText("") +}) + +test("US-059 cancel a reply or an edit in progress", async ({seed, as}) => { + 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.profile(user.bob, {name: "Bob Roberts"}) + seedRelayList(space, user.bob) + space.message(user.bob, "general", "morning all", at(2, HOUR)) + }) + + const page = await as(users.alice, roomPath(scenario.space("space").url, "general")) + const editor = composer(page) + const replyBanner = banner(page, "Replying to @Bob Roberts") + const editBanner = banner(page, "Editing message") + + await expect(timeline(page).getByText("morning all")).toBeVisible() + + // Only a message alice sent herself in the last five minutes offers an edit, so she sends one. + await editor.click() + await editor.pressSequentially("my first message") + await editor.press("Enter") + + await expect(timeline(page).getByText("my first message")).toBeVisible() + + await editor.pressSequentially("half-written thought") + + // Bob's message offers zap, emoji, reply and the menu; alice's own also offers the edit. + await expect(messageActions(page, "morning all")).toHaveCount(4) + await expect(messageActions(page, "my first message")).toHaveCount(5) + + await messageActions(page, "morning all").nth(2).click() + + await expect(replyBanner).toContainText("morning all") + + await editor.press("Escape") + + await expect(replyBanner).toHaveCount(0) + await expect(editor).toHaveText("half-written thought") + + await messageActions(page, "morning all").nth(2).click() + + await expect(replyBanner).toBeVisible() + + await replyBanner.getByRole("button").click() + + await expect(replyBanner).toHaveCount(0) + await expect(editor).toHaveText("half-written thought") + + await messageActions(page, "my first message").nth(3).click() + + await expect(editBanner).toBeVisible() + await expect(editor).toHaveText("my first message") + + await editor.press("Escape") + + await expect(editBanner).toHaveCount(0) + await expect(editor).toHaveText("half-written thought") + + await messageActions(page, "my first message").nth(3).click() + + await expect(editBanner).toBeVisible() + + await editBanner.getByRole("button").click() + + await expect(editBanner).toHaveCount(0) + await expect(editor).toHaveText("half-written thought") + + // Neither message was touched, and the draft never left the composer. + await expect(timeline(page).getByText("morning all")).toHaveCount(1) + await expect(timeline(page).getByText("my first message")).toHaveCount(1) + await expect(timeline(page).getByText("half-written thought")).toHaveCount(0) +}) diff --git a/e2e/specs/content-rendering.spec.ts b/e2e/specs/content-rendering.spec.ts new file mode 100644 index 00000000..9ea78c3e --- /dev/null +++ b/e2e/specs/content-rendering.spec.ts @@ -0,0 +1,496 @@ +import {npubEncode} from "nostr-tools/nip19" +import {HOUR, MINUTE} from "@welshman/lib" +import {MESSAGE, makeEvent} from "@welshman/util" +import {Article, Thread} from "@welshman/domain" +import {expect, roomPath, spacePath, test, users} from "../harness" + +// A handle to a seeded event, which only reads once seed() has drained its queue. +type Seeded = {readonly id: string} + +// A bolt11 invoice as @welshman/content recognizes one: only behind a `lightning:` scheme, and +// what the chip renders and copies is what follows the scheme. +const INVOICE = + "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq" + + "ypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3" + + "agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9" + +// A cashu token keeps its own scheme in the value, and needs fifty-odd payload characters after +// it before the parser will take it. +const CASHU = + "cashu:cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJpZCI6IjAwOWExZjI5MzI1M2U0MWUiLCJhbW91bnQiOjIs" + + "InNlY3JldCI6ImFhYSIsIkMiOiJiYmIifV0sIm1pbnQiOiJodHRwczovL21pbnQudGVzdCJ9XX0=" + +test("US-060 reveal a flagged sensitive message", async ({seed, as}) => { + let flagged!: Seeded + + 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", "no spoilers please", at(45, MINUTE)) + + flagged = space.event( + user.bob, + makeEvent(MESSAGE, { + content: "the butler did it", + tags: [ + ["h", "general"], + ["content-warning", "spoilers"], + ], + created_at: at(30, MINUTE), + }), + ) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "general")) + + // Scoped to bob's own message, so "in place of his text" is a claim about this message rather + // than about the room as a whole. + const message = page.locator(`[data-event="${flagged.id}"]`) + const warning = message.getByText('flagged by the author as "spoilers"') + + await expect(warning).toBeVisible() + await expect(message.getByText("the butler did it")).toHaveCount(0) + + await message.getByRole("button", {name: "Show anyway"}).click() + + await expect(message.getByText("the butler did it")).toBeVisible() + await expect(warning).toHaveCount(0) +}) + +test("US-061 expand a long post", async ({seed, as}) => { + // Twelve of these is several times the three hundred characters an article card shows. + const paragraphs = Array.from( + {length: 12}, + (_, i) => `Paragraph ${i} works through another part of the plan in some detail`, + ) + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.carol, "general") + space.profile(user.carol, {name: "Carol Chen"}) + + // No summary, so the card shows the body itself and truncating it is what the story is about. + space.event( + user.carol, + () => + space + .kind(Article) + .writer() + .setIdentifier("the-long-road") + .setTitle("The Long Road") + .setPublishedAt(at(3, HOUR)) + .setContent(paragraphs.join("\n\n")) + .renderTemplate(), + at(3, HOUR), + ) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, `${spacePath(url)}/articles`) + + await expect(page.getByText("The Long Road")).toBeVisible() + await expect(page.getByText(paragraphs[0])).toBeVisible() + await expect(page.getByText(paragraphs[11])).toHaveCount(0) + + const before = page.url() + + await page.getByRole("button", {name: "Read more"}).click() + + await expect(page.getByText(paragraphs[11])).toBeVisible() + + // The whole card is a link into the article, so expanding in place means the click never + // reached it. + expect(page.url()).toBe(before) +}) + +test("US-062 see images and video inline", async ({seed, as}) => { + let picture!: Seeded + let clip!: Seeded + + 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") + + picture = space.message(user.bob, "general", "https://images.test/sunset.png", at(40, MINUTE)) + clip = space.message(user.bob, "general", "https://media.test/clip.mp4", at(35, MINUTE)) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "general")) + + const pictureMessage = page.locator(`[data-event="${picture.id}"]`) + const clipMessage = page.locator(`[data-event="${clip.id}"]`) + const inlineImage = pictureMessage.locator('img[src="https://images.test/sunset.png"]') + + await expect(inlineImage).toBeVisible() + await expect(pictureMessage.getByText("images.test/sunset.png")).toHaveCount(0) + + await inlineImage.click() + + // The lightbox is a fullscreen modal holding nothing but the image. + const lightbox = page.locator(".dialog img") + + await expect(lightbox).toBeVisible() + + await lightbox.click() + + await expect(page.locator(".dialog")).toHaveCount(0) + await expect(inlineImage).toBeVisible() + + const video = clipMessage.locator("video") + + await expect(video).toBeVisible() + await expect(video).toHaveJSProperty("controls", true) +}) + +test("US-063 preview a shared link", async ({seed, as}) => { + const announcement = "https://example.test/announcement" + const unreadable = "https://example.test/unreadable" + + let standalone!: Seeded + let broken!: Seeded + let sentence!: Seeded + + 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") + + standalone = space.message(user.bob, "general", announcement, at(40, MINUTE)) + broken = space.message(user.bob, "general", unreadable, at(35, MINUTE)) + sentence = space.message( + user.bob, + "general", + `Have a look at ${announcement} before the call.`, + at(30, MINUTE), + ) + }) + + const {url} = scenario.space("space") + + // The room is opened by hand below rather than by as(): a preview is fetched once per url and + // memoized for the life of the page, so the backend has to be standing before the first render. + const page = await as(users.alice, "/") + + let servePreview = () => {} + + const previewReleased = new Promise(resolve => { + servePreview = resolve + }) + + // Registered after as(), so it answers ahead of the harness's own dufflepud. Holding the one + // preview open makes the loading state a fact rather than a race. + await page.context().route( + requestUrl => requestUrl.pathname === "/link/preview", + async route => { + const {url: target}: {url: string} = route.request().postDataJSON() + + if (target === announcement) { + await previewReleased + + return route.fulfill({ + json: { + title: "Flotilla ships v1", + description: "Everything new in this release.", + image: "https://images.test/preview.png", + }, + }) + } + + // What dufflepud answers with for a url it could make nothing of. + return route.fulfill({json: {}}) + }, + ) + + await page.goto(roomPath(url, "general")) + + const card = page.locator(`[data-event="${standalone.id}"]`) + const failed = page.locator(`[data-event="${broken.id}"]`) + const inline = page.locator(`[data-event="${sentence.id}"]`) + const previewImage = 'img[src="https://images.test/preview.png"]' + + await expect(card.locator(".spinner")).toBeVisible() + await expect(failed.getByText(`Unable to load a preview for ${unreadable}`)).toBeVisible() + + servePreview() + + await expect(card.getByText("Flotilla ships v1")).toBeVisible() + await expect(card.getByText("Everything new in this release.")).toBeVisible() + await expect(card.locator(previewImage)).toBeVisible() + + // The same url mid-sentence, now that the preview it would have shown is known to resolve: a + // compact link, and none of the card. + await expect(inline.getByRole("link", {name: "example.test/announcement"})).toBeVisible() + await expect(inline.getByText("Flotilla ships v1")).toHaveCount(0) + await expect(inline.locator(previewImage)).toHaveCount(0) +}) + +test("US-064 follow a link to another space", async ({seed, as}) => { + let reference!: Seeded + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + + other.room("lounge", {name: "Other Lounge"}) + other.join(user.alice, "lounge") + + // The second space's url only exists once seeding has run, so the message is rendered late. + reference = space.event( + user.bob, + () => makeEvent(MESSAGE, {content: other.url, tags: [["h", "general"]]}), + at(30, MINUTE), + ) + }) + + const {url} = scenario.space("space") + const other = scenario.space("other") + const page = await as(users.alice, roomPath(url, "general")) + + const message = page.locator(`[data-event="${reference.id}"]`) + const spaceLink = message.getByRole("link", {name: "other.test"}) + + await expect(spaceLink).toBeVisible() + await expect(message.getByText(other.url)).toHaveCount(0) + await expect(spaceLink).toHaveAttribute("href", spacePath(other.url)) + await expect(spaceLink).not.toHaveAttribute("target", "_blank") + + await spaceLink.click() + + await page.waitForURL(next => next.pathname.startsWith(spacePath(other.url))) + + await expect( + page.locator(".secondary-nav").getByRole("link", {name: "Other Lounge"}), + ).toBeVisible() + + expect(page.context().pages()).toHaveLength(1) +}) + +test("US-065 see quoted and embedded content", async ({seed, as}) => { + let original!: Seeded + let reply!: Seeded + let thread!: Seeded + let threadQuote!: Seeded + + 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.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anders"}) + space.profile(user.bob, {name: "Bob Boberton"}) + space.profile(user.carol, {name: "Carol Chen"}) + + const point = space.message(user.alice, "general", "the original point", at(6, HOUR)) + + // Enough between the two that the original is well off screen when the room opens. + for (let i = 0; i < 30; i++) { + space.message(user.bob, "general", `filler message number ${i}`, at(300 - i * 5, MINUTE)) + } + + // Carol says something in the room too, so her profile is loaded by the time her name has to + // render on a card quoting her thread. + space.message(user.carol, "general", "posted a roadmap", at(140, MINUTE)) + + const topic = space.event( + user.carol, + () => + space + .kind(Thread) + .writer() + .setRoom(space.url, "general") + .setTitle("Roadmap for Q3") + .setContent("Here is what we are planning for the next quarter.") + .renderTemplate(), + at(3, HOUR), + ) + + original = point + thread = topic + reply = space.reply(user.bob, point, "totally agree with this", at(20, MINUTE)) + threadQuote = space.reply(user.bob, topic, "worth a read", at(15, MINUTE)) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "general")) + + const threadMessage = page.locator(`[data-event="${threadQuote.id}"]`) + + // First, because it is the state a quote card is in until the quoted event arrives, and a + // thread is not part of the room's own feed — the card has to go and fetch it. + await expect(threadMessage.getByText("Loading event...")).toBeVisible() + + const replyMessage = page.locator(`[data-event="${reply.id}"]`) + const quoteStrip = replyMessage.locator(".border-l-2") + + await expect(quoteStrip).toContainText("the original point") + await expect(replyMessage).toContainText("totally agree with this") + + const originalMessage = page.locator(`[data-event="${original.id}"]`) + + await expect(originalMessage).toBeAttached() + await expect(originalMessage).not.toBeInViewport() + + await quoteStrip.click() + + await expect(originalMessage).toBeInViewport() + + // The quoted thread's card, once it has loaded: the profile circle beside the message carries + // the same border classes, so the title is what says which of them is meant. + const quotedThread = threadMessage.locator(".border.border-solid").filter({ + hasText: "Roadmap for Q3", + }) + + await expect(quotedThread).toBeVisible() + await expect(quotedThread).toContainText("Carol Chen") + await expect(quotedThread).toContainText("Here is what we are planning for the next quarter.") + + await quotedThread.getByText("Roadmap for Q3").click() + + await page.waitForURL(next => next.pathname === `${spacePath(url)}/threads/${thread.id}`) + + await expect(page.getByRole("heading", {name: "Roadmap for Q3"})).toBeVisible() +}) + +test("US-066 see distinctive inline tokens", async ({seed, as}) => { + const mention = `nostr:${npubEncode(users.bob.pubkey)}` + const content = [ + "Shipping #nostr today :partyparrot:", + `Run \`npm install\` first, then ping ${mention} or email team@example.test`, + "```", + "const answer = 42", + "```", + ].join("\n") + + let tokens!: Seeded + + 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.join(user.carol, "general") + space.profile(user.bob, {name: "Bob Boberton"}) + space.profile(user.carol, {name: "Carol Chen"}) + + // Bob posts too, so his profile is loaded against this space before he is mentioned. + space.message(user.bob, "general", "morning all", at(50, MINUTE)) + + // The shortcode's image comes off the message's own emoji tag, which is what a client writes + // when someone picks a custom emoji. + tokens = space.event( + user.carol, + makeEvent(MESSAGE, { + content, + tags: [ + ["h", "general"], + ["emoji", "partyparrot", "https://images.test/partyparrot.png"], + ], + created_at: at(40, MINUTE), + }), + ) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "general")) + + const message = page.locator(`[data-event="${tokens.id}"]`) + + await expect(message.getByText("#nostr", {exact: true})).toHaveClass(/link-content/) + await expect(message.getByAltText(":partyparrot:")).toBeVisible() + // An img contributes no text, so this is the "instead of the raw text" half of the story: with + // no emoji tag to resolve, the shortcode is rendered verbatim. + await expect(message.getByText(":partyparrot:")).toHaveCount(0) + + const inlineCode = message.locator("code").filter({hasText: "npm install"}) + const blockCode = message.locator("code").filter({hasText: "const answer = 42"}) + + await expect(inlineCode).toBeVisible() + await expect(inlineCode).not.toHaveClass(/(^|\s)block(\s|$)/) + await expect(blockCode).toBeVisible() + await expect(blockCode).toHaveClass(/(^|\s)block(\s|$)/) + + await expect(message.getByRole("link", {name: "team@example.test"})).toHaveAttribute( + "href", + "mailto:team@example.test", + ) + + await message.getByRole("button", {name: "@Bob Boberton"}).click() + + // Dialog wraps its content in a second element of its own, so the inner one is the modal. + const profile = page.locator(".dialog").last() + + await expect(profile.getByText("Bob Boberton", {exact: true})).toBeVisible() + await expect(profile.getByRole("button", {name: "View Full Profile"})).toBeVisible() +}) + +test("US-067 copy a shared invoice or token", async ({seed, as}) => { + let invoice!: Seeded + let token!: Seeded + + 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") + + invoice = space.message(user.bob, "general", `lightning:${INVOICE}`, at(40, MINUTE)) + token = space.message(user.bob, "general", CASHU, at(35, MINUTE)) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "general"), { + context: {permissions: ["clipboard-read", "clipboard-write"]}, + }) + + const toast = page.getByRole("alert") + const invoiceChip = page + .locator(`[data-event="${invoice.id}"]`) + .getByRole("button", {name: INVOICE.slice(0, 16)}) + + await expect(invoiceChip).toBeVisible() + await expect(page.getByText(INVOICE)).toHaveCount(0) + + await invoiceChip.click() + + await expect(toast).toContainText("Copied to clipboard!") + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(INVOICE) + + // Both copies raise the same toast, so this one is dismissed rather than waited out — otherwise + // the second assertion would pass on the first toast. + await toast.getByRole("button").click() + + await expect(toast).toHaveCount(0) + + const tokenChip = page + .locator(`[data-event="${token.id}"]`) + .getByRole("button", {name: CASHU.slice(0, 16)}) + + await expect(tokenChip).toBeVisible() + await expect(page.getByText(CASHU)).toHaveCount(0) + + await tokenChip.click() + + await expect(toast).toContainText("Copied to clipboard!") + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(CASHU) +}) diff --git a/e2e/specs/delivery.spec.ts b/e2e/specs/delivery.spec.ts new file mode 100644 index 00000000..236be149 --- /dev/null +++ b/e2e/specs/delivery.spec.ts @@ -0,0 +1,608 @@ +import {HOUR} from "@welshman/lib" +import {LONG_FORM, MESSAGE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {ClientMessageType} from "@welshman/net" +import {Article, Comment, MessagingRelayList, Reaction, RelayList} from "@welshman/domain" +import type {Locator, Page} from "@playwright/test" +import {expect, getTranscript, mockBlossom, roomPath, spacePath, test, users} from "../harness" +import type {SeededSpace, TestUser} from "../harness" + +// The path the app builds for a conversation — see makeChatId in src/app/chats.ts. +const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}` + +// makeSpacePath percent-encodes each segment it is given, and an address is full of colons. +const articlePath = (url: string, address: string) => + `${spacePath(url)}/articles/${encodeURIComponent(address)}` + +// Where an upload lands: the space's own origin is probed first and blossom is off in every +// tenant's toml, so what is left is VITE_DEFAULT_BLOSSOM_SERVERS. +const BLOSSOM_ORIGIN = "https://blossom.primal.net" + +// A one pixel gif. Gif rather than png because compressFileForUpload passes it through untouched +// rather than re-encoding it through a canvas. +const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64") + +// Outbox routing resolves everything about a person through their relay list, so a person here is +// a membership, a profile and a kind-10002. +const seedPerson = (space: SeededSpace, user: TestUser, name: string) => { + space.profile(user, {name}) + space.event(user, () => + space + .kind(RelayList) + .writer() + .setReadUrls([space.url]) + .setWriteUrls([space.url]) + .renderTemplate(), + ) +} + +// The kind-10050 that says where someone's direct messages go. It is read lazily so that a list +// can name a relay whose url only exists once seeding has drained. +const enableDms = (space: SeededSpace, user: TestUser, getUrls: () => string[]) => + space.event(user, () => + space.kind(MessagingRelayList).writer().setUrls(getUrls()).renderTemplate(), + ) + +const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]") + +// The send button carries the shortcut it advertises, which differs by platform. +const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']") + +const timeline = (page: Page) => page.locator(".room__content") + +const message = (page: Page, text: string) => page.locator(".room__item").filter({hasText: text}) + +const bubble = (page: Page, text: string) => page.locator(".chat-bubble").filter({hasText: text}) + +// One toast at a time — src/app/toast.ts holds a single writable — so this is the toast. +const toast = (page: Page) => page.getByRole("alert") + +// A tippy is appended to the layout's own target rather than beside its trigger, and it keeps its +// content mounted after it hides, so the visible card there is the popover that was just opened. +const detail = (page: Page) => page.locator(".tippy-target .card").filter({visible: true}) + +// EventActions renders zap, emoji and menu into one join, in that order, and every one of them is +// an icon with no accessible name. +const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last() + +// A comment, an article and a thread post are all the same feature card, named by their text. +const noteCard = (page: Page, text: string) => + page.locator(".card.z-feature").filter({hasText: text}) + +// A modal is mounted alongside the page it covers, so a page's own "Write" and the composer's +// submit are both in the dom at once. +const modal = (page: Page, title: string) => + page.locator("form").filter({has: page.getByRole("heading", {name: title})}) + +const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]") + +// The composer stays disabled until every recipient's messaging relays have been read, so waiting +// on the send button is part of sending rather than a wait for a wait's sake. +const send = async (page: Page, content: string) => { + await expect(sendButton(page)).toBeEnabled() + await composer(page).click() + await composer(page).pressSequentially(content) + await composer(page).press("Enter") +} + +// Every frame this page put on the wire carrying an event, with the relay it went to. +const published = (page: Page): {url: string; event: TrustedEvent}[] => + getTranscript(page.context()) + .filter( + ({direction, message}) => direction === "toRelay" && message[0] === ClientMessageType.Event, + ) + .map(({url, message}) => ({url, event: message[1]})) + +const publishedEvents = (page: Page, kind: number) => + published(page) + .filter(({event}) => event.kind === kind) + .map(({event}) => event) + +// Which relays a given event was sent to, oldest first — one entry per attempt. +const publishedTo = (page: Page, id: string) => + published(page) + .filter(({event}) => event.id === id) + .map(({url}) => url) + +const writeArticle = async (page: Page, title: string, body: string) => { + await page.getByRole("button", {name: "Write"}).click() + + const composer = modal(page, "Write an Article") + + await composer.getByPlaceholder("What is this article about?").fill(title) + await editorOf(composer).pressSequentially(body) + await composer.getByRole("button", {name: "Publish Article"}).click() +} + +const writeComment = async (page: Page, body: string) => { + await page.getByRole("button", {name: "Add a comment"}).click() + + const composer = page.locator("form").filter({has: page.locator(".note-editor")}) + + await editorOf(composer).pressSequentially(body) + await composer.getByRole("button", {name: "Comment"}).click() +} + +// The send delay is a user setting rather than a page's own state, so it is set the way a person +// sets it. Reading it back off a freshly loaded page is what says the client has taken it up: the +// slider is initialised from the settings store on mount. +const setSendDelay = async (page: Page, seconds: number) => { + const slider = page.locator("input[type=range]") + + await page.goto("/settings/content") + await slider.fill(String(seconds * 1000)) + + await expect(page.getByText(`Delay sending chat messages for ${seconds} seconds.`)).toBeVisible() + + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(toast(page)).toContainText("Your settings have been saved!") + + await page.goto("/settings/content") + await expect(slider).toHaveValue(String(seconds * 1000)) +} + +const chooseFile = async (page: Page, button: Locator, name: string) => { + const chooser = page.waitForEvent("filechooser") + + await button.click() + await (await chooser).setFiles({name, mimeType: "image/gif", buffer: GIF}) +} + +test("US-068 watch a delayed send, and cancel it", async ({seed, as}) => { + 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") + + for (const person of [user.alice, user.bob]) { + seedPerson(space, person, person.name) + enableDms(space, person, () => [space.url]) + } + + space.message(user.bob, "general", "morning all", at(2, HOUR)) + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + + const alice = await as(users.alice, path) + const bob = await as(users.bob, path) + + await expect(timeline(alice).getByText("morning all")).toBeVisible() + await expect(timeline(bob).getByText("morning all")).toBeVisible() + + // The delay defaults to zero, so this one leaves as soon as it is written and says nothing. + await send(alice, "no delay here") + + await expect(message(alice, "no delay here")).toBeVisible() + await expect(message(bob, "no delay here")).toBeVisible() + await expect(toast(alice)).toHaveCount(0) + + await setSendDelay(alice, 5) + await alice.goto(path) + + await expect(timeline(alice).getByText("morning all")).toBeVisible() + + await send(alice, "second thoughts") + + // The bubble is hers straight away; the delay is only about when it leaves. + await expect(message(alice, "second thoughts")).toBeVisible() + await expect(toast(alice)).toContainText("Sending...") + await expect(toast(alice).getByRole("button", {name: "Cancel"})).toBeVisible() + + await expect(toast(alice)).toContainText("Message sent!") + await expect(message(bob, "second thoughts")).toBeVisible() + await expect(toast(alice)).toHaveCount(0) + + await send(alice, "wrong room, sorry") + + await expect(message(alice, "wrong room, sorry")).toBeVisible() + + await toast(alice).getByRole("button", {name: "Cancel"}).click() + + await expect(message(alice, "wrong room, sorry")).toHaveCount(0) + await expect(toast(alice)).toHaveCount(0) + + // Sent after the cancelled one and delayed by as long, so bob having this means the window the + // cancelled one would have left in has been and gone. + await send(alice, "still here") + + await expect(message(bob, "still here")).toBeVisible() + await expect(message(bob, "wrong room, sorry")).toHaveCount(0) + + // The same cancel in a conversation. + await alice.goto(chatPath(users.bob.pubkey)) + await bob.goto(chatPath(users.alice.pubkey)) + + await send(alice, "ignore this one") + + await expect(bubble(alice, "ignore this one")).toBeVisible() + + await toast(alice).getByRole("button", {name: "Cancel"}).click() + + await send(alice, "actually, hi") + + await expect(bubble(bob, "actually, hi")).toBeVisible() + await expect(bubble(bob, "ignore this one")).toHaveCount(0) + await expect(bubble(alice, "ignore this one")).toHaveCount(0) +}) + +test("US-069 see why a message failed to deliver", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + // Alice belongs to both relays; bob deliberately belongs only to the first. + other.join(user.alice) + + for (const person of [user.alice, user.bob]) { + seedPerson(space, person, person.name) + } + + enableDms(space, user.alice, () => [space.url]) + // Bob's client says his messages go to both relays, but he is not a member of the second, so a + // wrap addressed to him is stored by one and refused by the other. + enableDms(space, user.bob, () => [space.url, other.url]) + + space.message(user.bob, "general", "morning all", at(2, HOUR)) + }) + + const {url} = scenario.space("space") + + // A room the relay never created, which is where a link to a room that has since been deleted + // lands. Everything else about the space still works, so only this one publish is refused. + const ghost = roomPath(url, "archive") + + const alice = await as(users.alice, ghost) + const bob = await as(users.bob, ghost) + + await send(alice, "anyone here?") + + const failure = message(alice, "anyone here?").getByText("Failed to send!") + + await expect(failure).toBeVisible() + + // The text is still hers to see, and the publish is finished and was refused, so there is + // nothing left for bob to receive. + await expect(message(alice, "anyone here?")).toContainText("anyone here?") + await expect(message(bob, "anyone here?")).toHaveCount(0) + + await failure.click() + + await expect(detail(alice)).toContainText("Failed to send!") + await expect(detail(alice)).toContainText("space.test") + await expect(detail(alice)).toContainText("invalid: group not found.") + await expect(detail(alice).getByRole("button", {name: "Retry"})).toBeVisible() + + // A conversation with someone whose two messaging relays do not both take his mail. + await alice.goto(chatPath(users.bob.pubkey)) + + await send(alice, "half a message") + + const partial = alice.getByText("Failed to send!") + + await expect(partial).toBeVisible() + + await partial.click() + + await expect(detail(alice)).toContainText("Partial delivery 1/2 relays") + await expect(detail(alice)).toContainText("space.test") + await expect(detail(alice)).toContainText("other.test") + await expect(detail(alice)).toContainText("restricted:") + await expect(detail(alice).locator(".text-success")).toHaveCount(1) +}) + +test("US-070 retry a failed relay", 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") + seedPerson(space, user.alice, "Alice Anderson") + seedPerson(space, user.bob, "Bob Barker") + }) + + const {url} = scenario.space("space") + const ghost = roomPath(url, "archive") + const alice = await as(users.alice, ghost) + + // Opened now rather than once the room exists: seeding again below replaces the scenario, and a + // page opened from that one carries no room list — which is the only thing telling authPolicy it + // may answer a members-only relay's challenge, so bob would never get to read anything. + const bob = await as(users.bob, roomPath(url, "general")) + + // A delay is what makes the retry's own "Sending..." a state rather than an instant, so this + // watches a retry the way the person who asked for it does. + await setSendDelay(alice, 5) + await alice.goto(ghost) + + await send(alice, "is anyone here?") + + const failure = message(alice, "is anyone here?").getByText("Failed to send!") + + await expect(failure).toBeVisible() + + await failure.click() + await detail(alice).getByRole("button", {name: "Retry"}).click() + + await expect(toast(alice)).toContainText("Sending...") + + // The room still does not exist, so this attempt is refused too: the toast goes without ever + // having said the message was sent, and the message is still marked failed. + await expect(toast(alice)).toHaveCount(0) + await expect(failure).toBeVisible() + + // The admin creates the room the message was addressed to. Seeding again is the only way the + // relay changes its mind about something it has already refused. + await seed(({relay}) => { + relay("space").room("archive", {name: "Archive"}) + }) + + await failure.click() + await detail(alice).getByRole("button", {name: "Retry"}).click() + + await expect(toast(alice)).toContainText("Sending...") + await expect(toast(alice)).toContainText("Message sent!") + + const [sent] = publishedEvents(alice, MESSAGE) + + // One attempt and two retries, every one of them to the relay that failed and to nothing else. + expect(publishedTo(alice, sent.id)).toEqual([url, url, url]) + + await bob.goto(roomPath(url, "archive")) + + await expect(message(bob, "is anyone here?")).toBeVisible() +}) + +test("US-071 content posts show delivery status in place", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + seedPerson(space, user.alice, "Alice Anderson") + seedPerson(space, user.bob, "Bob Barker") + + // A second space in her list, so that opening it prompts her for nothing. + other.join(user.alice) + }) + + const {url} = scenario.space("space") + const quiet = scenario.space("other").url + + // A space whose relay this browser will not open a socket to, which is what a post that sits + // unconfirmed looks like from the inside: it is sent, and nothing comes back. + const alice = await as(users.alice, `${spacePath(url)}/articles`, { + env: {VITE_BLOCKED_RELAYS: quiet}, + }) + + await writeArticle(alice, "Signals in the Noise", "Everything worth hearing is quiet.") + + const article = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Signals"}) + + await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0) + + // The row's own actions first: a status this row does not have says nothing until the row it + // would sit in is on screen. + await expect(menuOf(article)).toBeVisible() + await expect(article.getByText("Sending...")).toHaveCount(0) + await expect(article.getByText("Failed to send!")).toHaveCount(0) + await expect(toast(alice)).toHaveCount(0) + + await article.getByText("Signals in the Noise").click() + await writeComment(alice, "Worth saying twice.") + + const comment = noteCard(alice, "Worth saying twice.") + + await expect(menuOf(comment)).toBeVisible() + await expect(comment.getByText("Sending...")).toHaveCount(0) + await expect(comment.getByText("Failed to send!")).toHaveCount(0) + await expect(toast(alice)).toHaveCount(0) + + // The same post into the space whose relay says nothing back. + await alice.goto(`${spacePath(quiet)}/articles`) + await writeArticle(alice, "Into the Void", "Nobody is listening.") + + const stuck = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Into the Void"}) + + await expect(stuck.getByText("Sending...")).toBeVisible() + await expect(stuck.getByRole("button", {name: "Cancel"})).toBeVisible() + + // The composer closes itself once publishing gives up, and the row it left behind says why. + await expect(stuck.getByText("Failed to send!")).toBeVisible() + await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0) + + await stuck.getByText("Failed to send!").click() + + await expect(detail(alice)).toContainText("Failed to send!") + await expect(detail(alice)).toContainText("other.test") + await expect(detail(alice)).toContainText("request timed out.") + + await stuck.getByText("Into the Void").click() + await writeComment(alice, "A footnote nobody asked for.") + + const footnote = noteCard(alice, "A footnote nobody asked for.") + + await expect(footnote.getByText("Sending...")).toBeVisible() + await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible() + + await footnote.getByRole("button", {name: "Cancel"}).click() + + await expect(noteCard(alice, "A footnote nobody asked for.")).toHaveCount(0) +}) + +test("US-072 a deleted post is marked deleted", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + seedPerson(space, user.alice, "Alice Anderson") + seedPerson(space, user.bob, "Bob Barker") + other.join(user.alice) + + const article = space.event( + user.alice, + () => + space + .kind(Article) + .writer() + .setIdentifier("tending-the-garden") + .setTitle("Tending the Garden") + .setPublishedAt(at(4, HOUR)) + .setContent("Gardens are worth the trouble.") + .renderTemplate(), + at(4, HOUR), + ) + + const comment = space.event( + user.alice, + () => + space + .kind(Comment) + .writer() + .setRootFromEvent(article.event) + .setParentFromEvent(article.event) + .setContent("A note about the soil chapter.") + .renderTemplate(), + at(3, HOUR), + ) + + // Somebody else's reaction, so the summary the pill is meant to replace has something in it. + space.event( + user.bob, + () => space.kind(Reaction).writer().setEvent(comment.event).setContent("🎉").renderTemplate(), + at(2, HOUR), + ) + }) + + const {url} = scenario.space("space") + const quiet = scenario.space("other").url + + const alice = await as(users.alice, `${spacePath(quiet)}/articles`, { + env: {VITE_BLOCKED_RELAYS: quiet}, + }) + + // A post whose relay never answered, deleted while it is still marked failed. + await writeArticle(alice, "Into the Void", "Nobody is listening.") + + const stuck = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Into the Void"}) + + await expect(stuck.getByText("Failed to send!")).toBeVisible() + + // The composer closes itself once publishing gives up, and the row's menu is underneath it. + await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0) + + await menuOf(stuck).click() + await alice.getByRole("button", {name: "Delete Article"}).click() + await alice.getByRole("button", {name: "Confirm"}).click() + + await expect(stuck.getByText("Deleted", {exact: true})).toBeVisible() + await expect(stuck.getByText("Failed to send!")).toHaveCount(0) + + await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`)) + + const comment = noteCard(alice, "A note about the soil chapter.") + const article = noteCard(alice, "Gardens are worth the trouble.") + + await expect(comment).toBeVisible() + await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible() + + await menuOf(comment).click() + await alice.getByRole("button", {name: "Delete Comment"}).click() + await alice.getByRole("button", {name: "Confirm"}).click() + + await expect(comment.getByText("Deleted", {exact: true})).toBeVisible() + + await menuOf(article).click() + await alice.getByRole("button", {name: "Delete Article"}).click() + await alice.getByRole("button", {name: "Confirm"}).click() + + await expect(article.getByText("Deleted", {exact: true})).toBeVisible() + + // The pill stands in place of what the row offered before it, rather than beside it. + await expect(comment.getByRole("button", {name: /🎉/})).toHaveCount(0) + await expect(comment.locator(".join")).toHaveCount(0) + await expect(article.locator(".join")).toHaveCount(0) +}) + +test("US-073 a multi-part message reports one status", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + + for (const person of [user.alice, user.bob, user.carol]) { + space.join(person, "general") + seedPerson(space, person, person.name) + } + + enableDms(space, user.alice, () => [space.url]) + enableDms(space, user.bob, () => [space.url]) + // Carol's client names a relay she does not belong to, so every part of a message to her is + // stored by one of her two relays and refused by the other. + enableDms(space, user.carol, () => [space.url, other.url]) + }) + + const alice = await as(users.alice, chatPath(users.bob.pubkey)) + const bob = await as(users.bob, chatPath(users.alice.pubkey)) + + await mockBlossom(alice.context(), {server: BLOSSOM_ORIGIN}) + await mockBlossom(bob.context(), {server: BLOSSOM_ORIGIN}) + + await expect(sendButton(alice)).toBeEnabled() + + await composer(alice).click() + await composer(alice).pressSequentially("here is the harbour") + await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), "harbour.gif") + + // The editor names the file the moment it is attached, so the name alone does not mean the + // upload is done — and a submit while it is still running is dropped on the floor. + await expect(composer(alice)).toContainText("harbour.gif") + await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0) + await expect(sendButton(alice)).toBeEnabled() + + await composer(alice).press("Enter") + + // Two bubbles, one status for the pair of them. + await expect(toast(alice)).toHaveCount(1) + await expect(toast(alice)).toContainText("Sending...") + await expect(alice.locator(".chat-bubble")).toHaveCount(2) + await expect(bubble(alice, "here is the harbour")).toBeVisible() + + // Which only becomes a success once both parts have landed everywhere they were sent. + await expect(toast(alice)).toContainText("Message sent!") + await expect(toast(alice)).toHaveCount(1) + await expect(bob.locator(".chat-bubble")).toHaveCount(2) + await expect(toast(alice)).toHaveCount(0) + + // The same message to someone one of whose relays refuses it: each part says so for itself. + await alice.goto(chatPath(users.carol.pubkey)) + + await expect(sendButton(alice)).toBeEnabled() + + await composer(alice).click() + await composer(alice).pressSequentially("and one for you") + await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), "harbour.gif") + + await expect(composer(alice)).toContainText("harbour.gif") + await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0) + await expect(sendButton(alice)).toBeEnabled() + + await composer(alice).press("Enter") + + await expect(alice.locator(".chat-bubble")).toHaveCount(2) + await expect(alice.getByText("Failed to send!")).toHaveCount(2) +}) diff --git a/e2e/specs/dms.spec.ts b/e2e/specs/dms.spec.ts new file mode 100644 index 00000000..94ff6f0a --- /dev/null +++ b/e2e/specs/dms.spec.ts @@ -0,0 +1,613 @@ +import {npubEncode} from "nostr-tools/nip19" +import type {Locator, Page} from "@playwright/test" +import {DAY, HOUR, MINUTE} from "@welshman/lib" +import {MessagingRelayList, RelayList} from "@welshman/domain" +import {expect, makeTestUser, test, users} from "../harness" +import type {SeededRumor, SeededSpace, TestUser} from "../harness" + +// The path the app builds for a conversation: the other participants' pubkeys, sorted and joined +// with commas — see makeChatId in src/app/chats.ts. +const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}` + +const profilePath = (pubkey: string) => `/people/${npubEncode(pubkey)}` + +// A path as a pattern, for a url that carries a modal's hash alongside it. +const pathPattern = (path: string) => new RegExp(path.replace(/[.?+*()[\]]/g, "\\$&")) + +// Everything about a person is resolved through their relay list — their profile, their messaging +// relays, the wraps addressed to them — and a scenario with no fallbacks resolves an author with +// no kind-10002 to no relays at all. So a person here is a membership, a profile and a relay list. +// Membership is also what lets a gift wrap addressed to them be stored: zooid authorizes a +// kind-1059 by the member named in its p tag. +const seedPerson = (space: SeededSpace, user: TestUser, name: string) => { + space.join(user) + space.profile(user, {name}) + space.event(user, () => + space + .kind(RelayList) + .writer() + .setReadUrls([space.url]) + .setWriteUrls([space.url]) + .renderTemplate(), + ) +} + +// The kind-10050 ChatEnable publishes when someone accepts "Enable direct messaging?". Having one +// is what makes a person reachable, so a story about messaging being off is a person without one. +const enableDms = (space: SeededSpace, user: TestUser) => + space.event(user, () => + space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(), + ) + +const chatList = (page: Page) => page.locator(".secondary-nav .overflow-auto") + +// One ChatItem is one button; nothing inside it is one. +const chatItems = (page: Page) => chatList(page).locator("button") + +const chatFilter = (page: Page) => page.locator(".secondary-nav input[type='text']") + +// ChatItem's unread mark is a bare dot with no text of its own. +const unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary") + +const pageBar = (page: Page) => page.locator('[data-component="PageBar"]') + +// A modal's body is the only scroll container carrying its title. +const modalBody = (page: Page, title: string) => + page + .locator(".scroll-container") + .filter({has: page.getByRole("heading", {name: title, exact: true})}) + +const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]") + +// The send button is named by the shortcut it advertises, which differs by platform. +const sendButton = (page: Page) => page.locator('.room__compose [data-tip$="enter to send"]') + +// Both things the composer puts above itself — the message being replied to and the editing +// indicator — are the same bordered strip. +const composePreview = (page: Page) => page.locator(".room__compose .border-l-2") + +const bubble = (page: Page, text: string) => page.locator(".chat-bubble").filter({hasText: text}) + +const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`) + +const enablePrompt = (page: Page) => page.getByRole("heading", {name: "Enable direct messaging?"}) + +// The composer stays disabled until every recipient's messaging relays have been read, so waiting +// on it is part of sending rather than a wait for a wait's sake. +const send = async (page: Page, content: string) => { + await expect(sendButton(page)).toBeEnabled() + await composer(page).pressSequentially(content) + await composer(page).press("Enter") +} + +// A suggestion carries the pubkey it selects as its label, so the name is what gets typed and the +// pubkey is what identifies the row that comes back. +const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => { + await page.getByRole("button", {name: "Start New Chat"}).click() + + await expect(page.getByRole("heading", {name: "Start a Chat"})).toBeVisible() + + for (const {term, user} of people) { + const suggestion = page.locator(`.tiptap-suggestions__item[aria-label="${user.pubkey}"]`) + + // Typed a keystroke at a time rather than filled: the suggestion list is rebuilt off the term + // changing, so a term that arrives all at once is matched against whatever the profile search + // held at that instant and never again. + await page.getByPlaceholder("Search for profiles...").pressSequentially(term, {delay: 150}) + + await expect(suggestion).toContainText(term) + + await suggestion.click() + } + + await page.getByRole("button", {name: "Create Chat"}).click() +} + +// A message's actions live behind a hover popover on a pointer device and behind a modal of named +// buttons on a touch one, so a spec that wants to name them opens a touch context. +const openMessageMenu = (page: Page, id: string) => + message(page, id).locator(".chat-bubble").click() + +// A day divider and a chat item's stamp, formatted by the browser rather than by node, so the +// locale and the timezone are the ones the app rendered with. The options mirror dateFormatter and +// dateTimeFormatter in @welshman/lib. +const dayLabel = (page: Page, seconds: number) => + page.evaluate( + ts => + new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format( + new Date(ts * 1000), + ), + seconds, + ) + +const stampLabel = (page: Page, seconds: number) => + page.evaluate( + ts => + new Intl.DateTimeFormat(undefined, {dateStyle: "short", timeStyle: "short"}).format( + new Date(ts * 1000), + ), + seconds, + ) + +const topOf = async (locator: Locator) => { + const box = await locator.boundingBox() + + expect(box).toBeTruthy() + + return box!.y +} + +test("US-029 start a one-on-one chat", async ({seed, as}) => { + await seed(({relay, user, at}) => { + const space = relay("space") + + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + seedPerson(space, user.carol, "Carol Cutter") + enableDms(space, user.alice) + enableDms(space, user.bob) + + // A conversation the three of them are already in. It puts bob's profile in her client before + // she goes looking for him, without being the one-on-one she is about to start. + space.dm(user.carol, [user.alice, user.bob], "welcome aboard, both of you", at(3, HOUR)) + }) + + const page = await as(users.alice, "/chat") + + // A group conversation's item in the list names only the first of its participants, so his name + // rather than his npub in its header is the client saying it has his profile, which is what the + // search she is about to type into is built from. + await chatItems(page).filter({hasText: "welcome aboard"}).click() + + await expect(pageBar(page)).toContainText("Bob Barnacle") + + await startChat(page, [{term: "Barnacle", user: users.bob}]) + + await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey))) + await expect(composer(page)).toBeVisible() + await expect(sendButton(page)).toBeEnabled() + + await send(page, "hi bob") + + await expect(bubble(page, "hi bob")).toBeVisible() + + // The conversation she just started is the one at the top of her list, under his name + await expect(chatItems(page).first()).toContainText("Bob Barnacle") + await expect(chatItems(page).first()).toContainText("hi bob") + + // His profile leads back into the same conversation + await pageBar(page).getByRole("button", {name: "Bob Barnacle"}).click() + await page.getByRole("button", {name: "View Full Profile"}).click() + + await expect(page).toHaveURL(pathPattern(profilePath(users.bob.pubkey))) + + await page.getByRole("button", {name: "Message", exact: true}).click() + + await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey))) + await expect(bubble(page, "hi bob")).toBeVisible() + + // Her own profile offers no way to message herself + await page.goto(profilePath(users.alice.pubkey)) + + await expect(page.getByRole("button", {name: "Edit profile"})).toBeVisible() + await expect(page.getByRole("button", {name: "Message", exact: true})).toHaveCount(0) +}) + +test("US-030 start a group chat", async ({seed, as}) => { + await seed(({relay, user, at}) => { + const space = relay("space") + + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + seedPerson(space, user.carol, "Carol Cutter") + enableDms(space, user.alice) + enableDms(space, user.bob) + enableDms(space, user.carol) + + // One conversation with each of them, so both profiles are in her client before she searches. + space.dm(user.bob, [user.alice], "just us two", at(4, HOUR)) + space.dm(user.carol, [user.alice], "hello from carol", at(3, HOUR)) + }) + + const page = await as(users.alice, "/chat") + + // Their names rather than their npubs is the client saying it has both profiles, which is what + // the search she is about to type into is built from. + await expect(chatItems(page).filter({hasText: "just us two"})).toContainText("Bob Barnacle") + await expect(chatItems(page).filter({hasText: "hello from carol"})).toContainText("Carol Cutter") + + await startChat(page, [ + {term: "Barnacle", user: users.bob}, + {term: "Cutter", user: users.carol}, + ]) + + // One conversation holding both of them, rather than one apiece + await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey, users.carol.pubkey))) + await expect(sendButton(page)).toBeEnabled() + + // Labeled with both of them rather than with one + await expect(pageBar(page)).toContainText("Bob Barnacle") + await expect(pageBar(page)).toContainText("Carol Cutter") + + await pageBar(page) + .getByRole("button", {name: /Bob Barnacle/}) + .click() + + const members = modalBody(page, "People in this conversation") + + await expect(members.getByText("Bob Barnacle")).toBeVisible() + await expect(members.getByText("Carol Cutter")).toBeVisible() + + await members.getByRole("button", {name: "Carol Cutter"}).click() + + await expect(members).toHaveCount(0) + await expect(page.getByRole("button", {name: "View Full Profile"})).toBeVisible() + + await page.getByRole("button", {name: "View Full Profile"}).click() + + await expect(page).toHaveURL(pathPattern(profilePath(users.carol.pubkey))) +}) + +test("US-031 direct messaging has to be switched on", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + + // Neither of them has ever published a kind-10050, which is what messaging being off is. + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + }) + + const alice = await as(users.alice, profilePath(users.bob.pubkey)) + + await alice.getByRole("button", {name: "Message", exact: true}).click() + + // The prompt stands where the conversation she asked for would have been + await expect(enablePrompt(alice)).toBeVisible() + await expect(alice).not.toHaveURL(pathPattern(chatPath(users.bob.pubkey))) + await expect(composer(alice)).toHaveCount(0) + + await alice.getByRole("button", {name: "Enable direct messaging"}).click() + + // Turning it on carries her on into the conversation she started + await expect(enablePrompt(alice)).toHaveCount(0) + await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey))) + + // Bob has never enabled it, so his end of the conversation is a dead end + const banner = alice.locator(".card").filter({hasText: "Direct messages are not enabled"}) + + await expect(banner).toContainText("Bob Barnacle") + await expect(sendButton(alice)).toBeDisabled() + + const bob = await as(users.bob, "/") + + await bob.locator('.primary-nav [data-tip="Messages"]').click() + + await expect(enablePrompt(bob)).toBeVisible() + + await bob.getByRole("button", {name: "Enable direct messaging"}).click() + + // His list is the app's evidence that his kind-10050 has been published + await expect(bob).toHaveURL(/\/chat$/) + await expect(bob.getByRole("button", {name: "Start New Chat"})).toBeVisible() + + // She reopens the conversation the way she opened it the first time + await pageBar(alice).getByRole("button", {name: "Bob Barnacle"}).click() + await alice.getByRole("button", {name: "View Full Profile"}).click() + + await expect(alice).toHaveURL(pathPattern(profilePath(users.bob.pubkey))) + + await alice.getByRole("button", {name: "Message", exact: true}).click() + + // The prompt is behind her for good, and his end of it works now + await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey))) + await expect(enablePrompt(alice)).toHaveCount(0) + await expect(sendButton(alice)).toBeEnabled() + await expect(banner).toHaveCount(0) + + await send(alice, "finally") + + await expect(bubble(alice, "finally")).toBeVisible() + await expect(chatItems(bob).filter({hasText: "finally"})).toBeVisible() +}) + +test("US-032 exchange messages in a conversation", async ({seed, as}) => { + let yesterday!: SeededRumor + let firstToday!: SeededRumor + let secondToday!: SeededRumor + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + enableDms(space, user.alice) + enableDms(space, user.bob) + + yesterday = space.dm(user.bob, [user.alice], "sent this yesterday", at(30, HOUR)) + // A minute apart, which is what puts these two in the same run of messages. + firstToday = space.dm(user.bob, [user.alice], "morning alice", at(50, MINUTE)) + secondToday = space.dm(user.bob, [user.alice], "still around?", at(49, MINUTE)) + }) + + const alice = await as(users.alice, chatPath(users.bob.pubkey)) + const bob = await as(users.bob, chatPath(users.alice.pubkey)) + + await expect(message(alice, yesterday.id)).toBeVisible() + await expect(message(alice, firstToday.id)).toBeVisible() + await expect(message(alice, secondToday.id)).toBeVisible() + + // Chronological: each message is rendered below the one before it + expect(await topOf(message(alice, yesterday.id))).toBeLessThan( + await topOf(message(alice, firstToday.id)), + ) + expect(await topOf(message(alice, firstToday.id))).toBeLessThan( + await topOf(message(alice, secondToday.id)), + ) + + // A divider per day + await expect(alice.getByText(await dayLabel(alice, scenario.at(30, HOUR)))).toBeVisible() + await expect(alice.getByText(await dayLabel(alice, scenario.at(50, MINUTE)))).toBeVisible() + + // His second message in a row carries neither his name nor his avatar + await expect(bubble(alice, "morning alice")).toContainText("Bob Barnacle") + await expect(bubble(alice, "morning alice").locator(".rounded-full")).toHaveCount(1) + await expect(bubble(alice, "still around?")).not.toContainText("Bob Barnacle") + await expect(bubble(alice, "still around?").locator(".rounded-full")).toHaveCount(0) + + await send(alice, "just got here") + + await expect(bubble(alice, "just got here")).toBeVisible() + await expect(bubble(alice, "just got here")).toHaveClass(/chat-bubble--user/) + await expect(composer(alice)).toHaveText("") + + // ...and it reaches the conversation he already has open, attributed to her + await expect(bubble(bob, "just got here")).toBeVisible() + await expect(bubble(bob, "just got here")).toContainText("Alice Anchor") +}) + +test("US-033 browse and search your conversations", async ({seed, as}) => { + const dave = makeTestUser("dave") + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + seedPerson(space, user.carol, "Carol Cutter") + seedPerson(space, dave, "Dave Davits") + enableDms(space, user.alice) + + space.dm(dave, [user.alice], "see you at the meetup", at(2, DAY)) + space.dm(user.carol, [user.alice], "thanks for the link", at(3, HOUR)) + space.dm(user.bob, [user.alice], "lunch tomorrow?", at(30, MINUTE)) + }) + + const page = await as(users.alice, "/chat") + + await expect(chatItems(page)).toHaveCount(3) + + const bobStamp = await stampLabel(page, scenario.at(30, MINUTE)) + const carolStamp = await stampLabel(page, scenario.at(3, HOUR)) + const daveStamp = await stampLabel(page, scenario.at(2, DAY)) + + // Most recently active first, each with a name, a preview and a timestamp + await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle") + await expect(chatItems(page).nth(0)).toContainText("lunch tomorrow?") + await expect(chatItems(page).nth(0)).toContainText(bobStamp) + await expect(chatItems(page).nth(1)).toContainText("Carol Cutter") + await expect(chatItems(page).nth(1)).toContainText("thanks for the link") + await expect(chatItems(page).nth(1)).toContainText(carolStamp) + await expect(chatItems(page).nth(2)).toContainText("Dave Davits") + await expect(chatItems(page).nth(2)).toContainText("see you at the meetup") + await expect(chatItems(page).nth(2)).toContainText(daveStamp) + + await chatFilter(page).fill("Bob Barnacle") + + await expect(chatItems(page)).toHaveCount(1) + await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle") + + await chatFilter(page).fill("") + + await expect(chatItems(page)).toHaveCount(3) +}) + +test("US-034 track and clear unread conversations", async ({seed, as}) => { + const dave = makeTestUser("dave") + + await seed(({relay, user, at}) => { + const space = relay("space") + + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + seedPerson(space, user.carol, "Carol Cutter") + seedPerson(space, dave, "Dave Davits") + enableDms(space, user.alice) + enableDms(space, user.bob) + + space.dm(user.carol, [user.alice], "did you see this?", at(3, HOUR)) + space.dm(dave, [user.alice], "ping from dave", at(2, HOUR)) + }) + + const alice = await as(users.alice, "/chat") + const bobChat = chatItems(alice).filter({hasText: "Bob Barnacle"}) + const carolChat = chatItems(alice).filter({hasText: "Carol Cutter"}) + const daveChat = chatItems(alice).filter({hasText: "Dave Davits"}) + + await expect(chatItems(alice)).toHaveCount(2) + await expect(unreadDots(carolChat)).toBeVisible() + await expect(unreadDots(daveChat)).toBeVisible() + + // Bob writes while she is looking at the list rather than at his conversation + const bob = await as(users.bob, chatPath(users.alice.pubkey)) + + await send(bob, "are you free later?") + + await expect(bobChat).toBeVisible() + await expect(unreadDots(bobChat)).toBeVisible() + + // Reading it and moving on clears it + await bobChat.click() + + await expect(bubble(alice, "are you free later?")).toBeVisible() + + await carolChat.click() + + await expect(bubble(alice, "did you see this?")).toBeVisible() + await expect(unreadDots(bobChat)).toHaveCount(0) + + // Mark all read clears what is left of them in one go + await alice.locator(".secondary-nav__header").getByRole("button").click() + await alice.getByRole("button", {name: "Mark all read"}).click() + + await expect(unreadDots(chatList(alice))).toHaveCount(0) + + // ...and they are still clear when she comes back + await alice.locator('.primary-nav [data-tip="Settings"]').click() + + await expect(alice).toHaveURL(pathPattern("/settings/profile")) + + await alice.locator('.primary-nav [data-tip="Messages"]').click() + + await expect(chatItems(alice)).toHaveCount(3) + await expect(unreadDots(chatList(alice))).toHaveCount(0) +}) + +test("US-035 reply to, edit, and react to a direct message", async ({seed, as}) => { + let his!: SeededRumor + let hers!: SeededRumor + + await seed(({relay, user, at}) => { + const space = relay("space") + + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + enableDms(space, user.alice) + enableDms(space, user.bob) + + his = space.dm(user.bob, [user.alice], "did you see the thing?", at(20, MINUTE)) + hers = space.dm(user.alice, [user.bob], "first attempt", at(10, MINUTE)) + }) + + // A touch context, which is what puts a message's actions behind named buttons rather than + // behind a row of icons in a hover popover. + const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}}) + const bob = await as(users.bob, chatPath(users.alice.pubkey)) + + await expect(message(alice, his.id)).toBeVisible() + await expect(message(alice, hers.id)).toBeVisible() + + // Reply: the preview appears above the composer, and closing it sends nothing + await openMessageMenu(alice, his.id) + await alice.getByRole("button", {name: "Send Reply"}).click() + + await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle") + await expect(composePreview(alice)).toContainText("did you see the thing?") + + await composePreview(alice).getByRole("button").last().click() + + await expect(composePreview(alice)).toHaveCount(0) + await expect(alice.locator(".chat-bubble")).toHaveCount(2) + + await openMessageMenu(alice, his.id) + await alice.getByRole("button", {name: "Send Reply"}).click() + + await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle") + + await send(alice, "yes I did") + + // The reply that gets sent quotes what it answers + await expect(bubble(alice, "yes I did")).toContainText("did you see the thing?") + await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?") + + // Edit: her own message is replaced in place for both of them rather than duplicated + await openMessageMenu(alice, hers.id) + + await expect(alice.getByRole("button", {name: "Edit Message"})).toBeVisible() + + await alice.getByRole("button", {name: "Edit Message"}).click() + + await expect(composePreview(alice)).toContainText("Editing message") + await expect(composer(alice)).toContainText("first attempt") + + await composer(alice).press("ControlOrMeta+a") + await send(alice, "second attempt") + + await expect(bubble(alice, "second attempt")).toBeVisible() + await expect(alice.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0) + await expect(bubble(bob, "second attempt")).toBeVisible() + await expect(bob.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0) + + // His, hers and her reply — the edit replaced one rather than adding a fourth + await expect(alice.locator(".chat-bubble")).toHaveCount(3) + await expect(bob.locator(".chat-bubble")).toHaveCount(3) + + // React, then take it back, on both sides of the conversation + await openMessageMenu(alice, his.id) + await alice.getByRole("button", {name: "Send Reaction"}).click() + + const picker = alice.locator("emoji-picker").filter({visible: true}) + + // A result's label is the emoji, its annotation and every shortcode joined together, so the + // annotation is what gets matched rather than the whole of it. + await picker.locator("input.search").fill("party popper") + await picker.getByRole("option", {name: /party popper/}).click() + + await expect(message(alice, his.id)).toContainText("🎉") + await expect(message(bob, his.id)).toContainText("🎉") + + await message(alice, his.id).getByRole("button", {name: "🎉"}).click() + + await expect(message(alice, his.id)).not.toContainText("🎉") + await expect(message(bob, his.id)).not.toContainText("🎉") + + // Up in an empty composer picks up the last thing she said, which is the reply + await expect(composer(alice)).toHaveText("") + + await composer(alice).press("ArrowUp") + + await expect(composePreview(alice)).toContainText("Editing message") + await expect(composer(alice)).toContainText("yes I did") +}) + +test("US-036 receive a new conversation live", async ({seed, as}) => { + await seed(({relay, user, at}) => { + const space = relay("space") + + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + seedPerson(space, user.carol, "Carol Cutter") + enableDms(space, user.alice) + enableDms(space, user.bob) + + // A conversation each of them already has. A list with something in it is how each page says + // its own end of the sync is up, before the one that has to arrive live is sent. + space.dm(user.carol, [user.alice], "see you monday", at(4, HOUR)) + space.dm(user.carol, [user.bob], "you too", at(4, HOUR)) + }) + + const alice = await as(users.alice, "/chat") + + await expect(chatItems(alice).filter({hasText: "see you monday"})).toBeVisible() + + const bob = await as(users.bob, "/chat") + + await expect(chatItems(bob).filter({hasText: "you too"})).toBeVisible() + + // He has never talked to her, so he identifies her by the code she gave him + await bob.getByRole("button", {name: "Start New Chat"}).click() + await bob.getByPlaceholder("Search for profiles...").fill(npubEncode(users.alice.pubkey)) + await bob.getByRole("button", {name: "Create Chat"}).click() + + await expect(bob).toHaveURL(pathPattern(chatPath(users.alice.pubkey))) + + await send(bob, "starting a chat with you") + + // Her list picks the conversation up on its own + const fromBob = chatItems(alice).filter({hasText: "Bob Barnacle"}) + + await expect(fromBob).toBeVisible() + + await fromBob.click() + + await expect(bubble(alice, "starting a chat with you")).toBeVisible() +}) diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts new file mode 100644 index 00000000..015048b8 --- /dev/null +++ b/e2e/specs/notifications.spec.ts @@ -0,0 +1,420 @@ +import {neventEncode, npubEncode} from "nostr-tools/nip19" +import {HOUR, MINUTE} from "@welshman/lib" +import {displayRelayUrl} from "@welshman/util" +import {MessagingRelayList, RelayList} from "@welshman/domain" +import type {Locator, Page} from "@playwright/test" +import {expect, roomPath, spacePath, test, users} from "../harness" +import type {SeededSpace, TestUser} from "../harness" + +// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id is ever +// read back here. +type Seeded = {readonly id: string} + +// A literal as a pattern, for a url that carries a query string alongside the path being matched, +// or a host whose dots would otherwise be wildcards. +const pattern = (literal: string) => new RegExp(literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + +// The unread indicator, which every surface renders the same way: a small primary-colored dot in +// the corner of the thing it belongs to. RelaySummary's "you're a member" check is the same shape +// at h-5 w-5, so the size is part of what says which one this is. +const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary") + +// The bell SpaceMenuRoomItem hangs off a room whose notification setting differs from its space's. +// An icon is a css mask built from a data url, so which bell it is can't be read out of the class +// list — but it only renders on a difference, and while the space itself is still notifying the +// only difference a room can have is a mute. +const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50") + +// The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only +// other absolutely-positioned dot in that button is the admin action-items one, which is opacity-0. +const mutedSpaceBell = (header: Locator) => header.locator("div.opacity-50") + +// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the +// label at one end and the control at the other. +const settingRow = (page: Page, label: string) => + page.locator("div.items-center.justify-between").filter({hasText: label}) + +// PrimaryNavItemSpace carries the relay's name as a tooltip rather than as an accessible name — its +// icon is a masked svg with no alt text — and it has an onclick, so PrimaryNavItem renders it as a +// button rather than a link. The name comes from nip-11; until that document lands the tooltip is +// the relay's host instead, and each tenant's toml names it after itself, so both start the same. +const spaceNavItem = (page: Page, name: string) => + page.locator(`.primary-nav [data-tip^="${name}"]`) + +// The space menu's header, the one button in the secondary nav carrying the relay's address. +const spaceMenu = (page: Page, url: string) => + page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))}) + +const roomLink = (page: Page, name: string) => + page.locator(".space-menu__scroll").getByRole("link", {name}) + +// The room's page bar carries a back arrow (display:none at this viewport), a search button and the +// detail button, in that order. +const openRoomDetail = (page: Page) => + page.locator('[data-component="PageBar"]').getByRole("button").last().click() + +// `.dialog` is on both the backdrop wrapper and the panel inside it, so the last match is the panel. +const dialog = (page: Page, title: string) => + page + .locator(".dialog") + .filter({has: page.getByRole("heading", {name: title, exact: true})}) + .last() + +const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]") + +const message = (page: Page, text: string) => page.locator(".room__item").filter({hasText: text}) + +const send = async (page: Page, content: string) => { + await composer(page).pressSequentially(content) + await composer(page).press("Enter") + await expect(message(page, content)).toBeVisible() +} + +// The page bar names the room, so waiting for it is what says the composer below now belongs to +// the room that was just opened rather than to the one being torn down. +const postTo = async (page: Page, room: string, content: string) => { + await roomLink(page, room).click() + await expect(page.locator('[data-component="PageBar"]')).toContainText(room) + await send(page, content) +} + +// Outbox routing resolves everything about a person through their relay list: settings are +// published to the relays it names, and a gift wrap only reaches somebody who has said where their +// messages go. +const seedRelays = (space: SeededSpace, user: TestUser) => { + space.event(user, () => + space + .kind(RelayList) + .writer() + .setReadUrls([space.url]) + .setWriteUrls([space.url]) + .renderTemplate(), + ) + space.event(user, () => + space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(), + ) +} + +test("US-103 see and clear unread indicators", 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", "random") + space.join(user.bob, "general", "random") + + // History bob wrote himself, so the space has something in it while nothing in it is unread + // for him — his own messages never raise an indicator. + space.message(user.bob, "general", "anyone around?", at(2, HOUR)) + }) + + const space = scenario.space("space") + const bob = await as(users.bob, "/spaces") + const alice = await as(users.alice, roomPath(space.url, "general")) + + const spaceRow = bob.getByRole("listitem").filter({hasText: space.url}) + const navItem = spaceNavItem(bob, space.name) + + await expect(spaceRow).toBeVisible() + await expect(navItem).toBeVisible() + await expect(unreadDot(spaceRow)).toHaveCount(0) + await expect(unreadDot(navItem)).toHaveCount(0) + + await send(alice, "the server is on fire") + + // Bob is sitting on the space list the whole time, so both dots arrive without a navigation + await expect(unreadDot(spaceRow)).toBeVisible() + await expect(unreadDot(navItem)).toBeVisible() + + // Inside the space, the dot points at the room the message landed in. A space's room list only + // exists in its own menu, so this is the one indicator the list above can't show. + await spaceRow.click() + + const general = roomLink(bob, "General") + const random = roomLink(bob, "Random") + + // Both rooms are on screen first, so a room with no dot is a room raising none rather than a + // row that never rendered + await expect(random).toBeVisible() + await expect(unreadDot(general)).toBeVisible() + await expect(unreadDot(random)).toHaveCount(0) + + await general.click() + + await expect(message(bob, "the server is on fire")).toBeVisible() + + // Reading it clears the room's dot... + await bob.getByRole("link", {name: "Space Details"}).click() + + await expect(general).toBeVisible() + await expect(unreadDot(general)).toHaveCount(0) + + // ...and it stays cleared back on the space list he started from + await bob.locator('.primary-nav a[href="/spaces"]').click() + + await expect(spaceRow).toBeVisible() + await expect(unreadDot(spaceRow)).toHaveCount(0) + await expect(unreadDot(navItem)).toHaveCount(0) +}) + +test("US-104 mute a room or a whole space", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.room("random", {name: "Random"}) + space.join(user.alice, "general", "random") + space.join(user.bob, "general", "random") + + seedRelays(space, user.alice) + }) + + const space = scenario.space("space") + + // Silencing a whole space is only offered once push notifications are on, so that is where alice + // has to start. + const alice = await as(users.alice, "/settings/alerts", { + context: {permissions: ["notifications"]}, + }) + + await settingRow(alice, "Enable push notifications").getByRole("checkbox").check() + await alice.getByRole("button", {name: "Save Changes"}).click() + + await expect(alice.getByRole("alert")).toContainText("Your settings have been saved!") + + await spaceNavItem(alice, space.name).click() + + const general = roomLink(alice, "General") + const random = roomLink(alice, "Random") + + await general.click() + + await expect(alice).toHaveURL(pattern(roomPath(space.url, "general"))) + + // Silence this one room from its detail panel + await openRoomDetail(alice) + + const roomNotifications = settingRow(alice, "Notifications").getByRole("checkbox") + + await expect(roomNotifications).toBeChecked() + + await roomNotifications.uncheck() + await alice.getByRole("button", {name: "Go back"}).click() + + await expect(random).toBeVisible() + await expect(mutedRoomBell(general)).toBeVisible() + await expect(mutedRoomBell(random)).toHaveCount(0) + + // Step out of both rooms, so either one is free to raise a dot + await alice.getByRole("link", {name: "Space Details"}).click() + + const bob = await as(users.bob, roomPath(space.url, "general")) + + await send(bob, "deploy is broken") + await postTo(bob, "Random", "lunch?") + + // The sibling room's message raises a dot, the muted room's does not + await expect(unreadDot(random)).toBeVisible() + await expect(unreadDot(general)).toHaveCount(0) + + // The muted room's message did arrive, it just raised nothing + await general.click() + + await expect(message(alice, "deploy is broken")).toBeVisible() + + // Turning the room back on restores its indicator for what comes next + await openRoomDetail(alice) + await roomNotifications.check() + await alice.getByRole("button", {name: "Go back"}).click() + await alice.getByRole("link", {name: "Space Details"}).click() + + await expect(general).toBeVisible() + await expect(mutedRoomBell(general)).toHaveCount(0) + + await postTo(bob, "General", "and now the build too") + + // Both rooms are now showing a dot, so silencing the space is what takes them down + await expect(unreadDot(general)).toBeVisible() + await expect(unreadDot(random)).toBeVisible() + + const menu = spaceMenu(alice, space.url) + + await menu.click() + await alice.getByRole("button", {name: "Turn off notifications"}).click() + + await expect(mutedSpaceBell(menu)).toBeVisible() + await expect(unreadDot(general)).toHaveCount(0) + await expect(unreadDot(random)).toHaveCount(0) + + await postTo(bob, "General", "still broken") + await postTo(bob, "Random", "still hungry") + + await expect(unreadDot(general)).toHaveCount(0) + await expect(unreadDot(random)).toHaveCount(0) + + // Reopening the menu shows the label the mute flipped, and turning it back on brings the + // indicators back — which is also what proves those messages reached alice at all + await menu.click() + + const turnOn = alice.getByRole("button", {name: "Turn on notifications"}) + + await expect(turnOn).toBeVisible() + await turnOn.click() + + await expect(mutedSpaceBell(menu)).toHaveCount(0) + await expect(unreadDot(general)).toBeVisible() + await expect(unreadDot(random)).toBeVisible() +}) + +test("US-105 land on the home page", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const space = scenario.space("space") + + // A build that names a platform space sends /home straight into it + const platform = await as(users.alice, "/home", {env: {VITE_PLATFORM_RELAYS: space.url}}) + + await expect(platform).toHaveURL(pattern(spacePath(space.url))) + + // With none configured, /home is a welcome screen with two ways out + const page = await as(users.alice, "/home") + + const addSpace = page.getByRole("link", {name: "Add a space"}) + const startConversation = page.getByRole("button", {name: "Start a conversation"}) + + await expect(addSpace).toBeVisible() + await expect(startConversation).toBeVisible() + + await addSpace.click() + + await expect(page).toHaveURL(/\/spaces$/) + + await page.goBack() + + await expect(page).toHaveURL(/\/home$/) + + await startConversation.click() + + await expect(page).toHaveURL(/\/chat$/) +}) + +test("US-106 share text into the app", async ({seed, as}) => { + const shared = "the offsite is moving to the 14th" + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.room("watercooler", {name: "Watercooler"}) + space.join(user.alice, "general", "watercooler") + space.join(user.bob, "general", "watercooler") + space.profile(user.bob, {name: "Bob Barker"}) + + seedRelays(space, user.alice) + seedRelays(space, user.bob) + + space.dm(user.bob, [user.alice], "are you around?", at(20, MINUTE)) + }) + + const space = scenario.space("space") + + // A client only unwraps direct messages once its owner has opened chat, so a conversation the + // share dialog can offer is one alice has already seen. + const page = await as(users.alice, "/chat") + + await expect(page.getByText("are you around?").first()).toBeVisible() + + // The text arrives in the query string rather than through a native share intent, which is the + // same thing src/routes/share reads either way. + await page.goto(`/share?text=${encodeURIComponent(shared)}`) + + const share = dialog(page, "Share") + + await expect(page.getByText("Where would you like to share this?")).toBeVisible() + await expect(share.getByRole("button", {name: "General"})).toBeVisible() + await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible() + await expect(share.getByRole("button", {name: "Bob Barker"})).toBeVisible() + + const search = page.getByPlaceholder("Search rooms and conversations...") + + await search.fill("Watercooler") + + await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible() + await expect(share.getByRole("button", {name: "General"})).toHaveCount(0) + await expect(share.getByRole("button", {name: "Bob Barker"})).toHaveCount(0) + + await search.fill("") + await share.getByRole("button", {name: "General"}).click() + await share.getByRole("button", {name: "Share", exact: true}).click() + + await expect(page).toHaveURL(pattern(roomPath(space.url, "general"))) + await expect(composer(page)).toHaveText(shared) +}) + +test("US-107 open a nostr link", async ({seed, as}) => { + let posted!: Seeded + + 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.profile(user.bob, {name: "Bob Barker"}) + + seedRelays(space, user.bob) + + posted = space.message(user.bob, "general", "the meeting moved to friday", at(20, MINUTE)) + }) + + const space = scenario.space("space") + const page = await as(users.alice, roomPath(space.url, "general"), { + context: {permissions: ["clipboard-read", "clipboard-write"]}, + }) + + const item = page.locator(`[data-event="${posted.id}"]`) + + await expect(item).toBeVisible() + await item.hover() + await item.locator(".room__item-actions button").last().click() + await page.getByRole("button", {name: "Message Details"}).click() + + await expect(page.getByRole("heading", {name: "Event Details"})).toBeVisible() + + // EventInfo renders the link on mount rather than during setup, so wait for it rather than + // copying an empty field + const eventLink = settingRow(page, "Event Link") + + await expect(eventLink.getByRole("textbox")).toHaveValue(/^nostr:nevent1/) + await eventLink.getByRole("button").click() + + await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") + + const link = await page.evaluate(() => navigator.clipboard.readText()) + + expect(link).toMatch(/^nostr:nevent1/) + + await page.goto("/" + link.replace(/^nostr:/, "")) + + await expect(page).toHaveURL(pattern(roomPath(space.url, "general") + "?at=")) + await expect(message(page, "the meeting moved to friday")).toBeVisible() + + // A person's npub resolves the same way + const npub = npubEncode(users.bob.pubkey) + + await page.goto(`/${npub}`) + + await expect(page).toHaveURL(pattern(`/people/${npub}`)) + await expect(page.getByRole("heading", {name: "Bob Barker"})).toBeVisible() + + // Nothing holds this event, so the link falls back to the app's home rather than a dead page + await page.goto(`/${neventEncode({id: "f".repeat(64), relays: [space.url]})}`) + + await expect(page).toHaveURL(/\/home$/) +}) diff --git a/e2e/specs/onboarding.spec.ts b/e2e/specs/onboarding.spec.ts new file mode 100644 index 00000000..aaa97c9e --- /dev/null +++ b/e2e/specs/onboarding.spec.ts @@ -0,0 +1,413 @@ +import {readFile} from "node:fs/promises" +import type {Page} from "@playwright/test" +import {npubEncode, nsecEncode} from "nostr-tools/nip19" +import {encrypt} from "nostr-tools/nip49" +import {hexToBytes} from "@welshman/lib" +import {expect, roomPath, spacePath, test, users} from "../harness" +import type {TestUser} from "../harness" + +// The sign-in gate AppContainer renders in place of the app for anyone without a session. +const gate = (page: Page) => page.getByRole("heading", {name: "Welcome to Flotilla!"}) + +const nsecFor = (user: TestUser) => nsecEncode(hexToBytes(user.secret)) + +// Landing → "Log in" → "Log in with Key" → paste → submit, which is the only way to watch a +// session come into existence: a session injected by `as()` is re-applied on every navigation. +const logInWithKey = async (page: Page, key: string) => { + await page.getByRole("button", {name: "Log in"}).click() + await page.getByRole("button", {name: "Log in with Key"}).click() + await page.getByPlaceholder("nsec1...").fill(key) + await page.getByRole("button", {name: "Log in", exact: true}).click() +} + +// The nav's settings link carries its label as a tooltip rather than as an accessible name — its +// icon is a masked svg with no alt text — so it is addressed by where it goes. +const openSettings = (page: Page) => + page.locator('.primary-nav a[href="/settings/profile"]').click() + +test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => { + const scenario = await seed(({relay}) => { + relay("space").room("general", {name: "General"}) + }) + + const {url} = scenario.space("space") + + // A room url rather than the root: the gate stands in front of the whole app, so where the + // visitor landed makes no difference. + const page = await visit(roomPath(url, "general")) + + await expect(gate(page)).toBeVisible() + await expect(page.getByRole("button", {name: "Log in"})).toBeVisible() + await expect(page.getByRole("button", {name: "Create an account"})).toBeVisible() + await expect(page.getByRole("link", {name: "Terms of Service"})).toHaveAttribute( + "href", + "https://flotilla.social/terms", + ) + await expect(page.getByRole("link", {name: "Privacy Policy"})).toHaveAttribute( + "href", + "https://flotilla.social/privacy", + ) + + await page.keyboard.press("Escape") + + await expect(gate(page)).toBeVisible() + + // The backdrop, clicked in its top left corner so the dialog itself isn't what gets hit. + await page.getByRole("button", {name: "Close dialog"}).click({position: {x: 4, y: 4}}) + + await expect(gate(page)).toBeVisible() + + await page.getByRole("button", {name: "Log in"}).click() + + await expect(page.getByRole("heading", {name: "Log in with Nostr"})).toBeVisible() + + await page.goBack() + + await expect(gate(page)).toBeVisible() + + await page.getByRole("button", {name: "Create an account"}).click() + + await expect(page.getByRole("heading", {name: "Join Flotilla"})).toBeVisible() +}) + +test("US-002 sign up by generating a new key", async ({seed, visit}) => { + const scenario = await seed(({relay}) => { + relay("space").room("general", {name: "General"}) + }) + + const {url} = scenario.space("space") + const page = await visit() + + await page.getByRole("button", {name: "Create an account"}).click() + await page.getByRole("button", {name: "Generate a key"}).click() + + await page.locator('input[type="text"]').fill("Nova Tester") + await page.getByRole("button", {name: "Create Account"}).click() + + await expect(page.getByRole("heading", {name: "Your Keys are Ready!"})).toBeVisible() + + const continueButton = page.getByRole("button", {name: "Continue"}) + const password = page.locator('input[type="password"]') + + await expect(continueButton).toBeDisabled() + + await page.getByRole("button", {name: "I want to download an encrypted version"}).click() + await password.fill("hunter2") + await page.getByRole("button", {name: "Download my key"}).click() + + await expect(page.getByRole("alert")).toContainText( + "Your password must be at least 12 characters long.", + ) + await expect(continueButton).toBeDisabled() + + await password.fill("correct horse battery staple") + + const download = page.waitForEvent("download") + + await page.getByRole("button", {name: "Download my key"}).click() + + const contents = await readFile(await (await download).path(), "utf8") + + expect(contents).toContain("ncryptsec1") + expect(contents).not.toContain("nsec1") + + await expect(continueButton).toBeEnabled() + await continueButton.click() + + await expect(page.getByRole("heading", {name: "You're all set!"})).toBeVisible() + + await page.getByRole("button", {name: "Go to Dashboard"}).click() + + await expect(page).toHaveURL(/\/home$/) + await expect(page.locator(".primary-nav")).toBeVisible() + await expect(page.getByRole("heading", {name: "You're all set!"})).toHaveCount(0) + await expect(gate(page)).toHaveCount(0) + + // A space's nav item is a button rather than a link — PrimaryNavItemSpace passes an onclick, and + // PrimaryNavItem renders a Button whenever it has one — so it is addressed by the tooltip it + // carries, which is the relay's nip-11 name, and clicking it is what says which space it is. + const spaceItem = page.locator('.primary-nav [data-tip="space"]') + + await expect(spaceItem).toBeVisible() + await spaceItem.click() + await expect(page).toHaveURL(new RegExp(spacePath(url))) + + await openSettings(page) + + await expect(page.getByText("Nova Tester")).toBeVisible() +}) + +test("US-003 log in with an existing private key", async ({seed, visit}) => { + await seed(({relay}) => { + relay("space").room("general", {name: "General"}) + }) + + const aliceNpub = npubEncode(users.alice.pubkey) + const password = "a very good password" + const ncryptsec = encrypt(hexToBytes(users.alice.secret), password) + + const withNsec = await visit() + + await logInWithKey(withNsec, nsecFor(users.alice)) + await expect(withNsec.locator(".primary-nav")).toBeVisible() + await openSettings(withNsec) + await expect(withNsec.getByRole("textbox")).toHaveValue(aliceNpub) + + const withHex = await visit() + + await logInWithKey(withHex, users.alice.secret) + await expect(withHex.locator(".primary-nav")).toBeVisible() + await openSettings(withHex) + await expect(withHex.getByRole("textbox")).toHaveValue(aliceNpub) + + const withNcryptsec = await visit() + + await withNcryptsec.getByRole("button", {name: "Log in"}).click() + await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click() + + const key = withNcryptsec.getByPlaceholder("nsec1...") + const submit = withNcryptsec.getByRole("button", {name: "Log in", exact: true}) + + await key.fill("this is not a key") + await expect(submit).toBeDisabled() + + await key.fill(ncryptsec) + await withNcryptsec.getByPlaceholder("Your password").fill("not the password") + await submit.click() + + await expect(withNcryptsec.getByRole("alert")).toContainText( + "Failed to decrypt key. Please check your password.", + ) + await expect(withNcryptsec.getByRole("heading", {name: "Log In with Key"})).toBeVisible() + await expect(withNcryptsec.locator(".primary-nav")).toHaveCount(0) + + await withNcryptsec.getByPlaceholder("Your password").fill(password) + await submit.click() + + await expect(withNcryptsec.locator(".primary-nav")).toBeVisible() + await openSettings(withNcryptsec) + await expect(withNcryptsec.getByRole("textbox")).toHaveValue(aliceNpub) + + // A second browser context with its own storage: bob's session is his own, not a second view + // of alice's. + const asBob = await visit() + + await logInWithKey(asBob, nsecFor(users.bob)) + await openSettings(asBob) + await expect(asBob.getByRole("textbox")).toHaveValue(npubEncode(users.bob.pubkey)) +}) + +test("US-004 log in with a browser extension", async ({seed, visit}) => { + await seed(({relay}) => { + relay("space").room("general", {name: "General"}) + }) + + const page = await visit("/", {nip07: users.alice}) + + await page.getByRole("button", {name: "Log in"}).click() + await page.getByRole("button", {name: "Log in with Extension"}).click() + + await expect(page.locator(".primary-nav")).toBeVisible() + + await openSettings(page) + + await expect(page.getByRole("textbox")).toHaveValue(npubEncode(users.alice.pubkey)) + await expect(page.locator('input[type="password"]')).toHaveCount(0) + + const withoutExtension = await visit() + + await withoutExtension.getByRole("button", {name: "Log in"}).click() + + await expect( + withoutExtension.getByRole("button", {name: "Log in with Remote Signer"}), + ).toBeVisible() + await expect(withoutExtension.getByRole("button", {name: "Log in with Extension"})).toHaveCount(0) +}) + +test("US-005 log in with a remote signer", async ({seed, visit}) => { + await seed(({relay}) => { + relay("space").room("general", {name: "General"}) + }) + + const page = await visit() + + await page.getByRole("button", {name: "Log in"}).click() + await page.getByRole("button", {name: "Log in with Remote Signer"}).click() + + const bunker = page.getByPlaceholder("bunker://") + const next = page.getByRole("button", {name: "Next"}) + + // The relay named here belongs to no scenario, so a connection attempt would be recorded as a + // leak and fail this test rather than passing unnoticed. + await bunker.fill("bunker://not-a-signer-pubkey?relay=wss://nowhere.test/") + await next.click() + + await expect(page.getByRole("alert")).toContainText( + "Sorry, it looks like that's an invalid bunker link.", + ) + + await bunker.fill(`bunker://${users.carol.pubkey}`) + await next.click() + + await expect(page.getByRole("alert")).toContainText( + "That bunker link does not include any relays.", + ) + + await page.getByRole("button", {name: "Log in with a QR code instead"}).click() + + await expect(page.getByText("Scan with your signer to log in, or click to copy.")).toBeVisible() + + await page.getByRole("button", {name: "Go back"}).click() + + await expect(bunker).toBeVisible() +}) + +test("US-006 stay logged in, and log out deliberately", async ({seed, visit}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const npub = npubEncode(users.alice.pubkey) + const page = await visit() + + await logInWithKey(page, nsecFor(users.alice)) + + await expect(page.locator(".primary-nav")).toBeVisible() + + await openSettings(page) + + await expect(page.getByRole("textbox")).toHaveValue(npub) + + await page.reload() + + await expect(page.locator(".primary-nav")).toBeVisible() + await expect(gate(page)).toHaveCount(0) + await expect(page.getByRole("textbox")).toHaveValue(npub) + + await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click() + + await expect(page.getByRole("heading", {name: /Are you sure you want/})).toBeVisible() + await expect(page.getByText("Your local database will be cleared.")).toBeVisible() + + await page.getByRole("button", {name: "Go back"}).click() + + await expect(page.locator(".primary-nav")).toBeVisible() + await expect(page.getByRole("textbox")).toHaveValue(npub) + + await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click() + await page.locator("form").getByRole("button", {name: "Log Out"}).click() + + await expect(gate(page)).toBeVisible() +}) + +test("US-007 inspect your keys and signer status", async ({seed, as, visit}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const npub = npubEncode(users.alice.pubkey) + const page = await as(users.alice, "/settings/profile", { + context: {permissions: ["clipboard-read", "clipboard-write"]}, + }) + + const readClipboard = () => page.evaluate(() => navigator.clipboard.readText()) + + const npubInput = page.getByRole("textbox") + + await expect(npubInput).toHaveValue(npub) + await expect(npubInput).toHaveJSProperty("readOnly", true) + + await page.locator("label.input").filter({has: npubInput}).getByRole("button").click() + + await expect.poll(readClipboard).toBe(npub) + + const secretInput = page.locator('input[type="password"]') + + await expect(secretInput).toHaveValue(users.alice.secret) + + await page.locator("label.input").filter({has: secretInput}).getByRole("button").click() + + await expect.poll(readClipboard).toBe(nsecFor(users.alice)) + + const signerStatus = page.locator(".card").filter({hasText: "Signer Status"}).last() + + await expect(signerStatus).toContainText("Logged in with private key") + await expect(signerStatus).toContainText("Ok") + + const succeeded = async () => + Number(/(\d+) requests succeeded/.exec(await signerStatus.innerText())?.[1]) + + const before = await succeeded() + + await page.locator(".card").first().getByRole("button").click() + await page.locator('input[type="text"]').first().fill("Alice Anderson") + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Your profile has been updated!") + await expect.poll(succeeded).toBeGreaterThan(before) + + // An extension session holds no key for the app to show. + const extension = await visit("/", {nip07: users.bob}) + + await extension.getByRole("button", {name: "Log in"}).click() + await extension.getByRole("button", {name: "Log in with Extension"}).click() + + await expect(extension.locator(".primary-nav")).toBeVisible() + + await openSettings(extension) + + await expect(extension.getByRole("textbox")).toHaveValue(npubEncode(users.bob.pubkey)) + await expect(extension.locator('input[type="password"]')).toHaveCount(0) + await expect(extension.locator(".card").filter({hasText: "Signer Status"}).last()).toContainText( + "Logged in with browser extension", + ) +}) + +test("US-008 delete your nostr account", async ({seed, as, visit}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.profile(user.alice, {name: "Alice Anderson"}) + }) + + const alice = await visit() + + await logInWithKey(alice, nsecFor(users.alice)) + await openSettings(alice) + + await alice.locator(".card").filter({hasText: "Advanced"}).getByRole("button").click() + await alice.getByRole("button", {name: "Delete your profile"}).click() + + await expect(alice.getByRole("heading", {name: "Delete your account"})).toBeVisible() + + const confirm = alice.getByRole("button", {name: "Confirm"}) + const phrase = alice.locator('input[type="text"]') + + await expect(confirm).toBeDisabled() + + await phrase.fill("permanently delete my") + + await expect(confirm).toBeDisabled() + + await phrase.fill("permanently delete my nostr account") + + await expect(confirm).toBeEnabled() + + await confirm.click() + + await expect(alice.getByRole("progressbar")).toBeVisible() + await expect(gate(alice)).toBeVisible() + + const bob = await as(users.bob, `/people/${npubEncode(users.alice.pubkey)}`) + + await expect(bob.getByRole("heading", {name: "[deleted]"})).toBeVisible() +}) diff --git a/e2e/specs/people.spec.ts b/e2e/specs/people.spec.ts new file mode 100644 index 00000000..92b7be27 --- /dev/null +++ b/e2e/specs/people.spec.ts @@ -0,0 +1,771 @@ +import {createHash} from "node:crypto" +import type {Locator, Page} from "@playwright/test" +import {nprofileEncode, npubEncode} from "nostr-tools/nip19" +import {HOUR, MINUTE, MONTH} from "@welshman/lib" +import {NOTE, makeEvent} from "@welshman/util" +import type {SignedEvent} from "@welshman/util" +import {FollowList, Note, PinList, Profile, RelayList} from "@welshman/domain" +import {expect, makeTestUser, mockBlossom, spacePath, test, users} from "../harness" +import type {SeededSpace, TestUser} from "../harness" + +// A handle to a seeded event, which only reads once seed() has drained its queue. +type Seeded = {readonly id: string; readonly event: SignedEvent} + +// The profile page keeps its Reputation and Spaces panels in a sidebar that only exists above +// tailwind's xl breakpoint, and the default 1280 viewport sits exactly on it. +const DESKTOP = {viewport: {width: 1440, height: 900}} + +const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]} + +// Where an upload lands. Both the avatar picker and the banner picker call into uploadFile with no +// relay of their own, so the blossom probe is skipped and VITE_DEFAULT_BLOSSOM_SERVERS is used. +const BLOSSOM_ORIGIN = "https://blossom.primal.net" + +// A 1x1 gif and a 1x1 webp. compressFileForUpload passes both formats through untouched rather +// than re-encoding them through a canvas, so the bytes the server hashes are the bytes chosen +// here and the url an upload resolves to is predictable from node. +const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64") +const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", "base64") + +// uploadFile appends the extension when the descriptor's url carries none, which the mock's never +// does. +const uploadedUrl = (body: Buffer, extension: string) => + `${BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}` + +const profilePath = (user: TestUser) => `/people/${npubEncode(user.pubkey)}` + +// displayPubkey in @welshman/domain: the npub with its middle taken out. +const shortNpub = (user: TestUser) => { + const npub = npubEncode(user.pubkey) + + return npub.slice(0, 8) + "…" + npub.slice(-5) +} + +// The page's own region, so an assertion about an avatar isn't satisfied by the copy of it the +// nav renders. +const pageContent = (page: Page) => page.locator(".page__content") + +// ProfileTrust and ProfileSharedSpaces are rendered twice — stacked for narrow viewports, and in +// the sidebar — so anything said about them is scoped to the copy that is actually on screen. +const sidebar = (page: Page) => page.locator("aside") + +// A modal is a `.dialog` overlay wrapping a `.dialog` card, and the card is the one with the +// content in it. +const dialog = (page: Page) => page.locator(".dialog").last() + +const notes = (page: Page) => pageContent(page).locator(".cv.card") + +const readClipboard = (page: Page) => page.evaluate(() => navigator.clipboard.readText()) + +// The "..." menu on a profile header, which is the only ghost circle button the page renders. +const profileMenu = (page: Page) => page.locator("button.button-circle.button-ghost") + +// PeopleItem renders its link twice, once for each breakpoint; the wide one comes first. +const viewProfile = (card: Locator) => card.getByRole("link", {name: "View Profile"}).first() + +// Outbox routing resolves everything about a person through their relay list, so a seeded fixture +// is only loadable by somebody else once its author has one. +const seedRelayList = (space: SeededSpace, user: TestUser) => + space.event(user, () => + space + .kind(RelayList) + .writer() + .setReadUrls([space.url]) + .setWriteUrls([space.url]) + .renderTemplate(), + ) + +test("US-074 find a person", async ({seed, as}) => { + // Sixty of them: the people list starts at ten and adds another ten every second until the page + // is taller than the scroller's threshold — and it keeps counting while the profiles are still + // on their way, so by the time the first ones render the limit is already well past ten. + // "Scrolling loads more" is only a fact when there are far more matches than that first fill + // can reach. + const searchers = Array.from({length: 60}, (_, i) => makeTestUser(`searcher-${i}`)) + const searcherName = (i: number) => `Searcher ${String(i).padStart(2, "0")}` + const searcherAvatar = (i: number) => `https://images.test/searcher-${i}.png` + const about = [ + "Deck crew on the northern run.", + "Splices rope, keeps the log, argues about knots.", + "Ask about the time the compass froze solid.", + "Answers to a whistle and to nothing else.", + ].join("\n") + + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + + // Bob is the one person whose name shares no token with the others, so narrowing the term is + // visibly a filter rather than a reordering. + space.profile(user.bob, { + name: "Bob Barnacle", + about: "Dockside cook and keeper of the ship's cat.", + }) + + seedRelayList(space, user.bob) + + for (const [i, searcher] of searchers.entries()) { + space.member(searcher) + space.profile(searcher, {name: searcherName(i), about, picture: searcherAvatar(i)}) + } + }) + + const page = await as(users.alice, "/people", {context: DESKTOP}) + const term = page.getByPlaceholder("Search for people...") + const cards = page.locator(".card.card-interactive") + + await term.fill("Searcher") + + // Which of them ranks first is fuse's business, so a result is described by what every + // result carries rather than by which one it turned out to be. + const first = cards.first() + + await expect(first).toBeVisible() + await expect(first.locator('img[src^="https://images.test/searcher-"]')).toBeVisible() + await expect(first).toContainText(/Searcher \d\d/) + await expect(first).toContainText("Deck crew on the northern run.") + + await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(10) + + const shown = await cards.count() + + expect(shown).toBeLessThan(searchers.length) + + await cards.last().scrollIntoViewIfNeeded() + + await expect.poll(() => cards.count()).toBeGreaterThan(shown) + + // Narrowing the term filters the list rather than reordering it. + await term.fill("Barnacle") + + const bobCard = cards.filter({hasText: "Bob Barnacle"}) + + await expect(bobCard).toHaveCount(1) + await expect(cards.filter({hasText: /Searcher \d\d/})).toHaveCount(0) + + await viewProfile(bobCard).click() + + await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`)) + await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible() +}) + +test("US-075 view someone's profile", async ({seed, as}) => { + const avatar = "https://images.test/bob-avatar.png" + const banner = "https://images.test/bob-banner.png" + + const scenario = await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + + // A second space alice does not belong to, so "Member" is a claim about the overlap rather + // than about every space bob is in. + other.room("lounge", {name: "Lounge"}) + other.join(user.bob, "lounge") + + space.event(user.bob, () => + space + .kind(Profile) + .writer() + .setName("Bob Barnacle") + .setAbout("Deckhand, dockside cook, and keeper of the ship's cat.") + .setPicture(avatar) + .setBanner(banner) + .setWebsite("bobbarnacle.example") + .renderTemplate(), + ) + + seedRelayList(space, user.bob) + + // Carol belongs to no space at all, which is what the panel's empty state is about. + space.member(user.carol) + space.profile(user.carol, {name: "Carol Cutter"}) + seedRelayList(space, user.carol) + }) + + const space = scenario.space("space") + const other = scenario.space("other") + const page = await as(users.alice, profilePath(users.bob), {context: CLIPBOARD}) + const region = pageContent(page) + + await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible() + // The header renders the avatar twice, one size per breakpoint; the wide one is second. + await expect(region.locator(`img[src="${avatar}"]`).last()).toBeVisible() + await expect(region.locator(`img[src="${banner}"]`)).toBeVisible() + await expect(region.getByText("Deckhand, dockside cook")).toBeVisible() + + const npub = region.getByText(shortNpub(users.bob)) + + await expect(npub).toBeVisible() + + await npub.locator("xpath=following-sibling::button").click() + + await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") + await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey)) + + await expect(region.getByRole("link", {name: "bobbarnacle.example"})).toHaveAttribute( + "href", + "https://bobbarnacle.example", + ) + + const spaces = sidebar(page).locator(".card.card-sm").filter({hasText: "Spaces"}) + const spaceLink = spaces.locator(`a[href="${spacePath(space.url)}"]`) + const otherLink = spaces.locator(`a[href="${spacePath(other.url)}"]`) + + await expect(spaces.locator(".badge").first()).toHaveText("2") + await expect(spaceLink).toContainText("Member") + await expect(otherLink).toBeVisible() + await expect(otherLink.locator(".badge").filter({hasText: "Member"})).toHaveCount(0) + + await spaceLink.click() + + await expect(page).toHaveURL(new RegExp(spacePath(space.url))) + + await page.goto(profilePath(users.carol)) + + await expect(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible() + await expect(sidebar(page).getByText("No spaces found.")).toBeVisible() +}) + +test("US-076 follow and unfollow", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + seedRelayList(space, user.alice) + seedRelayList(space, user.bob) + }) + + const page = await as(users.alice, profilePath(users.bob), {context: DESKTOP}) + const follow = page.getByRole("button", {name: "Follow", exact: true}) + const unfollow = page.getByRole("button", {name: "Unfollow", exact: true}) + + await expect(follow).toBeVisible() + + await follow.click() + + // The same page, still on the same url: the label flips where it stands. + await expect(unfollow).toBeVisible() + await expect(follow).toHaveCount(0) + await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`)) + + await unfollow.click() + + await expect(follow).toBeVisible() + await expect(unfollow).toHaveCount(0) +}) + +test("US-077 see web-of-trust standing build up", async ({seed, as}) => { + const carolAvatar = "https://images.test/carol-avatar.png" + + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.join(user.carol, "general") + space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."}) + space.profile(user.carol, {name: "Carol Cutter", about: "Sailmaker.", picture: carolAvatar}) + seedRelayList(space, user.alice) + seedRelayList(space, user.bob) + seedRelayList(space, user.carol) + + // Carol already follows bob, so alice following carol is the one thing that has to happen + // through the ui for his standing to move. + space.event(user.carol, () => + space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(), + ) + }) + + // Every hop here is a link click rather than a navigation, so the follow alice publishes stays + // in the client that published it. + const page = await as(users.alice, "/people", {context: DESKTOP}) + const term = page.getByPlaceholder("Search for people...") + const cards = page.locator(".card.card-interactive") + const bobCard = cards.filter({hasText: "Bob Barnacle"}) + const carolCard = cards.filter({hasText: "Carol Cutter"}) + const reputation = () => sidebar(page).locator(".card.card-sm").filter({hasText: "Reputation"}) + + // The trust ring beside a name: a circle whose dash offset shrinks as the ring fills in. + const ring = async () => + Number(await bobCard.locator("circle.wot-highlight").getAttribute("stroke-dashoffset")) + + await term.fill("Barnacle") + + await expect(bobCard).toHaveCount(1) + await expect(bobCard.locator("circle.wot-highlight")).toBeAttached() + + const emptyRing = await ring() + + await viewProfile(bobCard).click() + + await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`)) + // Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100", which are + // exactly the readings this is supposed to rule out. + await expect(reputation()).toContainText(/\b0 \/ 100\b/) + await expect(reputation()).toContainText("This user is not well known in your network.") + + await page.locator('.primary-nav a[href="/people"]').click() + await term.fill("Cutter") + + await expect(carolCard).toHaveCount(1) + + await viewProfile(carolCard).click() + + await expect(page).toHaveURL(new RegExp(`${profilePath(users.carol)}$`)) + + await page.getByRole("button", {name: "Follow", exact: true}).click() + + await expect(page.getByRole("button", {name: "Unfollow", exact: true})).toBeVisible() + + await page.locator('.primary-nav a[href="/people"]').click() + await term.fill("Barnacle") + + await expect(bobCard).toHaveCount(1) + await expect.poll(ring).toBeLessThan(emptyRing) + + await viewProfile(bobCard).click() + + await expect(reputation()).toContainText("Followed by 1+ people in your network.") + await expect(reputation()).toContainText("1 person you follow also follow Bob Barnacle.") + await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible() +}) + +test("US-078 edit your own profile", async ({seed, as}) => { + const oldAvatar = "https://images.test/alice-avatar.png" + const newAvatar = uploadedUrl(WEBP, "webp") + const newBanner = uploadedUrl(GIF, "gif") + + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.event(user.alice, () => + space + .kind(Profile) + .writer() + .setName("Alice Anderson") + .setAbout("Ship's navigator.") + .setPicture(oldAvatar) + .setNip05("alice@flotilla.test") + .renderTemplate(), + ) + seedRelayList(space, user.alice) + }) + + const page = await as(users.alice, profilePath(users.alice), {context: DESKTOP}) + + await mockBlossom(page.context(), {server: BLOSSOM_ORIGIN}) + + const region = pageContent(page) + const edit = page.getByRole("button", {name: "Edit profile"}) + + await expect(edit).toBeVisible() + await expect(page.getByRole("button", {name: "Follow", exact: true})).toHaveCount(0) + // Exact, or this would match the nav's "Messages". + await expect(page.getByRole("button", {name: "Message", exact: true})).toHaveCount(0) + + await edit.click() + + // ProfileEditForm's text inputs, in the order it renders them: nickname, website, nostr address. + const form = page.locator("form").first() + const nickname = form.locator('input[type="text"]').first() + const about = form.locator("textarea") + const nostrAddress = form.locator('input[type="text"]').nth(2) + const picker = page.locator('label[aria-label="Drag and drop files here."]') + + await expect(nickname).toHaveValue("Alice Anderson") + await expect(about).toHaveValue("Ship's navigator.") + await expect(nostrAddress).toHaveValue("alice@flotilla.test") + await expect.poll(() => picker.getAttribute("style")).toContain(oldAvatar) + + await nickname.fill("Alice Anchor") + await about.fill("Ship's navigator and part-time cartographer.") + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Your profile has been updated!") + await expect(page.getByRole("heading", {name: "Alice Anchor"})).toBeVisible() + await expect(region.getByText("part-time cartographer")).toBeVisible() + + await edit.click() + + const avatarChooser = page.waitForEvent("filechooser") + + await picker.click() + await ( + await avatarChooser + ).setFiles({ + name: "avatar.webp", + mimeType: "image/webp", + buffer: WEBP, + }) + + await expect.poll(() => picker.getAttribute("style")).toContain(newAvatar) + + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(region.locator(`img[src="${newAvatar}"]`).last()).toBeVisible() + + const bannerChooser = page.waitForEvent("filechooser") + + await page.getByRole("button", {name: "Change banner"}).click() + await (await bannerChooser).setFiles({name: "banner.gif", mimeType: "image/gif", buffer: GIF}) + + await expect(page.getByRole("alert")).toContainText("Banner updated.") + await expect(region.locator(`img[src="${newBanner}"]`)).toBeVisible() +}) + +test("US-079 read a person's notes", async ({seed, as}) => { + const avatar = "https://images.test/alice-avatar.png" + + let carolNote!: Seeded + let pinned!: Seeded + + 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.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anderson", picture: avatar}) + space.profile(user.carol, {name: "Carol Cutter"}) + seedRelayList(space, user.alice) + seedRelayList(space, user.carol) + + carolNote = space.event( + user.carol, + makeEvent(NOTE, {content: "Anyone seen the tide charts?", created_at: at(4, HOUR)}), + ) + + pinned = space.event( + user.alice, + makeEvent(NOTE, {content: "PINNED how to read a tide chart", created_at: at(3, HOUR)}), + ) + + space.event( + user.alice, + makeEvent(NOTE, {content: "MIDDLE rigging notes from this morning", created_at: at(2, HOUR)}), + ) + + space.event( + user.alice, + makeEvent(NOTE, {content: "NEWEST the wind has finally turned", created_at: at(1, HOUR)}), + ) + + // Older than the window the feed opens with, so reaching it is the feed paging backwards. + space.event( + user.alice, + makeEvent(NOTE, {content: "OLDEST from the spring refit", created_at: at(8, MONTH)}), + ) + + // A reply to carol, which a profile's note list leaves out. + space.event( + user.alice, + () => + space + .kind(Note) + .writer() + .setParent(carolNote.event) + .setContent("REPLY they are in the chart drawer") + .renderTemplate(), + at(90, MINUTE), + ) + + space.event(user.alice, () => + space.kind(PinList).writer().pinPublicly(["e", pinned.id]).renderTemplate(), + ) + }) + + const {url} = scenario.space("space") + const page = await as(users.bob, profilePath(users.alice), {context: DESKTOP}) + const list = notes(page) + const newest = list.filter({hasText: "NEWEST"}) + + await expect(newest).toBeVisible() + await expect(newest.getByText("Alice Anderson")).toBeVisible() + await expect(newest.locator(`img[src="${avatar}"]`)).toBeVisible() + // The story asks for a relative timestamp. NoteCard renders formatTimestamp, which is a short + // date plus a clock time, so this is the story's reading rather than the app's. + await expect(newest).toContainText(/\d+ (second|minute|hour|day)s? ago/) + + await expect(list.filter({hasText: "REPLY"})).toHaveCount(0) + + // The pin outranks the newer notes; the rest are newest first. + await expect(list.first()).toContainText("PINNED") + await expect(list.nth(1)).toContainText("NEWEST") + await expect(list.nth(2)).toContainText("MIDDLE") + + // Nothing is scrolled here: the feed keeps widening its window until the page is full, which is + // what "loads older notes automatically" means. + await expect(list.filter({hasText: "OLDEST"})).toBeVisible() + + // A note alice publishes while bob is looking. Flotilla has no composer for a kind-1 note, so + // it goes out through the app's own primitives in her signed-in session, over her socket to the + // space — which is the half of this the story is about. + const alice = await as(users.alice, spacePath(url), {context: DESKTOP}) + + await alice.evaluate( + async ([relayUrl, content]) => { + const { + app, + network, + makeEvent: make, + } = window as unknown as { + app: {get(): {user: {sign(template: object): Promise}}} + network: {get(): {publish(options: {event: object; relays: string[]}): Promise}} + makeEvent(kind: number, values: {content: string}): object + } + + await network.get().publish({ + event: await app.get().user.sign(make(1, {content})), + relays: [relayUrl], + }) + }, + [url, "LIVE straight off the deck"] as const, + ) + + // Above every unpinned note, without bob having reloaded anything. + await expect(list.filter({hasText: "LIVE"})).toBeVisible() + await expect(list.nth(1)).toContainText("LIVE") + + // Carol has posted nothing at all. + await page.goto(profilePath(users.carol)) + + await expect(page.getByText("Loading notes...")).toBeVisible() + await expect(page.getByText("No notes found for this profile.")).toBeVisible() +}) + +test("US-080 preview a profile from anywhere", async ({seed, as}) => { + const avatar = "https://images.test/bob-avatar.png" + + 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.profile(user.bob, { + name: "Bob Barnacle", + about: "Deckhand, dockside cook, and keeper of the ship's cat.", + picture: avatar, + }) + space.message(user.bob, "general", "the cat has the helm", at(1, HOUR)) + seedRelayList(space, user.bob) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, `${spacePath(url)}/directory`, {context: DESKTOP}) + // SpaceMember covers its whole card with one button, whose aria-label is interpolated from + // `$profiles.display(pubkey).get()` — a plain call, so the label keeps whatever the name was at + // first render, which is the npub the card falls back to before the profile has loaded. The + // name it *displays* comes from a store and does update, so the card is found by that. + const preview = page + .locator(".card.card-interactive") + .filter({hasText: "Bob Barnacle"}) + .locator("button[aria-label]") + .first() + + await expect(preview).toBeVisible() + + await preview.click() + + await expect(dialog(page).getByText("Bob Barnacle")).toBeVisible() + await expect(dialog(page).locator(`img[src="${avatar}"]`)).toBeVisible() + await expect(dialog(page).getByText("Deckhand, dockside cook")).toBeVisible() + await expect(dialog(page).getByText(/Last active/)).toBeVisible() + + await dialog(page).getByRole("button", {name: "Go back"}).click() + + // Closed, and alice is exactly where she opened it from. + await expect(page.locator(".dialog")).toHaveCount(0) + await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`)) + await expect(preview).toBeVisible() + + await preview.click() + await page.getByRole("button", {name: "View Full Profile"}).click() + + await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`)) + await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible() +}) + +test("US-081 inspect and share a profile", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."}) + seedRelayList(space, user.bob) + }) + + const page = await as(users.alice, profilePath(users.bob), {context: CLIPBOARD}) + + await profileMenu(page).click() + await page.getByRole("button", {name: "Profile Info"}).click() + + await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible() + + const info = dialog(page) + const link = info.locator('input[type="text"]').first() + const pubkey = info.locator('input[type="text"]').nth(1) + + // The profile was signed during this test, so the creation date is today's. A FieldInline puts + // its value in the div right after its label, which keeps this a claim about the date rather + // than about anything else in the dialog that happens to carry four digits. + const createdAt = info + .locator("label") + .filter({hasText: "Created At"}) + .locator("xpath=following-sibling::div") + + await expect(createdAt).toContainText(String(new Date().getFullYear())) + + await expect(link).toHaveValue(/^nostr:nprofile1/) + await expect(pubkey).toHaveValue(npubEncode(users.bob.pubkey)) + await expect(info.locator("pre code")).toContainText("Bob Barnacle") + + await info.locator("label.input").filter({has: link}).getByRole("button").click() + + await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") + await expect.poll(() => readClipboard(page)).toBe(await link.inputValue()) + + await info.locator("label.input").filter({has: pubkey}).getByRole("button").click() + + await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey)) + + await info.getByRole("button", {name: "Copy"}).click() + + await expect.poll(() => readClipboard(page)).toContain("Bob Barnacle") + + await page.getByRole("button", {name: "Got it"}).click() + + await profileMenu(page).click() + await page.getByRole("button", {name: "Share"}).click() + + await expect(page.getByText("Share Profile")).toBeVisible() + + const share = dialog(page) + const qr = share.locator("canvas") + const shareLink = share.locator("input") + + await expect(qr).toBeVisible() + expect((await qr.boundingBox())?.width ?? 0).toBeGreaterThan(0) + + // pubkeyLink in src/app/env.ts, which shares a profile without relay hints. + await expect(shareLink).toHaveValue( + `https://coracle.social/${nprofileEncode({pubkey: users.bob.pubkey, relays: []})}`, + ) + + await share.locator("label.input").getByRole("button").click() + + await expect.poll(() => readClipboard(page)).toBe(await shareLink.inputValue()) +}) + +test("US-082 mute an account", async ({seed, as}) => { + 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.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."}) + seedRelayList(space, user.alice) + seedRelayList(space, user.bob) + + space.event( + user.bob, + makeEvent(NOTE, {content: "Salt on the rigging this morning", created_at: at(2, HOUR)}), + ) + + space.event( + user.bob, + makeEvent(NOTE, {content: "The gulls have taken the mainsail", created_at: at(1, HOUR)}), + ) + }) + + const page = await as(users.alice, "/settings/content", {context: DESKTOP}) + const badge = page.locator(".badge").filter({hasText: "Bob Barnacle"}) + const save = page.getByRole("button", {name: "Save Changes"}) + + // Typed rather than filled, and slower than the search's own debounce: the suggestion list is + // rebuilt per keystroke, and the profile it is searching for only arrives from the relay once a + // pause in the typing has let the query go out. + await page + .getByPlaceholder("Search for profiles...") + .pressSequentially("Bob Barnacle", {delay: 700}) + + const suggestion = page.locator(".tiptap-suggestions__item").filter({hasText: "Bob Barnacle"}) + + await expect(suggestion).toBeVisible() + + await suggestion.click() + + await expect(badge).toBeVisible() + + await save.click() + + await expect(page.getByRole("alert")).toContainText("Your settings have been saved!") + + // Through the nav rather than a fresh navigation, so what is on screen is what the client that + // just published the mute believes. + const openBobsProfile = async () => { + await page.locator('.primary-nav a[href="/people"]').click() + await page.getByPlaceholder("Search for people...").fill("Barnacle") + + const card = page.locator(".card.card-interactive").filter({hasText: "Bob Barnacle"}) + + await expect(card).toHaveCount(1) + await viewProfile(card).click() + await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`)) + } + + await openBobsProfile() + + const list = notes(page) + + await expect(list).toHaveCount(2) + await expect(list.first()).toContainText("You have muted this person.") + await expect(list.last()).toContainText("You have muted this person.") + await expect(pageContent(page).getByText("Salt on the rigging")).toHaveCount(0) + + await list.first().getByRole("button", {name: "Show anyway"}).click() + + await expect(list.first()).toContainText("The gulls have taken the mainsail") + await expect(list.last()).toContainText("You have muted this person.") + + await page.locator('.primary-nav a[href="/settings/profile"]').click() + await page.getByRole("link", {name: "Content"}).click() + + await expect(badge).toBeVisible() + + await page.reload() + + await expect(badge).toBeVisible() + + await badge.getByRole("button").first().click() + + await expect(badge).toHaveCount(0) + + await save.click() + + await expect(page.getByRole("alert")).toContainText("Your settings have been saved!") + + await openBobsProfile() + + await expect(list).toHaveCount(2) + await expect(pageContent(page).getByText("Salt on the rigging this morning")).toBeVisible() + await expect(pageContent(page).getByText("The gulls have taken the mainsail")).toBeVisible() + await expect(pageContent(page).getByText("You have muted this person.")).toHaveCount(0) +}) diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts new file mode 100644 index 00000000..73bfa632 --- /dev/null +++ b/e2e/specs/rooms.spec.ts @@ -0,0 +1,753 @@ +import {DAY, HOUR, WEEK} from "@welshman/lib" +import {MessagingRelayList, RelayList, displayPubkey} from "@welshman/domain" +import type {Locator, Page} from "@playwright/test" +import {expect, makeTestUser, roomPath, spacePath, test, users} from "../harness" +import type {SeededSpace, TestUser} from "../harness" + +// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id and +// timestamp are ever read back here. +type Seeded = {readonly id: string; readonly event: {readonly created_at: number}} + +// A path as a pattern, for a url that carries a query string or a modal's hash alongside it. +const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + +// The panel of the modal carrying a given title. `.dialog` is on both the backdrop wrapper and the +// panel inside it, so the last match is the panel. +const dialog = (page: Page, title: string) => + page + .locator(".dialog") + .filter({has: page.getByRole("heading", {name: title, exact: true})}) + .last() + +const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]") + +// .room__content is column-reverse, so the message at the bottom of the room is the first one in +// the dom. +const messages = (page: Page) => page.locator(".room__item") + +const message = (page: Page, text: string) => messages(page).filter({hasText: text}) + +// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is +// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu. +const messageActions = (page: Page, text: string) => + message(page, text).locator(".room__item-actions button") + +const openMessageMenu = (page: Page, text: string) => messageActions(page, text).last().click() + +// Each of these menus hides itself once the pointer leaves it, which is how one is dismissed +// without clicking an item. The popper sits against the right hand end of the message, so the top +// left corner of the viewport is outside it. +const dismissMenu = (page: Page) => page.mouse.move(0, 0) + +// The room's page bar carries a back arrow, a search button and the detail button, in that order. +// The back arrow is display:none at this viewport, so a role query sees the other two. +const pageBar = (page: Page) => page.locator('[data-component="PageBar"]') + +const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click() + +// RoomDetail has no title of its own — the room's name stands in for one — so it is named by the +// permissions card only it renders. Scoped to the modal's body rather than the dialog, so that the +// dialog's own close button isn't the first button in it. +const roomDetail = (page: Page) => + page.locator(".scroll-container").filter({hasText: "Room Permissions"}) + +const openRoomDetailMenu = (page: Page) => roomDetail(page).getByRole("button").first().click() + +// The space menu's sections are flat siblings — a header, then the rooms under it — so which +// section a room is in is a question about document order rather than nesting. +const roomSection = (page: Page, name: string) => + page.locator(".space-menu__scroll").evaluate((menu, roomName) => { + let section: string | undefined + + for (const node of menu.querySelectorAll(".secondary-nav__header, .secondary-nav__nav-item")) { + if (node.classList.contains("secondary-nav__header")) { + section = node.textContent?.trim() + } else if (node.textContent?.trim() === roomName) { + return section + } + } + }, name) + +const roomLink = (page: Page, name: string) => + page.locator(".space-menu__scroll").getByRole("link", {name}) + +// A FieldInline puts its control in the div immediately after its label. +const field = (form: Locator, label: string) => + form + .locator("label") + .filter({hasText: label}) + .locator("xpath=following-sibling::div") + .locator("input[type=text]") + +// RoomForm's permission toggles are a bare checkbox beside their own text rather than a labelled +// control. +const permission = (form: Locator, label: string) => + form.getByText(label).locator("xpath=preceding-sibling::input") + +const reactionPill = (page: Page, text: string) => + message(page, text).getByRole("button", {name: /🎉/}) + +// The picker is a web component with an open shadow root, so its search field and its results are +// reachable through it. Searching rather than browsing avoids depending on which category tab an +// emoji happens to live under. A tippy keeps its content mounted after it hides, so the visible +// one is the picker that was just opened. +const react = async (page: Page, opener: Locator) => { + await opener.click() + + const picker = page.locator("emoji-picker").filter({visible: true}) + + await picker.locator("input.search").fill("party popper") + await picker.locator('[role="option"][aria-label*="party popper"]').first().click() +} + +const send = async (page: Page, content: string) => { + await composer(page).pressSequentially(content) + await composer(page).press("Enter") +} + +// Outbox routing resolves everything about a person through their relay list, and a gift wrap only +// reaches somebody who has said where their messages go. +const seedChatter = (space: SeededSpace, user: TestUser) => { + space.event(user, () => + space + .kind(RelayList) + .writer() + .setReadUrls([space.url]) + .setWriteUrls([space.url]) + .renderTemplate(), + ) + space.event(user, () => + space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(), + ) +} + +test("US-018 send and receive a room message in real time", async ({seed, as}) => { + 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.profile(user.alice, {name: "Alice Anchor", picture: "https://images.test/alice.png"}) + space.message(user.bob, "general", "morning all", at(2, HOUR)) + }) + + 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. + const alice = await as(users.alice, path) + const bob = await as(users.bob, path) + + await expect(message(bob, "morning all")).toBeVisible() + + await send(alice, "anyone there?") + + await expect(composer(alice)).toHaveText("") + + const sent = messages(alice).first() + + await expect(sent).toContainText("anyone there?") + await expect(sent).toContainText("Alice Anchor") + await expect(sent).toContainText(/Today at \d/) + await expect(sent.locator('img[src="https://images.test/alice.png"]')).toBeVisible() + + await expect(message(bob, "anyone there?")).toBeVisible() + + // Shift+Enter breaks the line rather than sending it + await composer(alice).pressSequentially("first line") + await composer(alice).press("Shift+Enter") + await composer(alice).pressSequentially("second line") + + await expect(composer(alice).locator("br")).toHaveCount(1) + await expect(messages(alice)).toHaveCount(2) + + // ...and cmd/ctrl+enter sends from the middle of it + await composer(alice).press("ArrowUp") + await composer(alice).press("ControlOrMeta+Enter") + + await expect(composer(alice)).toHaveText("") + await expect(message(alice, "second line")).toContainText("first line") + await expect(message(bob, "second line")).toContainText("first line") +}) + +test("US-019 join and leave a room", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + // Bob belongs to the space but to none of its rooms, so joining one is his to do. + space.join(user.bob) + space.profile(user.bob, {name: "Bob Barnacle"}) + space.message(user.alice, "general", "morning all", at(2, HOUR)) + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + + const bob = await as(users.bob, path) + const alice = await as(users.alice, path) + + await expect(message(bob, "morning all")).toBeVisible() + await expect.poll(() => roomSection(bob, "General")).toBe("Rooms") + + await openRoomDetail(bob) + await openRoomDetailMenu(bob) + await bob.getByRole("button", {name: "Join member list"}).click() + + await expect.poll(() => roomSection(bob, "General")).toBe("Your Rooms") + + // Everyone else in the room watches him arrive + const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"}) + + await expect(joined).toBeVisible() + + await joined.getByRole("button", {name: "@Bob Barnacle"}).click() + + await expect(alice.getByRole("button", {name: "View Full Profile"})).toBeVisible() + + await openRoomDetailMenu(bob) + await bob.getByRole("button", {name: "Leave member list"}).click() + + await expect(bob.getByRole("alert")).toHaveClass(/text-content/) + + await openRoomDetailMenu(bob) + + await expect(bob.getByRole("button", {name: "Join member list"})).toBeVisible() + await expect.poll(() => roomSection(bob, "General")).toBe("Rooms") +}) + +test("US-020 create, edit, and delete a room", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.bob, "general") + }) + + const {url} = scenario.space("space") + const admin = await as(users.admin, roomPath(url, "general")) + + await admin.getByRole("button", {name: "Create room"}).click() + + const createForm = dialog(admin, "Create a Room") + + await field(createForm, "Name").fill("Ship Log") + await field(createForm, "Description").fill("Where the watch writes things down") + await createForm.getByRole("button", {name: "Create Room"}).click() + + await expect(pageBar(admin)).toContainText("Ship Log") + await expect.poll(() => roomSection(admin, "Ship Log")).toBe("Your Rooms") + + // The room id is generated, so it comes back off the url the form navigated to + const h = new URL(admin.url()).pathname.split("/").pop()! + + await openRoomDetail(admin) + await openRoomDetailMenu(admin) + await admin.getByRole("button", {name: "Edit Room"}).click() + + const editForm = dialog(admin, "Edit a Room") + + await field(editForm, "Name").fill("Captain's Log") + await permission(editForm, "Only allow members to read messages").check() + await editForm.getByRole("button", {name: "Save Changes"}).click() + + await expect(pageBar(admin)).toContainText("Captain's Log") + await expect(roomLink(admin, "Captain's Log")).toBeVisible() + + await openRoomDetail(admin) + + await expect(roomDetail(admin).getByRole("button", {name: "Private"})).toBeVisible() + + await openRoomDetailMenu(admin) + await admin.getByRole("button", {name: "Edit Room"}).click() + + await expect(field(editForm, "Name")).toHaveValue("Captain's Log") + await expect(permission(editForm, "Only allow members to read messages")).toBeChecked() + + await editForm.getByRole("button", {name: "Go back"}).click() + await openRoomDetailMenu(admin) + await admin.getByRole("button", {name: "Delete Room"}).click() + + await expect( + admin.getByRole("heading", {name: "Are you sure you want to delete this room?"}), + ).toBeVisible() + + await admin.getByRole("button", {name: "Confirm"}).click() + + await expect(admin).not.toHaveURL(pathPattern(h)) + await expect(admin).toHaveURL(pathPattern(spacePath(url))) + await expect(roomLink(admin, "General")).toBeVisible() + await expect(roomLink(admin, "Captain's Log")).toHaveCount(0) + + // Room management is the relay's business as much as the ui's, and bob has neither + const bob = await as(users.bob, roomPath(url, "general")) + + await openRoomDetail(bob) + await openRoomDetailMenu(bob) + + await expect(bob.getByRole("button", {name: "Leave member list"})).toBeVisible() + await expect(bob.getByRole("button", {name: "Edit Room"})).toHaveCount(0) + await expect(bob.getByRole("button", {name: "Delete Room"})).toHaveCount(0) +}) + +test("US-021 request access to a private room and get approved", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + // `private` is what hides the room's history from non-members; `closed` is what keeps a join + // request from admitting her on its own, so an admin has to act on it. + space.room("wardroom", {name: "Wardroom", private: true, closed: true}) + space.join(user.admin, "general", "wardroom") + space.join(user.alice, "general", "wardroom") + // Carol belongs to the space, which is what lets her see that the room exists at all. + space.join(user.carol) + space.profile(user.carol, {name: "Carol Cutter"}) + space.message(user.alice, "wardroom", "the charts are in the locker", at(2, HOUR)) + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "wardroom") + + const carol = await as(users.carol, path) + const admin = await as(users.admin, path) + + await expect(message(admin, "the charts are in the locker")).toBeVisible() + + await expect(carol.getByText("You aren't currently a member of this room.")).toBeVisible() + await expect(carol.getByText("the charts are in the locker")).toHaveCount(0) + + await carol.getByRole("button", {name: "Ask to Join"}).click() + + await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible() + + await admin.getByRole("button", {name: /space\.test/}).click() + await admin.getByRole("button", {name: /Action Items/}).click() + + const request = admin.locator(".card").filter({hasText: "requested membership in"}) + + await expect(request).toContainText("Carol Cutter") + await expect(request).toContainText("Wardroom") + + await request.getByRole("button", {name: "Accept"}).click() + + await expect(admin.getByText("Member has been added to the room!")).toBeVisible() + + await expect(carol.getByText("the charts are in the locker")).toBeVisible() + + await send(carol, "found them, thanks") + + await expect(message(carol, "found them, thanks")).toBeVisible() + await expect(message(admin, "found them, thanks")).toBeVisible() +}) + +test("US-022 bring people into a room", async ({seed, as}) => { + const dora = makeTestUser("dora") + const erik = makeTestUser("erik") + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.bob, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + // A space member who isn't in the room yet. Her message is what loads her profile into the + // client that goes looking for her. + space.join(dora) + space.profile(dora, {name: "Dora Deckhand"}) + space.message(dora, "general", "passing through", at(3, HOUR)) + }) + + const {url} = scenario.space("space") + const admin = await as(users.admin, roomPath(url, "general")) + + await expect(message(admin, "passing through")).toContainText("Dora Deckhand") + + await openRoomDetail(admin) + await admin.getByRole("button", {name: "Create invite"}).click() + + const inviteModal = dialog(admin, "Create a Room Invite") + const invite = inviteModal.locator("input[readonly]") + + await expect(inviteModal.locator("canvas")).toBeVisible() + await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/) + + await inviteModal.locator("label").filter({has: invite}).getByRole("button").click() + + await expect(admin.getByText("Copied to clipboard!")).toBeVisible() + + // The link is opened as a path rather than as an absolute url, which would leave the test's own + // dev server for the platform's. + const link = new URL(await invite.inputValue()) + const carol = await as(users.carol, `/join${link.search}`) + + await expect(carol.getByText("You're about to join:")).toBeVisible() + + await carol.getByRole("button", {name: "Join Room"}).click() + + await expect(carol.getByText("Welcome to the room!")).toBeVisible() + await expect(carol).toHaveURL(pathPattern(roomPath(url, "general"))) + await expect(message(carol, "passing through")).toBeVisible() + + await admin.getByRole("button", {name: "Done"}).click() + await admin.getByRole("button", {name: "View All"}).click() + + const members = dialog(admin, "Members") + + await expect(members.getByText("Bob Barnacle")).toBeVisible() + await expect(members.getByText("Dora Deckhand")).toHaveCount(0) + + await admin.getByRole("button", {name: "Add members"}).click() + await admin.getByPlaceholder("Search for profiles...").fill("Dora") + await admin.locator(".tiptap-suggestions").getByRole("button", {name: dora.pubkey}).click() + await admin.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByText("Members have successfully been added!")).toBeVisible() + await expect(members.getByText("Dora Deckhand")).toBeVisible() + + // Somebody who isn't in the space yet has to be let into it first. A pubkey pasted into the + // search field selects that person outright, which is how erik is reachable without a profile. + await admin.getByRole("button", {name: "Add members"}).click() + await admin.getByPlaceholder("Search for profiles...").fill(erik.pubkey) + await admin.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByText("is not a member of this space. Add them?")).toBeVisible() + + await admin.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByText("Members have successfully been added!")).toBeVisible() + await expect(members.getByText(displayPubkey(erik.pubkey))).toBeVisible() + + const doraCard = members.locator(".card").filter({hasText: "Dora Deckhand"}) + + await doraCard.getByRole("button").last().click() + await admin.getByRole("button", {name: "Remove Member"}).click() + await admin.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByText("Member has successfully been removed!")).toBeVisible() + await expect(members.getByText("Dora Deckhand")).toHaveCount(0) + + const bob = await as(users.bob, roomPath(url, "general")) + + await openRoomDetail(bob) + await bob.getByRole("button", {name: "View All"}).click() + + await expect(dialog(bob, "Members").getByText("Bob Barnacle")).toBeVisible() + await expect(bob.getByRole("button", {name: "Add members"})).toHaveCount(0) +}) + +test("US-023 reply to a message", async ({seed, as}) => { + 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.profile(user.alice, {name: "Alice Anchor"}) + space.message(user.alice, "general", "the tide turns at six", at(2, HOUR)) + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + + const bob = await as(users.bob, path) + const alice = await as(users.alice, path) + + await expect(message(bob, "the tide turns at six")).toBeVisible() + + await messageActions(bob, "the tide turns at six").nth(2).click() + + const banner = bob.getByText("Replying to @Alice Anchor") + + await expect(banner).toBeVisible() + await expect(bob.locator(".room__compose")).toContainText("the tide turns at six") + + // Escape clears the reply without sending it + await composer(bob).press("Escape") + + await expect(banner).toHaveCount(0) + + // ...and so does the banner's own close button + await messageActions(bob, "the tide turns at six").nth(2).click() + await banner.locator("..").getByRole("button").last().click() + + await expect(banner).toHaveCount(0) + await expect(messages(bob)).toHaveCount(1) + + await messageActions(bob, "the tide turns at six").nth(2).click() + await send(bob, "aye, I'll be there") + + await expect(message(bob, "aye, I'll be there")).toContainText("the tide turns at six") + await expect(message(alice, "aye, I'll be there")).toContainText("the tide turns at six") +}) + +test("US-024 edit or delete a message you sent", 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") + space.profile(user.alice, {name: "Alice Anchor"}) + space.profile(user.bob, {name: "Bob Barnacle"}) + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + + const alice = await as(users.alice, path) + const bob = await as(users.bob, path) + + // Only a message she sent in the last five minutes is editable, so she sends one + await send(alice, "we sail at dwan") + + await expect(message(bob, "we sail at dwan")).toBeVisible() + + await send(bob, "spelling?") + + await expect(message(alice, "spelling?")).toBeVisible() + + // Her own message offers zap, emoji, reply, edit and a menu; his offers no edit... + await expect(messageActions(alice, "we sail at dwan")).toHaveCount(5) + await expect(messageActions(alice, "spelling?")).toHaveCount(4) + + // ...and no delete either + await openMessageMenu(alice, "spelling?") + + await expect(alice.getByRole("button", {name: "Report Content"})).toBeVisible() + await expect(alice.getByRole("button", {name: "Delete Message"})).toHaveCount(0) + + await dismissMenu(alice) + + await messageActions(alice, "we sail at dwan").nth(3).click() + + await expect(alice.getByText("Editing message")).toBeVisible() + await expect(composer(alice)).toHaveText("we sail at dwan") + + await composer(alice).press("ControlOrMeta+a") + await send(alice, "we sail at dawn") + + await expect(message(alice, "we sail at dawn")).toBeVisible() + await expect(alice.getByText("we sail at dwan")).toHaveCount(0) + await expect(message(bob, "we sail at dawn")).toBeVisible() + await expect(bob.getByText("we sail at dwan")).toHaveCount(0) + + // Republished with its original timestamp, so it stays above the message it drew + await expect(messages(alice).nth(1)).toContainText("we sail at dawn") + await expect(messages(bob).nth(1)).toContainText("we sail at dawn") + + // Up in an empty composer picks up her most recent editable message + await composer(alice).press("ArrowUp") + + await expect(alice.getByText("Editing message")).toBeVisible() + await expect(composer(alice)).toHaveText("we sail at dawn") + + await alice.getByText("Editing message").locator("..").getByRole("button").click() + + await expect(alice.getByText("Editing message")).toHaveCount(0) + await expect(message(alice, "we sail at dawn")).toBeVisible() + + await openMessageMenu(alice, "we sail at dawn") + await alice.getByRole("button", {name: "Delete Message"}).click() + await alice.getByRole("button", {name: "Confirm"}).click() + + await expect(alice.getByText("we sail at dawn")).toHaveCount(0) + await expect(bob.getByText("we sail at dawn")).toHaveCount(0) +}) + +test("US-025 react to a message", async ({seed, as}) => { + 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.join(user.carol, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + space.message(user.alice, "general", "we made port", at(2, HOUR)) + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + + const carol = await as(users.carol, path) + const bob = await as(users.bob, path) + + await expect(message(carol, "we made port")).toBeVisible() + await expect(message(bob, "we made port")).toBeVisible() + + // The quick reaction button is the second of RoomItem's hover actions + await react(carol, messageActions(carol, "we made port").nth(1)) + + await expect(reactionPill(carol, "we made port")).toBeVisible() + await expect(reactionPill(bob, "we made port")).toBeVisible() + + await react(bob, messageActions(bob, "we made port").nth(1)) + + await expect(reactionPill(carol, "we made port")).toContainText("2") + await expect(reactionPill(bob, "we made port")).toContainText("2") + + await reactionPill(carol, "we made port").click() + + await expect(reactionPill(carol, "we made port")).not.toContainText("2") + await expect(reactionPill(carol, "we made port")).not.toHaveClass(/button-primary/) + await expect(reactionPill(bob, "we made port")).not.toContainText("2") + + // On a phone, a pill she hasn't joined shows who reacted rather than adding her own + const phone = await as(users.carol, path, { + context: {viewport: {width: 390, height: 844}, hasTouch: true}, + }) + + await expect(reactionPill(phone, "we made port")).toBeVisible() + + await reactionPill(phone, "we made port").click() + + await expect(phone.getByText("Reacted to this message")).toBeVisible() + await expect(phone.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible() +}) + +test("US-026 pin a message and browse pins", async ({seed, as}) => { + let pinned!: Seeded + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.alice, "general") + + pinned = space.message(user.alice, "general", "muster at eight bells", at(2, HOUR)) + }) + + const {url} = scenario.space("space") + const admin = await as(users.admin, roomPath(url, "general")) + + await expect(message(admin, "muster at eight bells")).toBeVisible() + + await openMessageMenu(admin, "muster at eight bells") + await admin.getByRole("button", {name: "Pin Message"}).click() + + await expect(admin.getByText("Message pinned")).toBeVisible() + + const banner = admin.locator(".room-pins") + + await expect(banner).toContainText("muster at eight bells") + + await banner.getByRole("button", {name: "All pinned messages"}).click() + + const pins = dialog(admin, "Pinned Messages") + + await expect(pins).toContainText("muster at eight bells") + + await pins.getByRole("button", {name: "Jump to message"}).click() + + await expect(pins).toHaveCount(0) + await expect(admin.locator(`[data-event="${pinned.id}"]`)).toBeInViewport() + + await banner.getByRole("button", {name: "All pinned messages"}).click() + await pins.getByRole("button", {name: "Unpin"}).click() + + await expect(admin.getByText("Message unpinned")).toBeVisible() + await expect(pins).toContainText("No pinned messages.") + await expect(banner).toHaveCount(0) +}) + +test("US-027 find a past message and jump to it", async ({seed, as}) => { + let lastWeek!: Seeded + let older!: Seeded + + 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.bob, "general", "harbor lights are on tonight", at(2, HOUR)) + + lastWeek = space.message(user.bob, "general", "harbor pilot is booked", at(3, DAY)) + older = space.message(user.bob, "general", "harbor dredging starts monday", at(3, WEEK)) + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + const page = await as(users.alice, path) + + await expect(message(page, "harbor lights are on tonight")).toBeVisible() + + await pageBar(page).getByRole("button", {name: "Search"}).click() + + const term = page.getByPlaceholder("Search this room...") + const search = dialog(page, "Search Content") + + await term.fill("harbor") + + await expect(search.getByText("Last 24 Hours")).toBeVisible() + await expect(search.getByText("Last 7 Days")).toBeVisible() + await expect(search.getByText("Older")).toBeVisible() + await expect(search).toContainText("harbor dredging starts monday") + + await term.fill("mizzenmast") + + await expect(page.getByText("No results found.")).toBeVisible() + + await term.fill("harbor pilot") + await search.getByText("harbor pilot is booked").click() + + await expect(search).toHaveCount(0) + await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport() + + // A permalink to one message lands on it, with a way back down to the newest + await page.goto(`${path}?at=${older.event.created_at}`) + + await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport() + await expect(page.locator(".chat__scroll-down")).toBeVisible() +}) + +test("US-028 share a message somewhere else", 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", "random") + space.join(user.bob, "general", "random") + space.profile(user.alice, {name: "Alice Anchor"}) + space.profile(user.bob, {name: "Bob Barnacle"}) + space.message(user.bob, "general", "the dock is closed on sunday", at(2, HOUR)) + + seedChatter(space, user.alice) + seedChatter(space, user.bob) + }) + + const {url} = scenario.space("space") + + const alice = await as(users.alice, roomPath(url, "general")) + const bob = await as(users.bob, roomPath(url, "random")) + + await expect(message(alice, "the dock is closed on sunday")).toBeVisible() + + await openMessageMenu(alice, "the dock is closed on sunday") + await alice.getByRole("button", {name: "Share"}).click() + + // ShareEvent is titled after the noun it was opened with, so its subtitle names it instead. + const picker = alice + .locator(".dialog") + .filter({hasText: "Which room would you like to share this event to?"}) + .last() + + await picker.getByRole("button", {name: "Random"}).click() + await picker.getByRole("button", {name: /^Share/}).click() + + await expect(alice).toHaveURL(pathPattern(roomPath(url, "random"))) + await expect(alice.locator(".room__compose")).toContainText("the dock is closed on sunday") + + await send(alice, "heads up") + + await expect(message(alice, "heads up")).toContainText("the dock is closed on sunday") + await expect(message(bob, "heads up")).toContainText("the dock is closed on sunday") +}) diff --git a/e2e/specs/settings.spec.ts b/e2e/specs/settings.spec.ts new file mode 100644 index 00000000..f7c21698 --- /dev/null +++ b/e2e/specs/settings.spec.ts @@ -0,0 +1,589 @@ +import {MINUTE} from "@welshman/lib" +import {RelayList} from "@welshman/domain" +import type {Locator, Page} from "@playwright/test" +import {expect, roomPath, test, users} from "../harness" +import type {SeededSpace, TestUser} from "../harness" + +// A handle to a seeded event, which only reads once seed() has drained its queue. +type Seeded = {readonly id: string} + +// The user's own relay list. Everything on these pages is published through their outbox, and the +// sync that loads their settings back after a reload hangs off this list, so a spec that saves a +// setting has to seed one. +const relayList = (space: SeededSpace, user: TestUser) => + space.event(user, () => + space + .kind(RelayList) + .writer() + .setReadUrls([space.url]) + .setWriteUrls([space.url]) + .renderTemplate(), + ) + +// FieldInline lays a labelled control out as a single row, with the label at one end and the +// control at the other. +const row = (page: Page, label: string) => + page.locator("div.items-center.justify-between").filter({hasText: label}) + +const toggle = (page: Page, label: string) => row(page, label).getByRole("checkbox") + +// RelaySettingsItem renders one relay list as a button: its name and description at one end, a +// check or a danger triangle and the list's size at the other. +const relayRow = (page: Page, title: string) => page.getByRole("button").filter({hasText: title}) + +const relayCount = (page: Page, title: string) => relayRow(page, title).locator("div.justify-end") + +// The check/warning icon beside that count. Icons are mask-image data urls, so which one is showing +// can only be read by comparing it against a row whose state is already known. +const relayIcon = (page: Page, title: string) => + relayRow(page, title).locator("div.inline-block").last().getAttribute("style") + +// One relay list's modal, which carries the list's name as its heading. Dialog nests two elements +// with the class; the inner one holds the content. +const relayDialog = (page: Page, title: string) => + page + .locator(".dialog") + .filter({has: page.getByRole("heading", {name: title, exact: true})}) + .last() + +// The relay picker, which has no heading of its own — and is pushed over the list modal rather +// than alongside it, so it is the only dialog in the dom while it is open. +const relayPicker = (page: Page) => page.locator(".dialog").last() + +const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name}) + +// RelaySettingsHealthCheck renders one issue as a small card with its recommendation beside it. +const issue = (page: Page, title: string) => page.locator(".card-sm").filter({hasText: title}) + +// A saved setting reaches indexeddb in batches, and a settings page reads its values once when it +// mounts — so a reload only sees the new value after the batch has been flushed. A toast clears +// itself after five seconds, which is longer than the batch window, so waiting it out is what +// makes the assertion that follows about persistence rather than about timing. +const waitForToastToClear = (page: Page) => expect(page.getByRole("alert")).toHaveCount(0) + +test("US-083 manage inbox and outbox relays", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + // Kind 10002 publishes to every relay it names, and to the indexers, so alice has to be a + // member of both of them for her own edits to be accepted. + other.room("lounge", {name: "Lounge"}) + other.join(user.alice, "lounge") + + space.event(user.alice, () => + space + .kind(RelayList) + .writer() + .setReadUrls([space.url]) + .setWriteUrls([space.url, other.url]) + .renderTemplate(), + ) + }) + + const page = await as(users.alice, "/settings/relays") + + await expect(relayRow(page, "Inbox Relays")).toBeVisible() + await expect(relayRow(page, "Outbox Relays")).toBeVisible() + await expect(relayCount(page, "Inbox Relays")).toHaveText("1") + await expect(relayCount(page, "Outbox Relays")).toHaveText("2") + + // alice has no dm relays at all, so this row is the reference for what a warning looks like. + const warningIcon = await relayIcon(page, "DM Relays") + + expect(await relayIcon(page, "Outbox Relays")).not.toBe(warningIcon) + + await relayRow(page, "Inbox Relays").click() + + const inbox = relayDialog(page, "Inbox Relays") + + await expect(inbox.getByText("space.test")).toBeVisible() + await expect(inbox.getByText("other.test")).toHaveCount(0) + + await inbox.getByRole("button", {name: "Add Relays"}).click() + await page.getByPlaceholder("Search for relays...").fill("other.test") + + // The typed url is offered as a custom entry alongside whatever the picker already knows about, + // and both add the same relay. + await relayPicker(page).getByRole("button", {name: "Add Relay"}).first().click() + + await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0) + + await page.getByRole("button", {name: "Done"}).click() + + await expect(relayDialog(page, "Inbox Relays").getByText("other.test")).toBeVisible() + + await relayDialog(page, "Inbox Relays").getByRole("button", {name: "Go back"}).click() + + await expect(relayCount(page, "Inbox Relays")).toHaveText("2") + + await relayRow(page, "Outbox Relays").click() + + const outbox = relayDialog(page, "Outbox Relays") + + await relayCard(outbox, "other.test").getByRole("button", {name: "Remove"}).click() + + await expect(outbox.getByText("space.test")).toBeVisible() + await expect(outbox.getByText("other.test")).toHaveCount(0) + + await outbox.getByRole("button", {name: "Go back"}).click() + + await expect(relayCount(page, "Outbox Relays")).toHaveText("1") + + expect(await relayIcon(page, "Outbox Relays")).toBe(warningIcon) +}) + +test("US-084 manage DM, search, and blocked relays", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + // A second relay for the pickers to offer. Nobody is a member of it — what puts a relay in + // the suggestion pool is its nip-11 document having been fetched, and every scenario relay is + // an indexer, so bob's client reads this one at startup. + relay("other") + + space.room("general", {name: "General"}) + space.join(user.bob, "general") + + relayList(space, user.bob) + }) + + const other = scenario.space("other") + const page = await as(users.bob, "/settings/relays", { + // zooid advertises no nip-50, and the search-relay picker offers nothing that doesn't. + relayInfo: {[other.url]: {supported_nips: ["1", "29", "43", "50"]}}, + }) + + await expect(relayCount(page, "DM Relays")).toHaveText("0") + await expect(relayCount(page, "Search Relays")).toHaveText("0") + + await relayRow(page, "DM Relays").click() + await relayDialog(page, "DM Relays").getByRole("button", {name: "Add Relays"}).click() + await relayCard(relayPicker(page), "space.test").getByRole("button", {name: "Add Relay"}).click() + await page.getByRole("button", {name: "Done"}).click() + + const dms = relayDialog(page, "DM Relays") + + await expect(dms.getByText("space.test")).toBeVisible() + + await dms.getByRole("button", {name: "Go back"}).click() + + await expect(relayCount(page, "DM Relays")).toHaveText("1") + + // The same relay is not now a search relay: the two lists are separate. + await relayRow(page, "Search Relays").click() + + const search = relayDialog(page, "Search Relays") + + await expect(search.getByText("No relay selections found.")).toBeVisible() + await expect(search.getByText("space.test")).toHaveCount(0) + + await search.getByRole("button", {name: "Add Relays"}).click() + + // Only the relay advertising nip-50 is on offer. + await expect(relayPicker(page).getByText("other.test")).toBeVisible() + await expect(relayPicker(page).getByText("space.test")).toHaveCount(0) + await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(1) + + await relayPicker(page).getByRole("button", {name: "Add Relay"}).click() + await page.getByRole("button", {name: "Done"}).click() + await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click() + + await expect(relayCount(page, "Search Relays")).toHaveText("1") + + await relayRow(page, "Search Relays").click() + await relayCard(relayDialog(page, "Search Relays"), "other.test") + .getByRole("button", {name: "Remove"}) + .click() + await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click() + + await expect(relayCount(page, "Search Relays")).toHaveText("0") + + await relayRow(page, "Blocked Relays").click() + await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click() + await relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).click() + await page.getByRole("button", {name: "Done"}).click() + + const blocked = relayDialog(page, "Blocked Relays") + + await expect(blocked.getByText("other.test")).toBeVisible() + + await blocked.getByRole("button", {name: "Go back"}).click() + + await expect(relayCount(page, "Blocked Relays")).toHaveText("1") + + // A blocked relay is one bob never wants used, so the other lists stop suggesting it. The search + // picker offered other.test and nothing else a moment ago, which is what makes its absence here + // a statement about blocking rather than about an empty picker. + await relayRow(page, "Search Relays").click() + await relayDialog(page, "Search Relays").getByRole("button", {name: "Add Relays"}).click() + + await expect(relayPicker(page).getByPlaceholder("Search for relays...")).toBeVisible() + await expect(relayPicker(page).getByText("other.test")).toHaveCount(0) + await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0) +}) + +test("US-085 fix relay misconfiguration from the health check", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + // A recommendation writes the platform's default relays into the list, which is both of the + // scenario's, so alice has to be a member of both for the new list to be accepted. + other.room("lounge", {name: "Lounge"}) + other.join(user.alice, "lounge") + + relayList(space, user.alice) + }) + + const page = await as(users.alice, "/settings/relays") + + // Exact, because "3 Issues Detected" is also a substring of "13 Issues Detected". + await expect(page.getByText("3 Issues Detected", {exact: true})).toBeVisible() + await expect(issue(page, "Missing Outbox Relays")).toBeVisible() + await expect(relayCount(page, "Outbox Relays")).toHaveText("1") + + await issue(page, "Missing Outbox Relays").getByRole("button", {name: "Update"}).click() + + await expect(relayCount(page, "Outbox Relays")).toHaveText("2") + await expect(issue(page, "Missing Outbox Relays")).toHaveCount(0) + await expect(page.getByText("2 Issues Detected", {exact: true})).toBeVisible() + + await page.getByRole("button", {name: "Apply All Recommendations"}).click() + + await expect(page.getByText("0 Issues Detected", {exact: true})).toBeVisible() + await expect(page.getByRole("button", {name: "Apply All Recommendations"})).toHaveCount(0) +}) + +test("US-086 configure alerts", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + relayList(space, user.alice) + }) + + const page = await as(users.alice, "/settings/alerts") + + const sound = toggle(page, "Play sound for new activity") + const push = toggle(page, "Enable push notifications") + const alertTypes = page.locator("div.card").filter({has: page.getByText("Alert Types")}) + + await expect(sound).toBeChecked() + await expect(push).not.toBeChecked() + await expect(alertTypes).not.toHaveClass(/opacity-50/) + + // With nothing left to be alerted through, there is nothing to be alerted about. + await sound.uncheck() + + await expect(alertTypes).toHaveClass(/opacity-50/) + + await page.getByRole("button", {name: "Discard Changes"}).click() + + await expect(sound).toBeChecked() + await expect(alertTypes).not.toHaveClass(/opacity-50/) + + // Push asks the browser for permission, and this context was never granted it. + await push.check() + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Failed to request notification permissions") + await expect(push).not.toBeChecked() + await expect(sound).toBeChecked() + + await waitForToastToClear(page) + + await sound.uncheck() + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Your settings have been saved!") + + await waitForToastToClear(page) + await page.reload() + + await expect(toggle(page, "Play sound for new activity")).not.toBeChecked() +}) + +test("US-087 configure content display", async ({seed, as}) => { + let picture!: Seeded + let link!: Seeded + + 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") + + picture = space.message(user.bob, "general", "https://images.test/sunset.png", at(40, MINUTE)) + link = space.message(user.bob, "general", "https://example.test/announcement", at(35, MINUTE)) + + relayList(space, user.alice) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, "/settings/content") + + const hideSensitive = toggle(page, "Hide sensitive content?") + const showMedia = toggle(page, "Show media?") + + await expect(hideSensitive).toBeChecked() + await expect(showMedia).toBeChecked() + + await hideSensitive.uncheck() + await showMedia.uncheck() + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Your settings have been saved!") + + await waitForToastToClear(page) + await page.goto(roomPath(url, "general")) + + const pictureMessage = page.locator(`[data-event="${picture.id}"]`) + const linkMessage = page.locator(`[data-event="${link.id}"]`) + + await expect(pictureMessage.getByRole("link", {name: "images.test/sunset.png"})).toBeVisible() + await expect(pictureMessage.locator('img[src="https://images.test/sunset.png"]')).toHaveCount(0) + + await expect(linkMessage.getByRole("link", {name: "example.test/announcement"})).toBeVisible() + await expect(linkMessage.locator(".spinner")).toHaveCount(0) + await expect(linkMessage.getByText("Unable to load a preview")).toHaveCount(0) + + await page.goto("/settings/content") + + await expect(toggle(page, "Hide sensitive content?")).not.toBeChecked() +}) + +test("US-088 adjust send delay and media servers", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + relayList(space, user.alice) + }) + + const page = await as(users.alice, "/settings/content") + + // The send delay is the only slider on this page. + const sendDelay = page.locator('input[type="range"]') + const servers = page.getByRole("listitem") + + await expect(page.getByText("Delay sending chat messages for 0 seconds.")).toBeVisible() + + await sendDelay.fill("3000") + + await expect(page.getByText("Delay sending chat messages for 3 seconds.")).toBeVisible() + + await expect(servers).toHaveCount(0) + + await page.getByRole("button", {name: "Add Server"}).click() + + await expect(servers).toHaveCount(1) + + await servers.first().locator("input").fill("https://media.test/") + + await page.getByRole("button", {name: "Add Server"}).click() + + await expect(servers).toHaveCount(2) + + // Each row leads with its own remove button; the drag handle is the one with a label. + await servers.nth(1).getByRole("button").first().click() + + await expect(servers).toHaveCount(1) + await expect(servers.first().locator("input")).toHaveValue("https://media.test/") + + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Your settings have been saved!") + + await waitForToastToClear(page) + await page.reload() + + await expect(page.getByText("Delay sending chat messages for 3 seconds.")).toBeVisible() + await expect(page.locator('input[type="range"]')).toHaveValue("3000") +}) + +test("US-089 configure privacy preferences", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + relayList(space, user.alice) + }) + + const page = await as(users.alice, "/settings/privacy") + + const auth = toggle(page, "Authenticate with unknown relays?") + const usage = toggle(page, "Report usage?") + + await expect(auth).not.toBeChecked() + await expect(usage).toBeChecked() + + await auth.check() + await usage.uncheck() + await page.getByRole("button", {name: "Discard Changes"}).click() + + await expect(auth).not.toBeChecked() + await expect(usage).toBeChecked() + + await auth.check() + await usage.uncheck() + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Your settings have been saved!") + + await waitForToastToClear(page) + await page.reload() + + await expect(toggle(page, "Authenticate with unknown relays?")).toBeChecked() + await expect(toggle(page, "Report usage?")).not.toBeChecked() +}) + +test("US-090 change the app's appearance", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + relayList(space, user.alice) + }) + + const page = await as(users.alice, "/settings/theme", {context: {colorScheme: "light"}}) + const body = page.locator("body") + + await expect(body).toHaveAttribute("data-theme", "light") + + await page.getByRole("button", {name: "Dark", exact: true}).click() + + await expect(body).toHaveAttribute("data-theme", "dark") + + await page.getByRole("button", {name: "System", exact: true}).click() + + await expect(body).toHaveAttribute("data-theme", "light") + + // System means the device's, so changing the device's changes the app's. + await page.emulateMedia({colorScheme: "dark"}) + + await expect(body).toHaveAttribute("data-theme", "dark") + + await page.getByLabel("Style").selectOption("navy") + + await expect(body).toHaveAttribute("data-fl-theme", "navy") + + await page.reload() + + await expect(body).toHaveAttribute("data-fl-theme", "navy") + await expect(page.getByLabel("Style")).toHaveValue("navy") + + // Font size is the only slider on this page, and the only setting here that is published. + const fontSize = page.locator('input[type="range"]') + + await expect(page.getByText("110%")).toBeVisible() + await expect(page.getByRole("button", {name: "Save Changes"})).toHaveCount(0) + + await fontSize.fill("1.25") + + await expect(page.getByText("125%")).toBeVisible() + await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/) + + // Moving the slider is the save, so the size the document is rendered at survives a reload + // without anything else having been pressed. + await page.reload() + + await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/) +}) + +test("US-091 set up how people zap you", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.profile(user.alice, {name: "Alice Anders"}) + + relayList(space, user.alice) + }) + + const page = await as(users.alice, "/settings/wallet") + + await expect(page.getByText("Not set")).toBeVisible() + + await page.getByRole("button", {name: "Update"}).click() + + const address = page + .locator(".dialog") + .filter({has: page.getByRole("heading", {name: "Update Lightning Address"})}) + .last() + + await address.getByPlaceholder("user@domain.com").fill("alice@example.test") + await address.getByRole("button", {name: "Save Changes"}).click() + + // The dialog closes itself once the profile has gone out, and the page behind it carries its own + // "Save Changes" — so wait it out rather than leaving that name ambiguous. + await expect(address).toHaveCount(0) + await expect(page.getByText("alice@example.test")).toBeVisible() + await expect(page.getByText("Not set")).toHaveCount(0) + + await page.getByRole("button", {name: "Update"}).click() + await address.getByPlaceholder("user@domain.com").fill("") + await address.getByRole("button", {name: "Save Changes"}).click() + + await expect(address).toHaveCount(0) + await expect(page.getByText("Not set")).toBeVisible() + + // Each preset is a row: its own remove button and the amount side by side. + const presets = page.locator("form div.items-center.gap-2") + + await expect(presets).toHaveCount(4) + + await page.getByRole("button", {name: "Add amount"}).click() + + await expect(presets).toHaveCount(5) + + // A preset has to be worth something, so saving a zero is refused with an error. + await presets.nth(4).locator("input").fill("0") + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Zap amounts must be greater than zero.") + + await waitForToastToClear(page) + + // Discarding puts back the last saved values, which are still alice's original four — the zero + // never reached them. + await page.getByRole("button", {name: "Discard Changes"}).click() + + await expect(presets).toHaveCount(4) + + await presets.nth(3).getByRole("button").click() + await presets.nth(2).getByRole("button").click() + await presets.nth(1).getByRole("button").click() + + await expect(presets).toHaveCount(1) + await expect(presets.first().getByRole("button")).toBeDisabled() + + await presets.first().locator("input").fill("500") + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Your zap amounts have been saved!") + + await waitForToastToClear(page) + await page.reload() + + const saved = page.locator("form div.items-center.gap-2") + + await expect(saved).toHaveCount(1) + await expect(saved.first().locator("input")).toHaveValue("500") +}) diff --git a/e2e/specs/spaces.spec.ts b/e2e/specs/spaces.spec.ts new file mode 100644 index 00000000..83139fb7 --- /dev/null +++ b/e2e/specs/spaces.spec.ts @@ -0,0 +1,545 @@ +import {DAY, HOUR, MINUTE, WEEK} from "@welshman/lib" +import {THREAD, makeEvent} from "@welshman/util" +import {expect, roomPath, spacePath, test, users} from "../harness" + +test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + const unsigned = relay("unsigned") + + space.room("general", {name: "General"}) + other.room("lounge", {name: "Lounge"}) + unsigned.room("hall", {name: "Hall"}) + + // Alice's own two spaces, declared in the order her room list should start out in + space.join(user.alice, "general") + other.join(user.alice, "lounge") + + // A space she hasn't joined only becomes browsable through somebody else's room list + space.join(user.bob, "general") + unsigned.join(user.bob, "hall") + }) + + const space = scenario.space("space") + const other = scenario.space("other") + const unsigned = scenario.space("unsigned") + + // The spaces page discovers unjoined spaces by pulling the room lists of the pubkeys it + // bootstraps from, so pointing that at bob is what puts his other space in front of alice. + const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}}) + + await expect(page.getByText("Your spaces")).toBeVisible() + await expect(page.getByText(space.url)).toBeVisible() + await expect(page.getByText(other.url)).toBeVisible() + + // ...and the rest of them in a section of their own + await expect(page.getByText("Browse Spaces")).toBeVisible() + await expect(page.getByText(unsigned.url)).toBeVisible() + + const term = page.getByPlaceholder("Search for spaces...") + + await term.fill("unsigned") + + await expect(page.getByText(unsigned.url)).toBeVisible() + await expect(page.getByText(space.url)).toHaveCount(0) + await expect(page.getByText(other.url)).toHaveCount(0) + + await term.fill("") + + await expect(page.getByText(space.url)).toBeVisible() + + // A space she's joined opens + await page.getByRole("listitem").filter({hasText: space.url}).click() + + await expect(page).toHaveURL(/\/spaces\/space\.test\//) + + // A space she hasn't asks her to join first + await page.goto("/spaces") + await page.getByRole("button").filter({hasText: unsigned.url}).click() + + await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible() + await expect(page.getByRole("button", {name: "Go back"})).toBeEnabled() + + // Reordering by dragging, which lives in her room list and so outlives the page + await page.goto("/spaces") + + const joined = page.getByRole("listitem") + + await expect(joined.first()).toContainText(space.url) + + await joined.filter({hasText: other.url}).dragTo(joined.filter({hasText: space.url})) + + await expect(joined.first()).toContainText(other.url) + + await page.reload() + + await expect(page.getByRole("listitem").first()).toContainText(other.url) +}) + +test("US-010 join a space from an invite link", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + other.room("lounge", {name: "Lounge"}) + space.join(user.alice, "general") + }) + + const other = scenario.space("other") + const page = await as(users.alice, spacePath(other.url)) + + // Arriving at a space she hasn't joined raises the join prompt on its own + await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled() + await expect(page.getByText(other.url)).toBeVisible() + + await page.getByRole("button", {name: "Go back"}).click() + + // Going back left her un-joined, so arriving again asks again + await page.goto(spacePath(other.url)) + + await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled() + + await page.goto("/spaces") + await page.getByRole("button", {name: "Add Space"}).click() + await page.getByRole("button", {name: /Join a space/}).click() + + const invite = page.locator("form").filter({hasText: "Invite Link*"}).getByRole("textbox") + + await invite.fill("not an invite") + + await expect(page.getByRole("button", {name: "Join Space"})).toBeDisabled() + await expect(page.getByText("You're about to join:")).toHaveCount(0) + + // The link makeInviteLink builds, typed rather than followed — an absolute platform url is an + // off-origin navigation, and parseInviteLink only ever reads its query params. + await invite.fill("https://app.flotilla.social/join?r=other.test&c=") + + await expect(page.getByText("You're about to join:")).toBeVisible() + await expect(page.getByText(other.url)).toBeVisible() + + await page.getByRole("button", {name: "Join Space"}).click() + + await expect(page.getByText("Welcome to the space!")).toBeVisible() + await expect(page).toHaveURL(/\/spaces\/other\.test/) +}) + +test("US-011 request access when a space turns you away", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const closed = relay("closed") + const space = relay("space") + + // This relay refuses a join that carries no claim. A claim is not an event a scenario can + // publish — the relay only registers one through the nip-86 method the invite dialog calls — + // so the code alice needs comes out of that dialog below. + closed.room("lobby", {name: "Lobby"}) + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.bob, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + }) + + const closed = scenario.space("closed") + const space = scenario.space("space") + + // Administering a relay is not the same as belonging to it, so the space asks admin to join + // like anyone else — and issues him an invite code all the same. + const admin = await as(users.admin, spacePath(closed.url) + "/about") + + await admin.locator("form").getByRole("button", {name: "Go back"}).click() + await admin.getByRole("button", {name: /closed\.test/}).click() + await admin.getByRole("button", {name: "Create Invite"}).click() + + const inviteLink = admin.locator("input[readonly]") + + await expect(inviteLink).toHaveValue(/[?&]c=\w/) + + const [, claim = ""] = (await inviteLink.inputValue()).match(/[?&]c=([^&]+)/) ?? [] + + const alice = await as(users.alice, spacePath(closed.url)) + + // Turned away rather than joined, so the prompt becomes a request + await expect(alice.getByRole("button", {name: "Request Access"})).toBeEnabled() + + await alice.getByRole("button", {name: "Request Access"}).click() + + await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible() + + const code = alice.locator("form").filter({hasText: "Invite code*"}).getByRole("textbox") + + await code.fill("NOTTHECODE") + await alice.getByRole("button", {name: "Join Space"}).click() + + // The relay refused the code, so she is told and left asking + await expect(alice.getByRole("alert")).toContainText("join request rejected") + await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible() + + await code.fill(claim) + await alice.getByRole("button", {name: "Join Space"}).click() + + // The correct code granted her access, so the space stops turning her away + await expect(alice.getByRole("heading", {name: "Request Access"})).toHaveCount(0) + await expect(alice.getByRole("button", {name: "Join Space"})).toBeEnabled() + + await alice.getByRole("button", {name: "Join Space"}).click() + + await expect(alice.getByText("Welcome to the space!")).toBeVisible() + await expect(alice).toHaveURL(/\/spaces\/closed\.test/) + + // A member the admin bans is told why by the relay itself + await admin.goto(spacePath(space.url) + "/directory") + + const bobCard = admin + .getByRole("button", {name: "View Bob Barnacle's profile"}) + .locator("xpath=..") + + await bobCard.getByRole("button").last().click() + await admin.getByRole("button", {name: "Ban member"}).click() + + await expect(admin.getByText("Ban @Bob Barnacle from the space?")).toBeVisible() + + await admin.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByText("Member has successfully been banned!")).toBeVisible() + + const bob = await as(users.bob, spacePath(space.url)) + + await expect(bob.getByRole("heading", {name: "Access Error"})).toBeVisible() + await expect(bob.getByText(/not a member of this relay/i)).toBeVisible() + + // From here he can ask for a code back in + await bob.getByRole("button", {name: "Request Access"}).click() + + await expect(bob.getByRole("heading", {name: "Request Access"})).toBeVisible() + + await bob.getByRole("button", {name: "Go back"}).click() + + // ...or give up on the space, which takes it off his list + await bob.getByRole("button", {name: "Leave Space"}).click() + + await expect(bob).toHaveURL(/\/home/) + + await bob.goto("/spaces") + + await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible() +}) + +test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const unsigned = relay("unsigned") + + unsigned.room("general", {name: "General"}) + unsigned.join(user.alice, "general") + unsigned.join(user.bob, "general") + unsigned.message(user.alice, "general", "the hull is patched") + }) + + const unsigned = scenario.space("unsigned") + + // This relay serves events with their signatures stripped, so it can forge messages + const bob = await as(users.bob, roomPath(unsigned.url, "general")) + + await expect(bob.getByRole("heading", {name: "Do you trust this space?"})).toBeVisible() + + await bob.getByRole("button", {name: "I don't trust this space"}).click() + + await expect(bob).toHaveURL(/\/home/) + + await bob.goto("/spaces") + + await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible() + + const alice = await as(users.alice, roomPath(unsigned.url, "general")) + + await expect(alice.getByRole("heading", {name: "Do you trust this space?"})).toBeVisible() + + await alice.getByRole("button", {name: "I trust this space, continue"}).click() + + await expect(alice.getByRole("heading", {name: "Do you trust this space?"})).toHaveCount(0) + await expect(alice.getByText("the hull is patched")).toBeVisible() +}) + +test("US-013 follow a space that has moved", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + other.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + + // The new address has to let her in once her list points at it + other.member(user.alice) + }) + + const space = scenario.space("space") + const other = scenario.space("other") + + // The relay's own nip-11 document is what reports the move + const relayInfo = {[space.url]: {redirect_to: "https://other.test/"}} + + const alice = await as(users.alice, spacePath(space.url) + "/about", {relayInfo}) + const movedDialog = alice.locator("form") + + await expect(alice.getByRole("heading", {name: "This space has moved"})).toBeVisible() + await expect(movedDialog.getByText("space.test", {exact: true})).toBeVisible() + await expect(movedDialog.getByText("other.test", {exact: true})).toBeVisible() + + await alice.getByRole("button", {name: "Update and go"}).click() + + await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/) + + await alice.goto("/spaces") + + const aliceSpaces = alice.getByRole("listitem") + + await expect(aliceSpaces).toHaveCount(1) + await expect(aliceSpaces.first()).toContainText(other.url) + + const bob = await as(users.bob, spacePath(space.url) + "/about", {relayInfo}) + + await expect(bob.getByRole("heading", {name: "This space has moved"})).toBeVisible() + + await bob.getByRole("button", {name: "Not now"}).click() + + await expect(bob).toHaveURL(/\/spaces\/space\.test\/about/) + + await bob.goto("/spaces") + + const bobSpaces = bob.getByRole("listitem") + + await expect(bobSpaces).toHaveCount(1) + await expect(bobSpaces.first()).toContainText(space.url) +}) + +test("US-014 leave a space", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.bob, "general") + space.message(user.bob, "general", "signing off for a while") + }) + + const space = scenario.space("space") + const page = await as(users.bob, spacePath(space.url) + "/about") + + await page.getByRole("button", {name: /space\.test/}).click() + await page.getByRole("button", {name: "Leave Space"}).click() + + await expect(page.getByText("Are you sure you want to leave?")).toBeVisible() + + await page.getByRole("button", {name: "Confirm"}).click() + + await expect(page).toHaveURL(/\/home/) + + await page.goto("/spaces") + + await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible() + + // Nothing stops him coming back + await page.getByRole("button", {name: "Add Space"}).click() + await page.getByRole("button", {name: /Join a space/}).click() + await page + .locator("form") + .filter({hasText: "Invite Link*"}) + .getByRole("textbox") + .fill("https://app.flotilla.social/join?r=space.test&c=") + + await expect(page.getByText("You're about to join:")).toBeVisible() + + await page.getByRole("button", {name: "Join Space"}).click() + + await expect(page.getByText("Welcome to the space!")).toBeVisible() + await expect(page).toHaveURL(/\/spaces\/space\.test/) +}) + +test("US-015 view a space's details", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.carol, "general") + space.join(user.alice, "general") + space.profile(user.alice, {name: "Alice Anchor"}) + space.message(user.alice, "general", "the tide is high", at(1, HOUR)) + }) + + const space = scenario.space("space") + + // Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish + // on its own, so the scenario merges them over the relay's real document. + const relayInfo = { + [space.url]: { + icon: "https://space.test/icon.png", + contact: "harbormaster@space.test", + terms_of_service: "https://space.test/terms", + privacy_policy: "https://space.test/privacy", + limitation: {auth_required: true, payment_required: true, min_pow_difficulty: 20}, + }, + } + + const carol = await as(users.carol, spacePath(space.url) + "/about", {relayInfo}) + + await expect(carol.locator('img[src="https://space.test/icon.png"]').first()).toBeVisible() + await expect(carol.getByRole("heading", {name: "space"})).toBeVisible() + await expect(carol.getByText("space.test", {exact: true}).first()).toBeVisible() + await expect(carol.getByText(/Throwaway relay/)).toBeVisible() + await expect(carol.getByRole("link", {name: "Terms of Service"})).toBeVisible() + await expect(carol.getByRole("link", {name: "Privacy Policy"})).toBeVisible() + + await expect(carol.getByText(/Administrator:/)).toBeVisible() + await expect(carol.getByText("Contact: harbormaster@space.test")).toBeVisible() + await expect(carol.getByText(/Software:/)).toBeVisible() + await expect(carol.getByText(/Version:/)).toBeVisible() + await expect(carol.getByText("Auth Required")).toBeVisible() + await expect(carol.getByText("Payment Required")).toBeVisible() + await expect(carol.getByText("Min PoW: 20")).toBeVisible() + + await expect(carol.getByRole("heading", {name: "Members"})).toBeVisible() + await expect(carol.getByText("Admins")).toBeVisible() + await expect(carol.getByText("New members")).toBeVisible() + + // Nothing is featured yet, so recent activity stands in for it + await expect(carol.getByRole("heading", {name: "Recent Activity"})).toBeVisible() + await expect(carol.getByText("the tide is high")).toBeVisible() + + const admin = await as(users.admin, spacePath(space.url) + "/about", {relayInfo}) + const featuredHeader = admin.getByRole("heading", {name: "Featured"}).locator("xpath=..") + + await featuredHeader.getByRole("button").click() + await admin.getByRole("button", {name: "Add content"}).click() + await admin.getByPlaceholder("URL or nevent...").fill("Start with the harbor rules") + await admin.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByText("Featured content updated!")).toBeVisible() + + // What admin featured reaches every visitor, and takes the top slot from recent activity + await expect(carol.getByRole("heading", {name: "Featured"})).toBeVisible() + await expect(carol.getByText("Start with the harbor rules")).toBeVisible() + + await carol.getByRole("link", {name: "View all members"}).click() + + await expect(carol).toHaveURL(/\/spaces\/space\.test\/directory/) +}) + +test("US-016 catch up on a space's recent activity", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.room("quiet", {name: "Quiet Corner"}) + space.join(user.alice, "general", "quiet") + space.join(user.bob, "general", "quiet") + space.message(user.alice, "general", "the tide is high", at(30, MINUTE)) + space.message(user.alice, "quiet", "anyone still here?", at(3, HOUR)) + space.event( + user.alice, + makeEvent(THREAD, { + created_at: at(2, HOUR), + content: "Where should we sail next?", + tags: [ + ["h", "general"], + ["title", "Next voyage"], + ], + }), + ) + + // More items than the page renders at once, so the oldest is only reachable by scrolling + for (let day = 1; day <= 20; day++) { + space.event( + user.alice, + makeEvent(THREAD, { + created_at: at(day, DAY), + content: `Log entry ${day}`, + tags: [ + ["h", "general"], + ["title", `Old voyage ${day}`], + ], + }), + ) + } + + // A space with a member and nothing else + other.join(user.bob) + }) + + const space = scenario.space("space") + const other = scenario.space("other") + const bob = await as(users.bob, spacePath(space.url) + "/recent") + const items = bob.locator(".cv") + + await expect(bob.getByText("the tide is high")).toBeVisible() + await expect(bob.getByText("anyone still here?")).toBeVisible() + await expect(bob.getByText("Next voyage")).toBeVisible() + + // Newest first: the latest message in each room alongside the thread, in one feed + await expect(items.first()).toContainText("the tide is high") + + await bob.mouse.move(640, 400) + await bob.mouse.wheel(0, 8000) + + await expect(bob.getByText("Old voyage 20")).toBeVisible() + + // A message in the room that had gone quiet pulls it back to the top + const alice = await as(users.alice, roomPath(space.url, "quiet")) + + await alice.locator(".chat-editor [contenteditable=true]").pressSequentially("still here!") + await alice.locator(".chat-editor [contenteditable=true]").press("Enter") + + await expect(alice.getByText("still here!")).toBeVisible() + await expect(items.first()).toContainText("Quiet Corner") + + await bob.goto(spacePath(other.url) + "/recent") + + await expect(bob.getByText("No recent activity found!")).toBeVisible() +}) + +test("US-017 search across a space", 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", "random") + space.message(user.alice, "general", "the kraken surfaced at dawn", at(2, HOUR)) + space.message(user.alice, "random", "kraken jokes only in here", at(3, DAY)) + space.message(user.alice, "general", "ancient kraken lore, volume one", at(3, WEEK)) + }) + + const space = scenario.space("space") + + // The directory rather than a feed: search is reachable from every page in a space, and this is + // one where a result's own text can't also be on the page behind the dialog. + const page = await as(users.alice, spacePath(space.url) + "/directory") + + await page.locator(".secondary-nav").getByRole("button", {name: "Search"}).click() + + const term = page.getByPlaceholder("Search this space...") + + await term.fill("narwhal") + + await expect(page.getByText("No results found.")).toBeVisible() + + await term.fill("kraken") + + await expect(page.getByText("Last 24 Hours")).toBeVisible() + await expect(page.getByText("Last 7 Days")).toBeVisible() + await expect(page.getByText("Older")).toBeVisible() + + await expect(page.getByText("the kraken surfaced at dawn")).toBeVisible() + await expect(page.getByText("ancient kraken lore, volume one")).toBeVisible() + + const fromRandom = page.getByRole("button").filter({hasText: "kraken jokes only in here"}) + + await expect(fromRandom).toContainText("Random") + + await fromRandom.click() + + await expect(page).toHaveURL(/\/spaces\/space\.test\/random\?at=/) +}) diff --git a/eslint.config.js b/eslint.config.js index 7d6c1ba2..52c52e7e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -29,6 +29,9 @@ export default [ rules: { // eslint doesn't see type parameters declared by %sveltekit.head% diff --git a/src/app/access.ts b/src/app/access.ts index 361e1b23..f6b93a23 100644 --- a/src/app/access.ts +++ b/src/app/access.ts @@ -10,13 +10,13 @@ import { FOLLOWS, MESSAGING_RELAYS, PROFILE, - RELAY_INVITE, RELAYS, + type ManagementResponse, } from "@welshman/util" -import {RelayInvite, RelayJoin, RelayLeave, RoomJoin} from "@welshman/domain" +import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain" import {Sync, User, publish} from "@welshman/app" import {stripPrefix} from "@lib/util" -import {app, command, network, reader, roomLists, thunks, writer} from "@app/core" +import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core" import {PLATFORM_URL} from "@app/env" import {relaysMostlyRestricted} from "@app/policies" import {Push} from "@app/push" @@ -100,20 +100,52 @@ export const deriveRelayAuthError = (url: string) => } }) -export const publishJoinRequest = (url: string, claim = "") => - command(writer(RelayJoin).forceRelays(url).setClaim(claim)).then(publish) +export const publishJoinRequest = (url: string, claim?: string) => { + const eventWriter = writer(RelayJoin).forceRelays(url) + + if (claim) { + eventWriter.setClaim(claim) + } + + return command(eventWriter).then(publish) +} export const publishLeaveRequest = (url: string) => command(writer(RelayLeave).forceRelays(url)).then(publish) -export const publishRoomJoinRequest = (url: string, h: string, code?: string) => { +// A relay answers a re-sent request with "duplicate:" and a membership it already has with +// "already a member" — both leave us where we wanted to be, so only anything else is a refusal. +const isMembershipRefusal = (error: string) => + Boolean(error) && !error.startsWith("duplicate:") && !error.includes("already") + +// Joining a room takes two publishes: a NIP-29 request the relay can refuse, and the user's +// own room list, which is what puts the room in their sidebar. `code` is a room invite code. +export const joinRoom = async (url: string, h: string, code?: string) => { const eventWriter = writer(RoomJoin).setRoom(url, h) if (code) { eventWriter.setClaim(code) } - return command(eventWriter).then(publish) + const thunk = await command(eventWriter).then(publish) + const error = await thunk.waitForError() + + if (isMembershipRefusal(error)) { + return error + } + + await roomLists.get().addRoom(h, url).then(publish) +} + +export const leaveRoom = async (url: string, h: string) => { + const thunk = await command(writer(RoomLeave).setRoom(url, h)).then(publish) + const error = await thunk.waitForError() + + if (isMembershipRefusal(error)) { + return error + } + + await roomLists.get().removeRoom(h, url).then(publish) } export const publishRoomInvite = async (url: string, h: string) => { @@ -198,12 +230,13 @@ export const attemptRelayAccess = async (url: string, claim = "") => { if (shouldIgnoreError(error)) return - if (claim) { - if (error.includes("invite code")) { - return "join request rejected" - } - } else if (error.includes("invite code")) { - return + if (error.includes("invite code")) { + return "join request rejected" + } + + // A space that isn't open to the public refuses a join carrying no claim at all + if (error.includes("claim")) { + return "This space requires an invite code" } return stripPrefix(error) @@ -316,17 +349,6 @@ export class Access { } } - async joinRoom(h: string, code?: string) { - if (!code) return - - const thunk = await publishRoomJoinRequest(this.url, h, code) - const message = await thunk.waitForError() - - if (message && !message.startsWith("duplicate:")) { - return message - } - } - async acceptInvite(data: InviteData, notifications: boolean) { const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get() const error = await this.joinSpace({ @@ -340,41 +362,51 @@ export class Access { } if (data.h && data.code) { - return this.joinRoom(data.h, data.code) + return joinRoom(this.url, data.h, data.code) } } async prepareInvite(h?: string) { this.loading.set(true) - // A request that times out or hits a closed socket resolves with no events, so an - // empty result is the only signal we get that the relay never answered. - const [events, roomInviteResult] = await Promise.all([ - network.get().request({ - relays: [this.url], - autoClose: true, - signal: AbortSignal.timeout(10000), - filters: [{kinds: [RELAY_INVITE]}], - }), - h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}), - // Keep the spinner up long enough that a fast relay doesn't make it flash - sleep(300), - ]) + try { + const management = relayManagement.get().forUrl(this.url) - if (events[0]) { - const eventReader = reader(RelayInvite) - const invite = await eventReader(events[0]) + const [{result: methods}, roomInviteResult] = await Promise.all([ + management.supportedMethods().catch((error): ManagementResponse => { + console.error(error) + return {} + }), + h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}), + // Keep the spinner up long enough that a fast relay doesn't make it flash + sleep(300), + ]) - this.claim.set(invite.claim() ?? "") + // The relay reports methods relay-wide rather than per-user, so a listed method can + // still come back "blocked" for this particular user — treat that as having no claim. + if (methods?.includes("createclaim")) { + const {result: claims} = await management.listClaims() + + if (claims?.[0]) { + this.claim.set(claims[0]) + } else { + const claim = randomId() + const {error} = await management.createClaim(claim) + + if (error) { + this.claimFailed.set(true) + } else { + this.claim.set(claim) + } + } + } + + if (h) { + this.roomCode.set(roomInviteResult.code) + this.roomInviteError.set(roomInviteResult.error) + } + } finally { + this.loading.set(false) } - - this.claimFailed.set(events.length === 0) - - if (h) { - this.roomCode.set(roomInviteResult.code) - this.roomInviteError.set(roomInviteResult.error) - } - - this.loading.set(false) } } diff --git a/src/app/actionItems.ts b/src/app/actionItems.ts index bd551bd4..d2c254c1 100644 --- a/src/app/actionItems.ts +++ b/src/app/actionItems.ts @@ -1,111 +1,12 @@ import {derived} from "svelte/store" -import {first, groupBy, removeUndefined, spec} from "@welshman/lib" -import { - REPORT, - ROOM_ADD_MEMBER, - ROOM_JOIN, - ROOM_LEAVE, - ROOM_MEMBERS, - ROOM_REMOVE_MEMBER, - hexTags, - sortEventsAsc, - sortEventsDesc, - tagSpec, - tagValue, - tagValues, -} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" +import {REPORT, sortEventsDesc} from "@welshman/util" +import {rooms} from "@app/core" import {deriveEventsForUrl} from "@app/repository" -// A room's membership replayed in order — each member list is a snapshot that resets -// the set, with add/remove events applied on top. -const getRoomMembers = (events: TrustedEvent[]) => { - const members = new Set() - - for (const event of sortEventsAsc(events)) { - const pubkeys = tagValues(hexTags("p"), event.tags) - - if (event.kind === ROOM_MEMBERS) { - members.clear() - pubkeys.forEach(pubkey => members.add(pubkey)) - } else if (event.kind === ROOM_ADD_MEMBER) { - pubkeys.forEach(pubkey => members.add(pubkey)) - } else if (event.kind === ROOM_REMOVE_MEMBER) { - pubkeys.forEach(pubkey => members.delete(pubkey)) - } - } - - return members -} - // Action items (admin review queue) export const deriveSpaceActionItems = (url: string) => derived( - deriveEventsForUrl(url, [ - {kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]}, - ]), - $events => { - const getRoomId = (e: TrustedEvent) => - tagValue(tagSpec(e.kind === ROOM_MEMBERS ? "d" : "h"), e.tags) - const reports = $events.filter(spec({kind: REPORT})) - const pendingJoins: TrustedEvent[] = [] - - // Room-level join requests — most recent per pubkey+h - for (const [h, roomEvents] of groupBy(getRoomId, $events)) { - if (!h) continue - - const roomJoins: TrustedEvent[] = [] - const roomLeaves: TrustedEvent[] = [] - const roomMembershipEvents: TrustedEvent[] = [] - - for (const event of roomEvents) { - switch (event.kind) { - case ROOM_JOIN: - roomJoins.push(event) - break - case ROOM_LEAVE: - roomLeaves.push(event) - break - case ROOM_MEMBERS: - case ROOM_ADD_MEMBER: - case ROOM_REMOVE_MEMBER: - roomMembershipEvents.push(event) - break - } - } - - const roomMembers = getRoomMembers(roomMembershipEvents) - - pendingJoins.push( - ...removeUndefined( - Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events => - first(sortEventsDesc(events)), - ), - ).filter(({pubkey, created_at}) => { - if (roomMembers.has(pubkey)) return false - if ( - roomMembershipEvents.some(event => { - if (event.created_at <= created_at) { - return false - } - - if (event.kind === ROOM_MEMBERS) { - return true - } - - return tagValues(hexTags("p"), event.tags).includes(pubkey) - }) - ) { - return false - } - if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false - - return true - }), - ) - } - - return sortEventsDesc([...reports, ...pendingJoins]) - }, + [deriveEventsForUrl(url, [{kinds: [REPORT]}]), rooms.get().pendingJoins(url).$], + ([$reports, $pendingJoins]) => sortEventsDesc([...$reports, ...$pendingJoins]), ) diff --git a/src/app/call.ts b/src/app/call.ts index ccc2364b..8e3e4752 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -642,6 +642,11 @@ export const participantKey = (p: CallParticipant) => p.pubkey ?? p.liveKitIdent let activeRoom: LiveKitRoom | undefined let reconnectTimeout: ReturnType | undefined let reconnectAttempt = 0 +// Captured from the dropped session so a full rejoin (as opposed to LiveKit's +// own internal reconnect, which reuses the existing tracks) restores the +// user's mic state instead of always rejoining muted. +let reconnectMicMuted = true +let reconnectMicDeviceId: string | undefined let joinAbortController: AbortController | undefined let hadCallSession = false @@ -814,7 +819,7 @@ const attemptReconnect = async () => { if (!target) return try { - await joinVoiceRoom(target.url, target.h) + await joinVoiceRoom(target.url, target.h, reconnectMicMuted, reconnectMicDeviceId) } catch { if (reconnectAttempt >= RECONNECT_DELAYS.length) { pushToast({theme: "error", message: "Voice connection lost."}) @@ -855,6 +860,12 @@ const makeOnRoomDisconnected = (livekit: LiveKitRoom) => (reason?: DisconnectRea activeRoom = undefined livekit.removeAllListeners() + // Capture mic state before resetting it, so a subsequent full rejoin (see + // scheduleReconnect/attemptReconnect below) can restore it instead of + // silently coming back muted regardless of what the user had set. + reconnectMicMuted = get(callMicMuted) + reconnectMicDeviceId = livekit.getActiveDevice(DeviceKind.AudioInput) + callMicMuted.set(true) currentCallSession.set(undefined) if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { diff --git a/src/app/components/ArticleActions.svelte b/src/app/components/ArticleActions.svelte new file mode 100644 index 00000000..15ed4bba --- /dev/null +++ b/src/app/components/ArticleActions.svelte @@ -0,0 +1,56 @@ + + +
+ {#if h && showRoom} + + Posted in # + + {/if} +
+ {#each uniq(topics) as topic (topic)} + + {/each} +
+ + + {#if showActivity} + + {/if} + + +
diff --git a/src/app/components/ArticleCreate.svelte b/src/app/components/ArticleCreate.svelte new file mode 100644 index 00000000..0d1ab32c --- /dev/null +++ b/src/app/components/ArticleCreate.svelte @@ -0,0 +1,215 @@ + + + + + + Write an Article + Publish a long-form post to this space. + +
+ + {#snippet label()} +

Title*

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ + {#snippet label()} +

Summary

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ + {#snippet label()} +

Topics

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ + {#snippet label()} +

Content*

+ {/snippet} + {#snippet input()} +
+ +
+ {/snippet} +
+ +
+
+ + + + +
diff --git a/src/app/components/ArticleItem.svelte b/src/app/components/ArticleItem.svelte new file mode 100644 index 00000000..b88f4f34 --- /dev/null +++ b/src/app/components/ArticleItem.svelte @@ -0,0 +1,59 @@ + + +
+ + +
+ {#if image} + + {/if} +
+

{title || "Untitled"}

+

+ {formatTimestamp(article.publishedAt())} +

+
+ +
+ + Written by + + + +
+
+
diff --git a/src/app/components/ArticleSidebar.svelte b/src/app/components/ArticleSidebar.svelte new file mode 100644 index 00000000..304336dc --- /dev/null +++ b/src/app/components/ArticleSidebar.svelte @@ -0,0 +1,80 @@ + + + diff --git a/src/app/components/CalendarEventActions.svelte b/src/app/components/CalendarEventActions.svelte index 5d429b83..5199fbc5 100644 --- a/src/app/components/CalendarEventActions.svelte +++ b/src/app/components/CalendarEventActions.svelte @@ -41,21 +41,22 @@ Posted in # {/if} - - - {#if showActivity} - - {/if} - - {#snippet customActions()} - {#if event.pubkey === $user.pubkey} -
  • - -
  • - {/if} - {/snippet} -
    + + + {#if showActivity} + + {/if} + + {#snippet customActions()} + {#if event.pubkey === $user.pubkey} +
  • + +
  • + {/if} + {/snippet} +
    +
    diff --git a/src/app/components/CalendarEventDate.svelte b/src/app/components/CalendarEventDate.svelte index 7e3a6a99..2cf63167 100644 --- a/src/app/components/CalendarEventDate.svelte +++ b/src/app/components/CalendarEventDate.svelte @@ -10,9 +10,9 @@ const {event}: Props = $props() - const timeEvent = reader(TimeEvent)(event) + const timeEvent = $derived(reader(TimeEvent)(event)) - const start = timeEvent.start() + const start = $derived(timeEvent.start()) {#if start} diff --git a/src/app/components/CalendarEventHeader.svelte b/src/app/components/CalendarEventHeader.svelte index 9d5b5208..ce61ee43 100644 --- a/src/app/components/CalendarEventHeader.svelte +++ b/src/app/components/CalendarEventHeader.svelte @@ -13,12 +13,12 @@ const {event}: Props = $props() - const timeEvent = reader(TimeEvent)(event) + const timeEvent = $derived(reader(TimeEvent)(event)) // NIP-52 settled on `title`, but events from before that still carry `name`. const title = $derived(timeEvent.title() ?? tagValue(tagSpec("name"), event.tags)) - const start = timeEvent.start() - const end = timeEvent.end() + const start = $derived(timeEvent.start()) + const end = $derived(timeEvent.end())
    diff --git a/src/app/components/CalendarEventMeta.svelte b/src/app/components/CalendarEventMeta.svelte index afa315cc..7a84ed4c 100644 --- a/src/app/components/CalendarEventMeta.svelte +++ b/src/app/components/CalendarEventMeta.svelte @@ -14,9 +14,9 @@ const {event, url}: Props = $props() - const timeEvent = reader(TimeEvent)(event) + const timeEvent = $derived(reader(TimeEvent)(event)) - const location = timeEvent.location() + const location = $derived(timeEvent.location())
    diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index 94ae8fb6..ea5462f7 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -179,7 +179,7 @@ eventToEdit = event } - const onEditPrevious = () => ifLet($chat?.messages.toReversed().find(canEditEvent), onEditEvent) + const onEditPrevious = () => ifLet($chat?.messages.find(canEditEvent), onEditEvent) let loading = $state(true) let compose: ChatCompose | undefined = $state() @@ -300,7 +300,7 @@ {#each missingRelayLists as pubkey (pubkey)} {/each} - to enable direct messaging by opening this conversation in their $app. + to enable direct messaging by opening this conversation in their client.

    diff --git a/src/app/components/ChatComposeParent.svelte b/src/app/components/ChatComposeParent.svelte index a9f9ecff..b2c63bb4 100644 --- a/src/app/components/ChatComposeParent.svelte +++ b/src/app/components/ChatComposeParent.svelte @@ -1,4 +1,5 @@
    {/each}
    - - - - {#if showActivity} - - {/if} - - {#snippet customActions()} - {#if event.pubkey === $user.pubkey} -
  • - -
  • - {/if} + {#snippet status()} + {/snippet} -
    + + {#if showActivity} + + {/if} + + {#snippet customActions()} + {#if event.pubkey === $user.pubkey} +
  • + +
  • + {/if} + {/snippet} +
    +
    diff --git a/src/app/components/CommentActions.svelte b/src/app/components/CommentActions.svelte index 4c379480..90fc1589 100644 --- a/src/app/components/CommentActions.svelte +++ b/src/app/components/CommentActions.svelte @@ -11,7 +11,7 @@ type Props = { url: string event: TrustedEvent - segment: string + segment?: string showActivity?: boolean } @@ -19,7 +19,7 @@ const h = tagValue(tagSpec("h"), event.tags) - const path = makeSpacePath(url, segment, event.id) + const path = segment && makeSpacePath(url, segment, event.id) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) @@ -28,11 +28,12 @@
    - - - {#if showActivity} - - {/if} - + + + {#if showActivity && path} + + {/if} + +
    diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte new file mode 100644 index 00000000..63f1c200 --- /dev/null +++ b/src/app/components/CommentCompose.svelte @@ -0,0 +1,121 @@ + + +
    +
    +
    + +
    + +
    +
    + + +
    +
    diff --git a/src/app/components/CommentTree.svelte b/src/app/components/CommentTree.svelte new file mode 100644 index 00000000..427b6e5a --- /dev/null +++ b/src/app/components/CommentTree.svelte @@ -0,0 +1,62 @@ + + +
    + +
    + + {#if url} +
    + + +
    + {/if} +
    +
    + {#if composing && url} +
    + +
    + {/if} + {#if node.children.length > 0} +
    + {#each node.children as child (child.comment.id)} + + {/each} +
    + {/if} +
    diff --git a/src/app/components/ComposeMenu.svelte b/src/app/components/ComposeMenu.svelte index cc55fada..0fba2249 100644 --- a/src/app/components/ComposeMenu.svelte +++ b/src/app/components/ComposeMenu.svelte @@ -4,12 +4,14 @@ import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl" import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl" + import DocumentText from "@assets/icons/document-text.svg?dataurl" import Revote from "@assets/icons/revote.svg?dataurl" import Button from "@lib/components/Button.svelte" import Icon from "@lib/components/Icon.svelte" import {pushModal} from "@app/modal" import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte" import ThreadCreate from "@app/components/ThreadCreate.svelte" + import ArticleCreate from "@app/components/ArticleCreate.svelte" import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte" import GoalCreate from "@app/components/GoalCreate.svelte" import PollCreate from "@app/components/PollCreate.svelte" @@ -32,6 +34,8 @@ const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true}) + const createArticle = () => pushModal(ArticleCreate, {url, h, shareToChat: true}) + let ul: Element onMount(() => { @@ -64,6 +68,12 @@ Create Thread +
  • + +
  • diff --git a/src/app/components/ContentMarkdown.svelte b/src/app/components/ContentMarkdown.svelte new file mode 100644 index 00000000..9c490be9 --- /dev/null +++ b/src/app/components/ContentMarkdown.svelte @@ -0,0 +1,123 @@ + + + + + + +
    + {@html html} +
    diff --git a/src/app/components/ContentMention.svelte b/src/app/components/ContentMention.svelte index 9a2ecd6a..00208b4c 100644 --- a/src/app/components/ContentMention.svelte +++ b/src/app/components/ContentMention.svelte @@ -1,4 +1,5 @@ diff --git a/src/app/components/ContentMinimal.svelte b/src/app/components/ContentMinimal.svelte index 5d934184..c09203f4 100644 --- a/src/app/components/ContentMinimal.svelte +++ b/src/app/components/ContentMinimal.svelte @@ -114,9 +114,10 @@ "overflow-hidden text-ellipsis", singleLine ? "whitespace-nowrap" : "wrap-break-word", )}> - {#each shortContent as parsed, i} + {#each shortContent as parsed, i (i)} {#if isNewline(parsed)} {#if singleLine} + {" "} {:else} diff --git a/src/app/components/ContentNewline.svelte b/src/app/components/ContentNewline.svelte index 67da4cf6..17739a85 100644 --- a/src/app/components/ContentNewline.svelte +++ b/src/app/components/ContentNewline.svelte @@ -2,6 +2,6 @@ const {value} = $props() -{#each value as _} +{#each value as _, i (i)}
    {/each} diff --git a/src/app/components/ContentQuote.svelte b/src/app/components/ContentQuote.svelte index 49cb05af..5fbf6188 100644 --- a/src/app/components/ContentQuote.svelte +++ b/src/app/components/ContentQuote.svelte @@ -29,7 +29,7 @@ const {value, event, url}: Props = $props() - const {id, identifier, kind, pubkey, relays = []} = value + const {id, identifier, kind, pubkey = value.author, relays = []} = value const idOrAddress = id || new Address(kind, pubkey, identifier).toString() const ref = {id, pubkey, kind, identifier, relays} const hints = removeUndefined([...relays, url]) diff --git a/src/app/components/EventActivity.svelte b/src/app/components/EventActivity.svelte index 44ebfea0..1254199f 100644 --- a/src/app/components/EventActivity.svelte +++ b/src/app/components/EventActivity.svelte @@ -1,7 +1,7 @@ + +
    + {#each nodes as node (node.comment.id)} + + {/each} +
    +{#if url} + {#if replyTo?.id === event.id} + + {:else} +
    + +
    + {/if} +{/if} diff --git a/src/app/components/EventDeleteConfirm.svelte b/src/app/components/EventDeleteConfirm.svelte index 8a8c945e..e04be9e2 100644 --- a/src/app/components/EventDeleteConfirm.svelte +++ b/src/app/components/EventDeleteConfirm.svelte @@ -3,6 +3,7 @@ import Confirm from "@lib/components/Confirm.svelte" import {deletes, relays} from "@app/core" import {clearModals} from "@app/modal" + import {pushToast} from "@app/toast" type Props = { url: string @@ -16,8 +17,11 @@ const confirm = async () => { const protect = await shouldProtect const command = await $deletes.deleteEvent(event, writer => writer.setProtected(protect)) + const error = await command.publishToRelays([url]).waitForError() - command.publishToRelays([url]) + if (error) { + return pushToast({theme: "error", message: error}) + } clearModals() } diff --git a/src/app/components/EventReply.svelte b/src/app/components/EventReply.svelte index 4539dfe0..bb8df47e 100644 --- a/src/app/components/EventReply.svelte +++ b/src/app/components/EventReply.svelte @@ -109,7 +109,7 @@ class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
    {#if parent} - onClearParent?.()} verb="Replying to" /> + onClearParent?.()} verb="Replying to" /> {/if}
    diff --git a/src/app/components/GoalActions.svelte b/src/app/components/GoalActions.svelte index 315ae032..67b2deec 100644 --- a/src/app/components/GoalActions.svelte +++ b/src/app/components/GoalActions.svelte @@ -36,10 +36,11 @@ Posted in # {/if} - - - {#if showActivity} - - {/if} - + + + {#if showActivity} + + {/if} + +
    diff --git a/src/app/components/IconPicker.svelte b/src/app/components/IconPicker.svelte index 76f1fd9e..8baa8400 100644 --- a/src/app/components/IconPicker.svelte +++ b/src/app/components/IconPicker.svelte @@ -49,7 +49,7 @@
    - {#each filteredIcons as icon} + {#each filteredIcons as icon (icon.url)} {/if} - {#each groupedZaps.entries() as [key, zaps]} + {#each groupedZaps.entries() as [key, zaps] (key)} {@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))} {@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))} {@const isOwn = pubkeys.includes($user.pubkey)} - {@const info = displayList(pubkeys.map(pubkey => $profiles.display(pubkey).get()))} + {@const info = displayList( + pubkeys.map(pubkey => $profiles.display(pubkey, removeUndefined([url])).get()), + )} {@const tooltip = `${info} zapped`} {@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)} {/each} - {#each groupedReactions.entries() as [key, events]} + {#each groupedReactions.entries() as [key, events] (key)} {@const pubkeys = events.map(e => e.pubkey)} {@const isOwn = pubkeys.includes($user.pubkey)} - {@const info = displayList(pubkeys.map(pubkey => $profiles.display(pubkey).get()))} + {@const info = displayList( + pubkeys.map(pubkey => $profiles.display(pubkey, removeUndefined([url])).get()), + )} {@const tooltip = `${info} reacted`} {@const onClick = () => onReactionClick(events, pubkeys, info)} + {:else} + onclick={onClick} + class="hover:bg-surface-less cursor-pointer border-b border-solid border-line text-sm transition-colors"> + {#if $notifications.has(path)} +
    + {/if} {title || "Untitled thread"} diff --git a/src/app/components/ThreadCreate.svelte b/src/app/components/ThreadCreate.svelte index 7a578fb2..2bcdcb77 100644 --- a/src/app/components/ThreadCreate.svelte +++ b/src/app/components/ThreadCreate.svelte @@ -31,11 +31,12 @@ url: string h?: string shareToChat?: boolean + initialValues?: Values } - const {url, h, shareToChat = false}: Props = $props() + const {url, h, shareToChat = false, initialValues}: Props = $props() const draftKey = new DraftKey(`thread:${url}:${h ?? ""}`) - const initialValues = draftKey.get() + const draft = draftKey.get() const shouldProtect = $relays.hasNip(url, 70) const uploading = writable(false) @@ -99,8 +100,8 @@ let loading = $state(false) - let title = $state(initialValues?.title ?? "") - let content = $state(initialValues?.content ?? "") + let title = $state(initialValues?.title ?? draft?.title ?? "") + let content = $state(initialValues?.content ?? draft?.content ?? "") const onChange = (json: object) => { content = json diff --git a/src/app/components/ThreadPagination.svelte b/src/app/components/ThreadPagination.svelte index d571993c..a091084d 100644 --- a/src/app/components/ThreadPagination.svelte +++ b/src/app/components/ThreadPagination.svelte @@ -37,7 +37,8 @@ }) -
    +
    +

    Page {page} of {pageCount}

    {/if} diff --git a/src/app/components/ThunkStatusDetail.svelte b/src/app/components/ThunkStatusDetail.svelte index 25619488..b5997c20 100644 --- a/src/app/components/ThunkStatusDetail.svelte +++ b/src/app/components/ThunkStatusDetail.svelte @@ -26,14 +26,14 @@ ) const relayMessage = (status: PublishStatus | undefined, detail: string | undefined) => { - if (detail) { - return detail - } - if (status === PublishStatus.Timeout) { return "request timed out" } + if (detail) { + return detail + } + return "no details received" } diff --git a/src/app/components/ThunkStatusOrDeleted.svelte b/src/app/components/ThunkStatusOrDeleted.svelte index cd4d8787..8682cbcf 100644 --- a/src/app/components/ThunkStatusOrDeleted.svelte +++ b/src/app/components/ThunkStatusOrDeleted.svelte @@ -8,20 +8,25 @@ type Props = { event: TrustedEvent + status?: Snippet children?: Snippet } - const {event, children}: Props = $props() + const {event, status, children}: Props = $props() - const deleted = deriveIsDeleted(event) + // Editing a replaceable event hands this a different event + const deleted = $derived(deriveIsDeleted(event)) const history = $thunks.history const thunk = $derived($thunks.merge($history.filter(t => t.event.id === event.id))) {#if $deleted}
    Deleted
    -{:else if thunk.thunks.length > 0 && !thunk.hasStatus(PublishStatus.Success)} - -{:else if children} +{:else} + {#if thunk.thunks.length > 0 && !thunk.hasStatus(PublishStatus.Success)} + + {:else} + {@render status?.()} + {/if} {@render children?.()} {/if} diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index f48fd691..f67978d3 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -172,13 +172,13 @@ $effect(() => { for (const t of videoTiles) { const pk = pubkeyFromLiveKitIdentity(t.liveKitIdentity) - if (pk) $profiles.load(pk) + if (pk) $profiles.load(pk, [url]) } }) const labelFor = (liveKitIdentity: string, source: VideoTileData["source"]) => { const pk = pubkeyFromLiveKitIdentity(liveKitIdentity) - const name = pk ? $profiles.display(pk).get() : "Unknown" + const name = pk ? $profiles.display(pk, [url]).get() : "Unknown" return source === Track.Source.ScreenShare ? `${name} · screen` : name } @@ -275,7 +275,7 @@ class="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]"> {#if tileGrid}
    - {#each tileGrid.rows as row, rowIndex} + {#each tileGrid.rows as row, rowIndex (rowIndex)} {@const offset = tileGrid.rows .slice(0, rowIndex) .reduce((sum, r) => sum + r.columnCount, 0)} diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index a0c6b401..a9166441 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -68,7 +68,7 @@ $effect(() => { for (const p of $participants) { - if (p.pubkey) $profiles.load(p.pubkey) + if (p.pubkey) $profiles.load(p.pubkey, [url]) } }) @@ -102,7 +102,7 @@
    - {p.pubkey ? $profiles.display(p.pubkey).get() : "Unknown"} + {p.pubkey ? $profiles.display(p.pubkey, [url]).get() : "Unknown"}
    - {#each $zapAmounts as preset} + {#each $zapAmounts as preset, i (i)} -
    - {/snippet} +
    +

    + Connect a wallet to pay instantly without scanning a QR code. +

    + +
    {/if} diff --git a/src/app/content.ts b/src/app/content.ts index 39194ad5..259dbf88 100644 --- a/src/app/content.ts +++ b/src/app/content.ts @@ -6,6 +6,8 @@ import { DIRECT_MESSAGE_FILE, EVENT_TIME, GENERIC_REPOST, + PINBOARD, + LONG_FORM, POLL, REACTION, REPORT, @@ -40,7 +42,7 @@ if (ENABLE_ZAPS) { REACTION_KINDS.push(ZAP_RECEIPT) } -export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL] +export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL, PINBOARD, LONG_FORM] export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] diff --git a/src/app/editor/EditorContent.svelte b/src/app/editor/EditorContent.svelte index a9c9b009..d13c6f78 100644 --- a/src/app/editor/EditorContent.svelte +++ b/src/app/editor/EditorContent.svelte @@ -14,6 +14,9 @@ onMount(() => { editor.then(ed => { if (ed.options.element) { + // Tiptap builds and owns its editor element, so mounting it here is the point of this + // component rather than something svelte could be left to render. + // eslint-disable-next-line svelte/no-dom-manipulating element?.append(ed.options.element) } diff --git a/src/app/editor/ProfileSuggestion.svelte b/src/app/editor/ProfileSuggestion.svelte index 00e8f016..fe45f3a2 100644 --- a/src/app/editor/ProfileSuggestion.svelte +++ b/src/app/editor/ProfileSuggestion.svelte @@ -1,4 +1,5 @@ diff --git a/src/app/management.ts b/src/app/management.ts index a63c0c62..aa35c387 100644 --- a/src/app/management.ts +++ b/src/app/management.ts @@ -21,9 +21,6 @@ export const deriveSpaceBannedPubkeyItems = (url: string) => { return store } -// A relay only answers NIP-86 for its admins, so the methods it reports double as the user's -// permissions. This costs a signature and an http round trip, so share one store per url and -// only re-check when the answer has had time to go stale (or when a check failed). export const deriveSpaceSupportedMethods = simpleCache(([url]: [string | undefined]) => { let checkedAt = 0 @@ -46,6 +43,8 @@ export const deriveSpaceSupportedMethods = simpleCache(([url]: [string | undefin // User +// A relay answers supportedmethods with everything it implements rather than with what the +// caller may use, so all this can tell us is that the call wasn't refused outright. export const deriveUserIsSpaceAdmin = (url?: string) => derived(deriveSpaceSupportedMethods(url), $methods => $methods.length > 0) diff --git a/src/app/notifications.ts b/src/app/notifications.ts index cf7bc9b6..e95f6f0b 100644 --- a/src/app/notifications.ts +++ b/src/app/notifications.ts @@ -1,7 +1,18 @@ import {derived, get, writable} from "svelte/store" import {Badge} from "@capawesome/capacitor-badge" import {page} from "$app/stores" -import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib" +import { + assoc, + prop, + first, + identity, + groupBy, + now, + remove, + throttle, + parseJson, + gt, +} from "@welshman/lib" import type {SignedEvent, TrustedEvent} from "@welshman/util" import { getIdOrAddress, @@ -17,13 +28,7 @@ import {synced, throttled, withGetter} from "@welshman/store" import {Relays, RoomLists} from "@welshman/app" import {deriveEventsByIdByUrl} from "@app/repository" import {app, fromApp} from "@app/core" -import { - makeSpacePath, - makeRoomPath, - makeSpaceChatPath, - makeChatPath, - makeContentPath, -} from "@app/routes" +import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes" import {CONTENT_KINDS, makeCommentFilter} from "@app/content" import {notificationSettings} from "@app/settings" import {chatsById} from "@app/chats" @@ -49,15 +54,15 @@ export const setChecked = (key: string) => checked.update(assoc(key, now())) /** Room path while video call UI hides chat; checked + badge stay active until chat is shown. */ export const deferredRoomPath = writable(undefined) +const getPaths = (path: string) => + path + .split("/") + .map((_, i, segments) => segments.slice(0, i + 1).join("/")) + .slice(1) + export const syncChecked = () => { let prev = "" - const getPaths = (path: string) => - path - .split("/") - .map((_, i, segments) => segments.slice(0, i + 1).join("/")) - .slice(1) - return page.subscribe($page => { // Set checked when we leave a given page checked.update($checked => { @@ -200,7 +205,7 @@ const getContentTarget = (event: TrustedEvent) => { // Assumes `events` is sorted descending, so the first event seen per content item wins. const latestEventByContentPath = (url: string, events: TrustedEvent[]) => { - const byPath = new Map() + const byPath = new Map() for (const event of events) { const target = getContentTarget(event) @@ -208,23 +213,21 @@ const latestEventByContentPath = (url: string, events: TrustedEvent[]) => { if (!target) continue const path = makeContentPath(url, target.kind, target.idOrAddress) - const listPath = makeContentPath(url, target.kind) - if (path && listPath && !byPath.has(path)) { - byPath.set(path, {listPath, latestEvent: event}) + if (path && !byPath.has(path)) { + byPath.set(path, event) } } return byPath } -export const allNotifications = derived( +export const latestActivityByPath = derived( throttled( 1000, derived( [ app, - checked, chatsById, fromApp($app => $app.use(Relays).index.$), fromApp($app => $app.use(RoomLists).index.$), @@ -236,17 +239,56 @@ export const allNotifications = derived( identity, ), ), - ([$app, $checked, $chatsById, $relays, $roomLists, eventsByIdByUrl]) => { - const hasNotification = (path: string, latestEvent?: TrustedEvent) => { - if (!latestEvent || latestEvent.pubkey === $app.user?.pubkey) { + ([$app, $chatsById, $relays, $roomLists, eventsByIdByUrl]) => { + const activity = new Map() + + for (const {pubkeys, messages} of $chatsById.values()) { + if (messages[0]) { + activity.set(makeChatPath(pubkeys), messages[0]) + } + } + + const roomList = $app.user?.pubkey ? $roomLists.get($app.user.pubkey) : undefined + const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? []) + + for (const url of urls) { + const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values()) + + if ($relays.get(url)?.hasNip(29)) { + for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) { + if (h) { + activity.set(makeRoomPath(url, h), latestEvent) + } + } + } else { + const latestEvent = first(events) + + if (latestEvent) { + activity.set(makeSpaceChatPath(url), latestEvent) + } + } + + for (const [path, latestEvent] of latestEventByContentPath(url, events)) { + activity.set(path, latestEvent) + } + } + + return activity + }, +) + +export const allNotifications = derived( + [app, latestActivityByPath, checked], + ([$app, $latestActivityByPath, $checked]) => { + const hasNotification = (path: string, latestEvent: TrustedEvent) => { + if (latestEvent.pubkey === $app.user?.pubkey) { return false } for (const [entryPath, ts] of Object.entries($checked)) { - const isMatch = - entryPath === "*" || - entryPath.startsWith(path) || - (entryPath === "/chat/*" && path.startsWith("/chat/")) + const isMatch = entryPath.endsWith("*") + ? path.startsWith(entryPath.slice(0, -1)) + : entryPath.startsWith(path) if (isMatch && ts > latestEvent.created_at) { return false @@ -258,56 +300,13 @@ export const allNotifications = derived( const paths = new Set() - for (const {pubkeys, messages} of $chatsById.values()) { - const chatPath = makeChatPath(pubkeys) + for (const [path, latestEvent] of $latestActivityByPath) { + if (hasNotification(path, latestEvent)) { + paths.add(path) - if (hasNotification(chatPath, messages[0])) { - paths.add("/chat") - paths.add(chatPath) - } - } - - const roomList = $app.user?.pubkey ? $roomLists.get($app.user?.pubkey) : undefined - const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? []) - - for (const url of urls) { - const spacePath = makeSpacePath(url) - const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values()) - - if ($relays.get(url)?.hasNip(29)) { - for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) { - if (h) { - const roomPath = makeRoomPath(url, h) - - if (hasNotification(roomPath, latestEvent)) { - paths.add(roomPath) - - if (hasNotification(spacePath, latestEvent)) { - paths.add(spacePath) - } - } - } - } - } else { - const messagesPath = makeSpaceChatPath(url) - const latestEvent = first(events) - - if (hasNotification(messagesPath, latestEvent)) { - paths.add(messagesPath) - - if (hasNotification(spacePath, latestEvent)) { - paths.add(spacePath) - } - } - } - - for (const [path, {listPath, latestEvent}] of latestEventByContentPath(url, events)) { - if (hasNotification(path, latestEvent)) { - paths.add(path) - paths.add(listPath) - - if (hasNotification(spacePath, latestEvent)) { - paths.add(spacePath) + for (const branchPath of remove(path, getPaths(path.split("?")[0]))) { + if (hasNotification(branchPath, latestEvent)) { + paths.add(branchPath) } } } @@ -332,11 +331,15 @@ export const notifications = derived( // Badges export const syncBadges = () => - derived([notifications, notificationSettings], identity).subscribe( - async ([$notifications, $notificationSettings]) => { + derived([latestActivityByPath, notifications, notificationSettings], identity).subscribe( + async ([$latestActivityByPath, $notifications, $notificationSettings]) => { if ($notificationSettings.badge) { + const count = [...$latestActivityByPath.keys()].filter(path => + $notifications.has(path), + ).length + try { - await Badge.set({count: $notifications.size}) + await Badge.set({count}) } catch (err) { // pass - firefox doesn't support badges } diff --git a/src/app/policies.ts b/src/app/policies.ts index 1f29e2c0..30963328 100644 --- a/src/app/policies.ts +++ b/src/app/policies.ts @@ -122,12 +122,12 @@ const trustPolicy = (socket: Socket) => { const mostlyRestrictedPolicy = (socket: Socket) => { let total = 0 - let restricted = 0 + let refused = 0 const pending = new Set() const updateStatus = (error?: string) => { - if (total > 5 && restricted > total / 2) { + if (total > 5 && refused > total / 2) { if (error) { return relaysMostlyRestricted.update(assoc(socket.url, error)) } @@ -136,6 +136,19 @@ const mostlyRestrictedPolicy = (socket: Socket) => { } } + // NIP-01 reserves "blocked: " for a ban and "restricted: " for lacking permission. + const countDetails = (details: string) => { + if (details.startsWith("auth-required: ")) { + total-- + updateStatus() + } + + if (details.startsWith("restricted: ") || details.startsWith("blocked: ")) { + refused++ + updateStatus(details) + } + } + const unsubscribers = [ on(socket, SocketEvent.Receive, (message: RelayMessage) => { if (isRelayOk(message)) { @@ -145,15 +158,7 @@ const mostlyRestrictedPolicy = (socket: Socket) => { pending.delete(id) if (!ok) { - if (details.startsWith("auth-required: ")) { - total-- - updateStatus() - } - - if (details.startsWith("restricted: ")) { - restricted++ - updateStatus(details) - } + countDetails(details) } } } @@ -163,16 +168,7 @@ const mostlyRestrictedPolicy = (socket: Socket) => { if (pending.has(id)) { pending.delete(id) - - if (details.startsWith("auth-required: ")) { - total-- - updateStatus() - } - - if (details.startsWith("restricted: ")) { - restricted++ - updateStatus(details) - } + countDetails(details) } } }), diff --git a/src/app/rooms.ts b/src/app/rooms.ts index 8fb7995d..a94bd1c9 100644 --- a/src/app/rooms.ts +++ b/src/app/rooms.ts @@ -2,20 +2,9 @@ import * as nip19 from "nostr-tools/nip19" import {derived} from "svelte/store" import {sortBy, uniq} from "@welshman/lib" import type {Maybe} from "@welshman/lib" -import { - MESSAGE, - ROOM_ADD_MEMBER, - ROOM_JOIN, - ROOM_LEAVE, - ROOM_REMOVE_MEMBER, - makeEvent, - outbox, - seen, - sortEventsAsc, - toNostrURI, -} from "@welshman/util" +import {MESSAGE, makeEvent, outbox, seen, toNostrURI} from "@welshman/util" import type {EventContent, TrustedEvent} from "@welshman/util" -import {RoomLists, makeRoomKey, createSearch} from "@welshman/app" +import {MembershipStatus, RoomLists, makeRoomKey, createSearch} from "@welshman/app" import type {Room, RoomMeta} from "@welshman/app" import { deriveUserItem, @@ -29,15 +18,8 @@ import { user, } from "@app/core" import {deriveUserIsSpaceAdmin} from "@app/management" -import {deriveEventsForUrl} from "@app/repository" import {makeRoomPath} from "@app/routes" -export enum MembershipStatus { - Initial, - Pending, - Granted, -} - export const ROOM = "h" export const PROTECTED = ["-"] @@ -58,7 +40,7 @@ export const displayRoom = (url: string, h: string) => export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() export const deriveRoomMembers = (url: string, h: string) => - derived(rooms.get().forRoom(url, h), $room => $room?.members?.members() ?? []) + derived(rooms.get().members(url, h).$, $members => Array.from($members)) // A room member also has to be allowed at the relay level, or they won't be able to read the // room at all. @@ -181,44 +163,11 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) => $isSpaceAdmin || Boolean($room?.admins?.pubkeys().includes($user.pubkey)), ) +// Room membership is the relay's business, but a space admin outranks it. export const deriveUserRoomMembershipStatus = (url: string, h: string) => derived( - [ - user, - rooms.get().forRoom(url, h), - deriveEventsForUrl(url, [{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}]), - deriveEventsForUrl(url, [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]), - deriveUserIsRoomAdmin(url, h), - ], - ([$user, $room, $addRemoveEvents, $joinLeaveEvents, $isAdmin]) => { - if ($isAdmin) { - return MembershipStatus.Granted - } - - let isMember = false - - if ($room?.members) { - isMember = $room.members.isMember($user.pubkey) - } else { - for (const event of sortEventsAsc($addRemoveEvents)) { - if (event.pubkey === $user.pubkey) { - isMember = event.kind === ROOM_ADD_MEMBER - } - } - } - - for (const event of $joinLeaveEvents) { - if (event.pubkey === $user.pubkey) { - if (event.kind === ROOM_JOIN) { - return isMember ? MembershipStatus.Granted : MembershipStatus.Pending - } - - return MembershipStatus.Initial - } - } - - return isMember ? MembershipStatus.Granted : MembershipStatus.Initial - }, + [rooms.get().membershipStatus(url, h).$, deriveUserIsRoomAdmin(url, h)], + ([$status, $isAdmin]) => ($isAdmin ? MembershipStatus.Granted : $status), ) export const deriveUserRoomSearch = () => diff --git a/src/app/routes.ts b/src/app/routes.ts index c0e478b5..3805a410 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -8,6 +8,7 @@ import type {TrustedEvent} from "@welshman/util" import { CLASSIFIED, EVENT_TIME, + LONG_FORM, MESSAGE, PINBOARD, POLL, @@ -130,6 +131,9 @@ export const makeThreadPath = (url: string, id?: string) => makeSpacePath(url, " export const makeClassifiedPath = (url: string, address?: string) => makeSpacePath(url, "classifieds", address) +export const makeArticlePath = (url: string, address?: string) => + makeSpacePath(url, "articles", address) + export const makeCalendarPath = (url: string, address?: string) => makeSpacePath(url, "calendar", address) @@ -155,6 +159,8 @@ export const makeContentPath = (url: string, kind: number, idOrAddress?: string) return makeThreadPath(url, idOrAddress) case CLASSIFIED: return makeClassifiedPath(url, idOrAddress) + case LONG_FORM: + return makeArticlePath(url, idOrAddress) case EVENT_TIME: return makeCalendarPath(url, idOrAddress) case PINBOARD: diff --git a/src/app/social.ts b/src/app/social.ts index e1fa2a46..f2bb2848 100644 --- a/src/app/social.ts +++ b/src/app/social.ts @@ -1,9 +1,10 @@ import {derived} from "svelte/store" -import {shuffle} from "@welshman/lib" +import {pushToMapKey, shuffle, sortBy, uniqBy} from "@welshman/lib" import { COMMENT, addressTags, getAddress, + getIdAndAddress, hexTags, tagSpec, tagValues, @@ -30,6 +31,54 @@ const getParents = ({kind, tags}: TrustedEvent) => { return replies.length > 0 ? replies : roots } +export type CommentNode = { + comment: TrustedEvent + children: CommentNode[] +} + +export const buildCommentTree = (root: TrustedEvent, comments: TrustedEvent[]) => { + const byParent = new Map() + + // A comment names its parent by id, and by address too when the parent is addressable, so + // index it under every value it gives and dedupe on the way back out. + for (const comment of comments) { + for (const parent of getParents(comment)) { + pushToMapKey(byParent, parent, comment) + } + } + + // Nothing stops a comment from naming several parents, which would let the tree cycle, so + // walk down from the root and keep each comment at the first place it turns up. + const seen = new Set() + + const build = (parent: TrustedEvent): CommentNode[] => { + const children = uniqBy( + e => e.id, + getIdAndAddress(parent).flatMap(value => byParent.get(value) ?? []), + ).filter(e => !seen.has(e.id)) + + for (const child of children) { + seen.add(child.id) + } + + return children.map(comment => ({comment, children: build(comment)})) + } + + const nodes = build(root) + + // A comment can name a parent we don't have — a superseded version of an addressable root, + // or a parent that failed to load — so adopt whatever's left rather than dropping it. + // `comments` is oldest first, so a parent is always adopted before its own children. + for (const comment of comments) { + if (!seen.has(comment.id)) { + seen.add(comment.id) + nodes.push({comment, children: build(comment)}) + } + } + + return sortBy(node => node.comment.created_at, nodes) +} + export const isEventMuted = withGetter( derived([user, deriveUserItem(MuteLists)], ([$user, $muteList]) => { const tags = $muteList?.tags() ?? [] diff --git a/src/app/storage.ts b/src/app/storage.ts index c94ec31d..6d137bfc 100644 --- a/src/app/storage.ts +++ b/src/app/storage.ts @@ -3,7 +3,7 @@ import type {Unsubscriber} from "svelte/store" import {deleteDB} from "idb" import {SecureStorage} from "@aparajita/capacitor-secure-storage" import {Preferences} from "@capacitor/preferences" -import {noop, on, throttle, batch, call} from "@welshman/lib" +import {noop, on, throttle, batch, call, makeQueue} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import { ALERT_ANDROID, @@ -45,7 +45,7 @@ import {IDB} from "@lib/indexeddb" import {appPolicies} from "@app/core" export const kv = call(() => { - let p = Promise.resolve() + const enqueue = makeQueue() const get = async (key: string): Promise => { const result = await Preferences.get({key}) @@ -58,22 +58,18 @@ export const kv = call(() => { } const set = async (key: string, value: T): Promise => { - p = p.then(() => Preferences.set({key, value: JSON.stringify(value)})) - - await p + await enqueue(() => Preferences.set({key, value: JSON.stringify(value)})) } const clear = async () => { - p = p.then(() => Preferences.clear()) - - await p + await enqueue(() => Preferences.clear()) } return {get, set, clear} }) export const ss = call(() => { - let p = Promise.resolve() + const enqueue = makeQueue() const get = async (key: string): Promise => { let value = await SecureStorage.getItem(key) @@ -97,16 +93,17 @@ export const ss = call(() => { } } + // Android's SecureStorage rejects undefined const set = async (key: string, value: T): Promise => { - p = p.then(() => SecureStorage.setItem(key, JSON.stringify(value))) - - await p + await enqueue(() => + value === undefined + ? SecureStorage.removeItem(key) + : SecureStorage.setItem(key, JSON.stringify(value)), + ) } const clear = async () => { - p = p.then(() => SecureStorage.clear()) - - await p + await enqueue(() => SecureStorage.clear()) } return {get, set, clear} @@ -114,7 +111,6 @@ export const ss = call(() => { const TABLES = [ {name: "events", keyPath: "id"}, - {name: "tracker", keyPath: "id"}, {name: "relays", keyPath: "url"}, {name: "relayStats", keyPath: "url"}, {name: "handles", keyPath: "nip05"}, @@ -149,13 +145,15 @@ const kinds = { ], } -const shouldPersistEvent = (event: TrustedEvent) => - kinds.meta.includes(event.kind) || - kinds.alert.includes(event.kind) || - kinds.space.includes(event.kind) || - kinds.room.includes(event.kind) +// Space and room events mean nothing apart from the relay they came from — welshman keys them +// per relay, and only counts room state the relay itself signed. +const isRelayScoped = (event: TrustedEvent) => + kinds.space.includes(event.kind) || kinds.room.includes(event.kind) -type TrackerItem = {id: string; relays: string[]} +const shouldPersistEvent = (event: TrustedEvent) => + kinds.meta.includes(event.kind) || kinds.alert.includes(event.kind) || isRelayScoped(event) + +type EventItem = {id: string; event: TrustedEvent; relays: string[]} type PlaintextItem = {key: string; value: string} @@ -194,9 +192,7 @@ class Storage { private start = async () => { await this.db.connect() - const [, unsubscribeRelays] = await Promise.all([this.loadCriticalEvents(), this.initRelays()]) - - await this.loadCriticalTracker() + const [, unsubscribeRelays] = await Promise.all([this.loadCriticalData(), this.initRelays()]) this.addUnsubscriber(this.syncEvents()) this.addUnsubscriber(this.syncTracker()) @@ -227,30 +223,54 @@ class Storage { } } - private loadCriticalEvents = async () => { - const table = this.db.table("events") - const initialEvents = await table.getAll() - const keep: TrustedEvent[] = [] - const drop: string[] = [] + /** + * Each row holds an event together with the relays it came from, since a relay-scoped event + * that lost its provenance can never be keyed to a space again — it sits in the repository + * invisible, and negentropy reconciles it away as already-present, so it never comes back. + * Drop those and let the next sync pull them down again. + */ + private loadCriticalData = async () => { + const table = this.db.table("events") + const items: EventItem[] = [] + const stale: string[] = [] - for (const event of initialEvents) { - if (shouldPersistEvent(event)) { - event[verifiedSymbol] = true - keep.push(event) + for (const item of await table.getAll()) { + // Rows written before provenance was stored inline hold a bare event + if ( + item.event && + shouldPersistEvent(item.event) && + (!isRelayScoped(item.event) || item.relays.length > 0) + ) { + item.event[verifiedSymbol] = true + items.push(item) } else { - drop.push(event.id) + stale.push(item.id) } } - this.app.repository.load(keep) + this.app.repository.load(items.map(item => item.event)) - if (drop.length > 0) { - void table.bulkDelete(drop) + const relaysById = new Map>() + + for (const {id, relays} of items) { + // Anything the repository rejected was superseded by an event that arrived before we + // finished loading + if (this.app.repository.getEvent(id)) { + relaysById.set(id, new Set(relays)) + } else { + stale.push(id) + } + } + + this.app.tracker.load(relaysById) + + if (stale.length > 0) { + void table.bulkDelete(stale) } } private syncEvents = () => { - const table = this.db.table("events") + const table = this.db.table("events") return on( this.app.repository, @@ -273,7 +293,15 @@ class Storage { } if (add.length > 0) { - await table.bulkPut(add) + // The ingest policy tracks an event before publishing it, so by the time the + // repository reports it, its provenance is already in the tracker + await table.bulkPut( + add.map(event => ({ + id: event.id, + event, + relays: Array.from(this.app.tracker.getRelays(event.id)), + })), + ) } if (remove.size > 0) { @@ -283,66 +311,38 @@ class Storage { ) } - private loadCriticalTracker = async () => { - const table = this.db.table("tracker") - const relaysById = new Map>() - const stale: string[] = [] - - for (const {id, relays} of await table.getAll()) { - if (this.app.repository.getEvent(id)) { - relaysById.set(id, new Set(relays)) - } else { - stale.push(id) - } - } - - this.app.tracker.load(relaysById) - - if (stale.length > 0) { - void table.bulkDelete(stale) - } - } - private syncTracker = () => { - const table = this.db.table("tracker") + const table = this.db.table("events") - const _onAdd = async (ids: Iterable) => { - const items: TrackerItem[] = [] + const _onChange = async (ids: Iterable) => { + const items: EventItem[] = [] for (const id of ids) { const event = this.app.repository.getEvent(id) - if (!event || !shouldPersistEvent(event)) continue - - const relays = Array.from(this.app.tracker.getRelays(id)) - - if (relays.length === 0) continue - - items.push({id, relays}) + // A brand-new event is tracked before it's published, so it isn't queryable here — + // syncEvents persists its provenance along with the event itself. This pass only + // records relay changes for events we already have. + if (event && shouldPersistEvent(event)) { + items.push({id, event, relays: Array.from(this.app.tracker.getRelays(id))}) + } } await table.bulkPut(items) } - const _onRemove = async (ids: Iterable) => { - await table.bulkDelete(Array.from(ids)) - } - - const onAdd = batch(3000, _onAdd) - const onRemove = batch(3000, _onRemove) - const onLoad = () => _onAdd(this.app.tracker.relaysById.keys()) - const onClear = () => _onRemove(this.app.tracker.relaysById.keys()) + const onAdd = batch(3000, _onChange) + const onRemove = batch(3000, _onChange) + const onLoad = () => _onChange(this.app.tracker.relaysById.keys()) this.app.tracker.on("add", onAdd) this.app.tracker.on("remove", onRemove) this.app.tracker.on("load", onLoad) - this.app.tracker.on("clear", onClear) return () => { this.app.tracker.off("add", onAdd) this.app.tracker.off("remove", onRemove) this.app.tracker.off("load", onLoad) - this.app.tracker.off("clear", onClear) } } diff --git a/src/app/title.ts b/src/app/title.ts index 0af928a4..85d08d4c 100644 --- a/src/app/title.ts +++ b/src/app/title.ts @@ -20,6 +20,7 @@ const staticTitles = new Map([ ["/spaces/[relay]/recent", "Recent Activity"], ["/spaces/[relay]/threads", "Threads"], ["/spaces/[relay]/classifieds", "Classifieds"], + ["/spaces/[relay]/articles", "Articles"], ["/spaces/[relay]/calendar", "Calendar"], ["/spaces/[relay]/goals", "Goals"], ["/spaces/[relay]/polls", "Polls"], @@ -43,6 +44,7 @@ const eventRoutes = new Set([ "/spaces/[relay]/goals/[id]", "/spaces/[relay]/calendar/[address]", "/spaces/[relay]/classifieds/[address]", + "/spaces/[relay]/articles/[address]", "/spaces/[relay]/polls/[id]", ]) @@ -150,6 +152,10 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => { return makeTitle(tagValue(tagSpec("title"), event?.tags || []) || "Listing") } + if (routeId === "/spaces/[relay]/articles/[address]") { + return makeTitle(tagValue(tagSpec("title"), event?.tags || []) || "Article") + } + if (routeId === "/spaces/[relay]/goals/[id]") { return makeTitle(event?.content || tagValue(tagSpec("summary"), event?.tags || []) || "Goal") } diff --git a/src/lib/components/Divider.svelte b/src/lib/components/Divider.svelte index 6ccc1a2b..0601ca0f 100644 --- a/src/lib/components/Divider.svelte +++ b/src/lib/components/Divider.svelte @@ -5,7 +5,7 @@ children?: Snippet } - const {children, ...props}: Props = $props() + const {children}: Props = $props()
    diff --git a/src/lib/components/InputList.svelte b/src/lib/components/InputList.svelte index d17510bc..6f3f45a6 100644 --- a/src/lib/components/InputList.svelte +++ b/src/lib/components/InputList.svelte @@ -55,6 +55,7 @@
    + {#each value as item, index}
    { diff --git a/src/lib/components/Scanner.svelte b/src/lib/components/Scanner.svelte index c99dfbf2..b495d957 100644 --- a/src/lib/components/Scanner.svelte +++ b/src/lib/components/Scanner.svelte @@ -62,7 +62,7 @@ class="select input input-sm absolute bottom-1 right-1 w-auto" bind:value={camera} onchange={changeCamera}> - {#each cameras as camera} + {#each cameras as camera (camera.id)} {/each} diff --git a/src/lib/components/base.css b/src/lib/components/base.css index b5601321..b723dda6 100644 --- a/src/lib/components/base.css +++ b/src/lib/components/base.css @@ -413,6 +413,12 @@ emoji-picker { max-width: 100%; } +/* In a popover the border is what separates the picker from the page, but in a dialog + it sits on a surface of its own color, so it reads as a stray box. */ +.dialog emoji-picker { + --border-size: 0; +} + [data-tippy-root]:has(emoji-picker) { max-width: min(352px, calc(100vw - 2rem)); } diff --git a/src/lib/components/content-markdown.css b/src/lib/components/content-markdown.css new file mode 100644 index 00000000..9aaea59f --- /dev/null +++ b/src/lib/components/content-markdown.css @@ -0,0 +1,72 @@ +@layer components { + /* Rendered markdown is raw html, so it can't carry utility classes — style the tags. */ + .content-markdown { + h1 { + @apply text-2xl leading-tight; + font-family: var(--font-heading); + } + + h2 { + @apply text-xl leading-tight; + font-family: var(--font-heading); + } + + h3 { + @apply text-lg leading-snug; + font-family: var(--font-heading); + } + + ul { + @apply list-inside list-disc; + } + + ol { + @apply list-inside list-decimal; + } + + li { + @apply my-2; + } + + /* Markdown wraps loose list items in a paragraph, which would break the line */ + li p { + @apply inline; + } + + a { + @apply text-primary underline; + } + + img { + @apply max-w-full rounded-2xl; + } + + hr { + @apply bg-line rounded-full; + border: none; + height: var(--border-thin); + } + + code { + @apply bg-surface-less rounded px-1; + box-decoration-break: clone; + } + + pre code { + @apply block overflow-x-auto p-3; + } + + blockquote { + @apply border-line text-content-muted border-l-4 border-solid pl-4; + } + + table { + @apply block w-full border-collapse overflow-x-auto; + } + + th, + td { + @apply border-line border border-solid px-3 py-2 text-left; + } + } +} diff --git a/src/lib/components/theme.css b/src/lib/components/theme.css index d4cf66ad..8f1ada91 100644 --- a/src/lib/components/theme.css +++ b/src/lib/components/theme.css @@ -24,6 +24,7 @@ @import "./card.css"; @import "./card-button.css"; @import "./chat.css"; +@import "./content-markdown.css"; @import "./content-search.css"; @import "./controls.css"; @import "./divider.css"; diff --git a/src/lib/indexeddb.ts b/src/lib/indexeddb.ts index 03f60e4e..60898b6a 100644 --- a/src/lib/indexeddb.ts +++ b/src/lib/indexeddb.ts @@ -1,5 +1,6 @@ import {openDB, deleteDB} from "idb" import type {IDBPDatabase} from "idb" +import {spec} from "@welshman/lib" import type {Maybe} from "@welshman/lib" export type IDBStore = { @@ -19,14 +20,17 @@ export class IDB { constructor(readonly options: IDBOptions) {} // Object stores can only be created during a version change, and which stores we need depends - // on who is logged in, so open at whatever version exists and bump it to add missing ones. + // on who is logged in, so open at whatever version exists and bump it to reconcile the schema. private open = async () => { const {name, stores} = this.options const blocking = () => this.close() const db = await openDB(name, undefined, {blocking}) const missing = stores.filter(store => !db.objectStoreNames.contains(store.name)) + const obsolete = Array.from(db.objectStoreNames).filter( + storeName => !stores.some(spec({name: storeName})), + ) - if (missing.length === 0) { + if (missing.length === 0 && obsolete.length === 0) { return db } @@ -39,6 +43,10 @@ export class IDB { for (const {name, keyPath} of missing) { idbDb.createObjectStore(name, {keyPath}) } + + for (const storeName of obsolete) { + idbDb.deleteObjectStore(storeName) + } }, blocked: (currentVersion, blockedVersion) => console.error( diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5cbc1be9..417e0b8c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,19 +6,21 @@ import type {Unsubscriber} from "svelte/store" import {get} from "svelte/store" import {App, type URLOpenListenerEvent} from "@capacitor/app" + import {Capacitor} from "@capacitor/core" + import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin" import {dev} from "$app/environment" import {goto} from "$app/navigation" import {page} from "$app/stores" import {context as pomadeContext} from "@pomade/core" import {sync, throttled} from "@welshman/store" - import * as domain from "@welshman/domain" + import {setNip55Plugin} from "@welshman/signer" import * as util from "@welshman/util" import * as lib from "@welshman/lib" import {Logger} from "@welshman/app" 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" + import * as core from "@app/core" import {setupHistory} from "@app/routes" import {setupAnalytics} from "@app/analytics" import {setupLogging} from "@app/logger" @@ -35,7 +37,11 @@ import {activeTheme, flTheme, 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 { + notifications as notificationPaths, + allNotifications, + latestActivityByPath, + } from "@app/notifications" import {Push} from "@app/push" import {onPushNotificationAction, pushState} from "@app/push/adapters/common" import {syncKeyboard} from "@app/keyboard" @@ -58,12 +64,16 @@ ) // Add stuff to window for convenience - Object.assign(window, {get, nip19, theme, app, domain, Logger, ...lib, ...util}) + Object.assign(window, {get, nip19, theme, Logger, ...lib, ...util, ...core}) // Set up context for various modules pomadeContext.setSignerUrls(env.POMADE_SIGNERS) pomadeContext.setArgonWorker(import("@pomade/core/argon-worker.js?worker")) + if (Capacitor.isNativePlatform()) { + setNip55Plugin(NostrSignerPlugin) + } + // Handle a deep link (universal/app link or custom scheme). Used for both // warm-start links (via the appUrlOpen event) and cold-start links (via // getLaunchUrl below) so the invite relay/claim query params are preserved @@ -203,14 +213,21 @@ unsubscribers.push(Push.sync()) // Logging in swaps in a fresh app — its policies rebind themselves, we just have to sync - // application data against the new identity's relays. - let currentApp = app.get() + // application data against the new identity's relays. Wait for the new app's storage to + // load the way startup does, so sync reconciles against cached events. + let currentApp = core.app.get() + + const resync = async () => { + await storage.get()?.ready + + syncApplicationData() + } unsubscribers.push( - app.subscribe($app => { + core.app.subscribe($app => { if ($app !== currentApp) { currentApp = $app - syncApplicationData() + resync() } }), ) @@ -271,10 +288,12 @@ }) $effect(() => { - const title = getPageTitle({page: $page, pubkey: $app.user?.pubkey}) + const {user} = core.app.get() + const title = getPageTitle({page: $page, pubkey: 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 + const paths = $documentActive ? $notificationPaths : $allNotifications + const unreadCount = [...$latestActivityByPath.keys()].filter(path => paths.has(path)).length document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title }) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c4009076..32fc047e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,11 @@ diff --git a/src/routes/settings/hosting/+page.svelte b/src/routes/settings/hosting/+page.svelte index 29d34450..c5113ac9 100644 --- a/src/routes/settings/hosting/+page.svelte +++ b/src/routes/settings/hosting/+page.svelte @@ -2,7 +2,7 @@ import {onMount} from "svelte" import {App} from "@capacitor/app" import type {Maybe} from "@welshman/lib" - import {indexBy, spec} from "@welshman/lib" + import {indexBy, sortBy, spec} from "@welshman/lib" import Add from "@assets/icons/add.svg?dataurl" import Server from "@assets/icons/server.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl" @@ -107,7 +107,7 @@ const [t, inv, rel, draft] = results if (t.status === "fulfilled") tenant = t.value - if (inv.status === "fulfilled") invoices = inv.value + if (inv.status === "fulfilled") invoices = sortBy(invoice => -invoice.created_at, inv.value) if (rel.status === "fulfilled") relays = rel.value if (draft.status === "fulfilled") draftInvoice = draft.value diff --git a/src/routes/settings/wallet/+page.svelte b/src/routes/settings/wallet/+page.svelte index 23bdac7d..60bb2214 100644 --- a/src/routes/settings/wallet/+page.svelte +++ b/src/routes/settings/wallet/+page.svelte @@ -269,6 +269,7 @@

    Preset amounts shown when sending a zap.

    + {#each zapAmountDraft as amount, index}
    + {/snippet} + + + +
    + {#each filtered as event (getAddress(event))} +
    + +
    + {/each} +

    + + {#if loading} + Looking for articles... + {:else if articles.length === 0} + No articles found. + {:else if filtered.length === 0} + No articles match that filter. + {:else} + That's all! + {/if} + +

    +
    + +
    diff --git a/src/routes/spaces/[relay]/articles/[address]/+page.svelte b/src/routes/spaces/[relay]/articles/[address]/+page.svelte new file mode 100644 index 00000000..c78886cf --- /dev/null +++ b/src/routes/spaces/[relay]/articles/[address]/+page.svelte @@ -0,0 +1,62 @@ + + + + {#snippet title()} +

    {$article?.title() ?? ""}

    + {/snippet} +
    + + + {#if $event && $article} + +
    + {#if $article.image()} + + {/if} +
    +

    {$article.title() ?? "Untitled"}

    +

    + Published {formatTimestamp($article.publishedAt())} +

    +
    + {#if $article.summary()} +

    {$article.summary()}

    + {/if} + + +
    +
    + + {:else} +
    + {#await sleep(5000)} + Loading article... + {:then} +

    Failed to load article.

    + {/await} +
    + {/if} +
    diff --git a/src/routes/spaces/[relay]/calendar/+page.svelte b/src/routes/spaces/[relay]/calendar/+page.svelte index f0fab54e..ef730446 100644 --- a/src/routes/spaces/[relay]/calendar/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/+page.svelte @@ -49,7 +49,7 @@ .map(event => { const newDateDisplay = formatTimestampAsDate(getStart(event)) const dateDisplay = prevDateDisplay === newDateDisplay ? undefined : newDateDisplay - const isFuture = todayDateDisplay === newDateDisplay || event.created_at > now() + const isFuture = todayDateDisplay === newDateDisplay || getStart(event) > now() const isFirstFutureEvent = !haveISeenTheFuture && isFuture prevDateDisplay = newDateDisplay diff --git a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte index 50a47ff5..bce46c77 100644 --- a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte @@ -1,10 +1,9 @@ @@ -96,7 +96,7 @@
    - +
    {/each} diff --git a/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte b/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte index 049bed8b..1048d2a2 100644 --- a/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte @@ -1,10 +1,9 @@ diff --git a/src/routes/spaces/[relay]/directory/+page.svelte b/src/routes/spaces/[relay]/directory/+page.svelte index c5ef33d7..40a8b35f 100644 --- a/src/routes/spaces/[relay]/directory/+page.svelte +++ b/src/routes/spaces/[relay]/directory/+page.svelte @@ -64,7 +64,8 @@ let term = $state("") const matchesTerm = (pubkey: string, t: string) => - $profiles.display(pubkey).get().toLowerCase().includes(t) || pubkey.toLowerCase().includes(t) + $profiles.display(pubkey, [url]).get().toLowerCase().includes(t) || + pubkey.toLowerCase().includes(t) // In-place search: match by member info or by the name of any role they hold. const visibleMembers = $derived.by(() => { diff --git a/src/routes/spaces/[relay]/goals/[id]/+page.svelte b/src/routes/spaces/[relay]/goals/[id]/+page.svelte index 8ce2c71e..7ae10b5f 100644 --- a/src/routes/spaces/[relay]/goals/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/goals/[id]/+page.svelte @@ -1,10 +1,9 @@ diff --git a/src/routes/spaces/[relay]/polls/[id]/+page.svelte b/src/routes/spaces/[relay]/polls/[id]/+page.svelte index fa593438..4fda5fab 100644 --- a/src/routes/spaces/[relay]/polls/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/polls/[id]/+page.svelte @@ -3,7 +3,7 @@ import {page} from "$app/stores" import {sleep} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib" - import {COMMENT, POLL, POLL_RESPONSE} from "@welshman/util" + import {POLL, POLL_RESPONSE, getCommentFiltersForRoot} from "@welshman/util" import {deriveEventsAsc} from "@welshman/store" import SortVertical from "@assets/icons/sort-vertical.svg?dataurl" import Icon from "@lib/components/Icon.svelte" @@ -22,8 +22,8 @@ const {relay, id} = $page.params as MakeNonOptional const url = decodeRelay(relay) const event = deriveEvent(id, [url]) - const filters = [{kinds: [COMMENT], "#E": [id]}] - const comments = deriveEventsAsc(deriveEventsById(filters)) + const filters = $derived($event ? getCommentFiltersForRoot([$event]) : []) + const comments = $derived(deriveEventsAsc(deriveEventsById(filters))) const back = () => history.back() @@ -47,7 +47,10 @@ $network.request({ relays: [url], - filters: [{kinds: [POLL], ids: [id]}, {kinds: [POLL_RESPONSE], "#e": [id]}, ...filters], + filters: [ + {kinds: [POLL], ids: [id]}, + {kinds: [POLL_RESPONSE], "#e": [id]}, + ], signal: controller.signal, }) @@ -55,6 +58,16 @@ controller.abort() } }) + + $effect(() => { + if (filters.length > 0) { + const controller = new AbortController() + + $network.request({relays: [url], filters, signal: controller.signal}) + + return () => controller.abort() + } + }) diff --git a/src/routes/spaces/[relay]/threads/[id]/+page.svelte b/src/routes/spaces/[relay]/threads/[id]/+page.svelte index 5628b1ff..bc0d151d 100644 --- a/src/routes/spaces/[relay]/threads/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/threads/[id]/+page.svelte @@ -1,13 +1,12 @@ @@ -171,7 +176,7 @@ {/each}
    {#if pageCount > 1} - + {/if} {#if showReply && replyTo && $event}
    +
    + Looking for third party managed hosting? + + Try lnvps.net + + +
    diff --git a/vite.config.ts b/vite.config.ts index 81434ad6..cf592a0a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ 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 + // here and there is one prosemirror. link-deps.mjs 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