Compare commits

...

48 commits

Author SHA1 Message Date
mplorentz
c0f545350c Merge branch 'dev' into email-notifications
Some checks failed
CI / lint-check-build (pull_request) Has been cancelled
2026-08-24 14:40:44 -04:00
mplorentz
645174447d Merge branch 'dev' into email-notifications 2026-08-24 14:36:08 -04:00
Jon Staab
637be41a8b Await deletions
Some checks are pending
CI / lint-check-build (push) Waiting to run
2026-08-22 12:36:25 -07:00
Jon Staab
35ed7bc09f React to addressed events getting updated for delete status 2026-08-22 11:40:05 -07:00
Jon Staab
735231f8bc Make some event rendering components reactive 2026-08-22 11:30:30 -07:00
Jon Staab
8d7b640d0f Fix thread permalinks 2026-08-22 11:20:33 -07:00
Jon Staab
99be43f396 Fix pagination collapse 2026-08-22 11:11:57 -07:00
Jon Staab
2edcfd9a79 Fix a test, avoid link nesting in article cards 2026-08-22 11:00:22 -07:00
Jon Staab
08821d27f4 Bump eslint 2026-08-22 10:32:51 -07:00
Jon Staab
5f3b964eec Bump welshman 2026-08-22 10:06:29 -07:00
Jon Staab
e869b2631e Fix US-039 spec 2026-08-22 09:24:36 -07:00
Jon Staab
ef1981c685 Fix the admin spec 2026-08-22 09:10:47 -07:00
Jon Staab
a4193c8629 Report clock drift in tests 2026-08-22 09:00:18 -07:00
Jon Staab
1c58c075a4 Bump welshman 2026-08-21 16:41:57 -07:00
Jon Staab
a28a4fd72a Show error toast on failed report resolution 2026-08-21 15:54:11 -07:00
Jon Staab
0e35cadebe Fix role create/edit colors 2026-08-21 14:11:13 -07:00
Jon Staab
3e4ae49eed Include space urls in hints for profile derivation 2026-08-21 13:47:06 -07:00
Jon Staab
5ecb811b24 Auto-cleanup 2026-08-21 11:29:15 -07:00
Jon Staab
4c21de57e2 Cleanup prose in e2e files 2026-08-20 14:58:26 -07:00
Jon Staab
196bea79b6 Add ci job 2026-08-20 14:43:33 -07:00
Jon Staab
aa2483fe78 Add tests for all user stories 2026-08-20 14:32:30 -07:00
mplorentz
725ef45d9d Fix signer error 2026-08-20 11:51:26 -04:00
Jon Staab
c53aaf8df8 Add e2e user stories 2026-08-19 10:32:47 -07:00
Jon Staab
d95ea3c905 Bump version 2026-08-18 15:22:25 -07:00
Jon Staab
e8455f2c8d Add a script to bump the version across web, ios, android 2026-08-18 15:22:14 -07:00
Jon Staab
d5dbb7fba1 Update server.js to use new welshman implementation 2026-08-18 15:13:12 -07:00
Jon Staab
56b9e6f031 Update changelog, bump version 2026-08-18 14:58:41 -07:00
Jon Staab
ce4ecedf7e De-emphasize link meta 2026-08-18 14:44:43 -07:00
Jon Staab
0a9d3cfb49 Use nip 86 for claim management 2026-08-18 12:55:08 -07:00
Jon Staab
8a0918d551 Merge tracker/events store into a single indexeddb store 2026-08-18 11:32:39 -07:00
Jon Staab
45cbd1d6ba Fix loading events to avoid lost provenance 2026-08-18 09:20:30 -07:00
Jon Staab
20f12a48a4 Fix session persistence on android 2026-08-17 16:10:00 -07:00
Jon Staab
b8ec94e548 Patch requestIdleCallback for android 2026-08-17 15:33:12 -07:00
Jon Staab
f71cdf3f8d Add lnvps link 2026-08-17 15:30:45 -07:00
Jon Staab
c62ef3fc3c Undo reaction by tapping 2026-08-17 15:25:53 -07:00
Jon Staab
f5d22664ac Bump welshman 2026-08-17 15:17:16 -07:00
Jon Staab
847d89845e Use new welshman membership helpers 2026-08-17 15:14:18 -07:00
Jon Staab
3dfb5e60de Hide emoji picker border in dialog 2026-08-17 15:14:12 -07:00
Jon Staab
974c6413af Add article support 2026-08-17 14:12:33 -07:00
Jon Staab
4c4b6b50a7 Fix author hint for quotes 2026-08-17 11:24:49 -07:00
Jon Staab
b06b855a42 Clean up thread create initial values 2026-08-17 11:18:38 -07:00
Aditya Chaudhary
dfb971e7dd feat: add "Convert to Thread" action on room messages (#370)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-17 18:13:37 +00:00
Jon Staab
01a79d570b Cache space nav items and fix meta storage 2026-08-17 10:49:28 -07:00
Aditya Chaudhary
0f448e0b5a Restore mic state on full voice reconnect after a dropout (#373)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-17 17:19:13 +00:00
Jon Staab
25f7d6892d Fix nip 55 2026-08-17 09:44:13 -07:00
Jon Staab
4ee05fd21c Fix unread notification count 2026-08-17 09:41:38 -07:00
Jon Staab
3d66fb3109 Use welshman's pendingJoins util 2026-08-17 09:21:30 -07:00
Jon Staab
da64b46494 Show notification badges on thread items 2026-08-17 08:55:54 -07:00
165 changed files with 13472 additions and 924 deletions

35
.gitea/workflows/ci.yml Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 `<name>.test`
The container listens on plaintext loopback, but a url that is local or insecure is dropped from
every relay selection unless the caller opts in — see `isLocalUrl` and `RelaySelection.getUrls` in
`@welshman/util` — and Flotilla never opts in, since in production neither belongs in a routing
`@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

1570
e2e/USER_STORIES.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -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<string, string>
}
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,
)

61
e2e/harness/app/nip07.ts Normal file
View file

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

View file

@ -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<string, string>
// 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<void>): Promise<Scenario>
// A logged-in page for a user: its own browser context, its own storage, its own sockets into
// the relays every other user is talking to. `options` overrides the project's context options,
// for a spec that needs a viewport or colour scheme of its own.
as(user: TestUser, path?: string, options?: BrowserContextOptions): Promise<Page>
// the relays every other user is talking to.
as(user: TestUser, path?: string, options?: PageOptions): Promise<Page>
// 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<Page>
}
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<HarnessFixtures, HarnessWorkerFixtures>({
// Playwright's own context — and the `page` fixture built on it — is unrouted: no websocket
// interception, no http block-all, no injected env, and nothing collecting leaks from it. A page
// born there boots the app against the relays baked into .env, which is the one thing this suite
// exists to prevent, so it is refused outright and `as()` is the only way to get a page.
// 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<HarnessFixtures, HarnessWorkerFixtures>({
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<HarnessFixtures, HarnessWorkerFixtures>({
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<HarnessFixtures, HarnessWorkerFixtures>({
// 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<HarnessFixtures, HarnessWorkerFixtures>({
},
seed: async ({harness}, use) => use(harness.seed),
as: async ({harness}, use) => use(harness.as),
visit: async ({harness}, use) => use(harness.visit),
})

View file

@ -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<string, TestUser>()
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)
}

View file

@ -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<string, object>
/**
* 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<ZapperValues, "lnurl">}[]
}
/**
* 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<string, unknown>
// 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<BrowserContext, HostingHandle>()
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) {
// Provisioning is the one tenant route with no pubkey in the path.
if (id) {
tenant = {...tenant, pubkey: id}
} else {
tenant = {...tenant, ...body}
return route.fulfill({json: {data: tenant}})
}
if (sub === "relays") {
return route.fulfill({json: {data: fixtures.relays ?? []}})
return route.fulfill({json: {data: Array.from(relays.values())}})
}
if (sub === "invoices") {
const data = detail === "draft" ? fixtures.draftInvoice : (fixtures.invoices ?? [])
const data = detail === "draft" ? fixtures.draftInvoice : Array.from(invoices.values())
return route.fulfill({json: {data}})
}
} else {
return route.fulfill({json: {data: {pubkey: id, ...fixtures.tenant}}})
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.

View file

@ -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<EventTemplate>)
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<R extends BaseEventReader, W extends EventWriter<R>>(
factory: KindFactory<R, W>,
): ConfiguredKind<R, W>
}
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<SignedEvent>): SeededEvent => {
let signed: Maybe<SignedEvent>
// Queues a write and hands back a getter for whatever it produced, which only reads once the
// scenario's queue has drained.
const seeded = <T>(write: () => Promise<T>) => {
let value: Maybe<T>
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<SignedEvent>): 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 = <R extends BaseEventReader, W extends EventWriter<R>>(factory: KindFactory<R, W>) =>
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,
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Promise<Maybe<string>>>
// 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 —

View file

@ -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<void>
publish: (event: SignedEvent, options?: PublishOptions) => Promise<void>
}
// 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.

View file

@ -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<void>
// Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs.
event(user: TestUser, event: StampedEvent): Promise<SignedEvent>
// 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<void>
}
// One client's connection to a relay. Every connection to a url reaches the same container, which

1120
e2e/specs/admin.spec.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -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()
})

1142
e2e/specs/community.spec.ts Normal file

File diff suppressed because it is too large Load diff

496
e2e/specs/composer.spec.ts Normal file
View file

@ -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<string, {body: Buffer; type: string}>()
let held = Promise.resolve()
let open = () => {}
return {
hold: () => {
held = new Promise<void>(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 <img> 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)
})

View file

@ -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<void>(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)
})

608
e2e/specs/delivery.spec.ts Normal file
View file

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

613
e2e/specs/dms.spec.ts Normal file
View file

@ -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()
})

View file

@ -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$/)
})

View file

@ -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()
})

771
e2e/specs/people.spec.ts Normal file
View file

@ -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<object>}}}
network: {get(): {publish(options: {event: object; relays: string[]}): Promise<unknown>}}
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)
})

753
e2e/specs/rooms.spec.ts Normal file
View file

@ -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")
})

589
e2e/specs/settings.spec.ts Normal file
View file

@ -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")
})

545
e2e/specs/spaces.spec.ts Normal file
View file

@ -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=/)
})

View file

@ -29,6 +29,9 @@ export default [
rules: {
// eslint doesn't see type parameters declared by <script generics="...">
"no-undef": "off",
// A `$bindable()` prop with no fallback reads as an assignment nothing consumes, since the
// parent is what consumes it.
"no-useless-assignment": "off",
},
},
{
@ -55,6 +58,8 @@ export default [
],
"svelte/valid-compile": "off",
"svelte/no-at-html-tags": "off",
"svelte/no-navigation-without-resolve": "off",
"svelte/prefer-svelte-reactivity": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-extra-semi": "off",
"@typescript-eslint/ban-ts-comment": "off",

View file

@ -350,7 +350,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 42;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = NO;
@ -362,7 +362,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.9.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
@ -387,7 +387,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 42;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = NO;
@ -399,7 +399,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.9.1;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 42;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@ -550,7 +550,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.9.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -570,7 +570,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 42;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@ -579,7 +579,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.9.0;
MARKETING_VERSION = 1.9.1;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -1,13 +1,14 @@
{
"name": "flotilla",
"version": "1.9.0",
"version": "1.9.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "./build.sh",
"build": "./scripts/build.sh",
"build:server": "vite build --config vite.config.server.ts",
"start": "node server.js",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"release:android": "./scripts/build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"bump": "node scripts/bump-version.mjs",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src e2e playwright.config.ts && eslint src e2e",
@ -19,7 +20,7 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.39.2",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.49.1",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
@ -28,10 +29,11 @@
"@types/node": "^25.9.1",
"@types/ws": "^8.18.1",
"autoprefixer": "^10.4.23",
"capacitor-set-version": "^2.2.0",
"classnames": "^2.5.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-svelte": "^2.46.1",
"eslint": "^10.9.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^15.15.0",
"postcss": "^8.5.15",
"prettier": "^3.8.1",
@ -41,7 +43,7 @@
"svelte-check": "^4.3.5",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"typescript-eslint": "^8.67.0",
"vite": "^6.4.2",
"ws": "^8.18.3"
},
@ -76,18 +78,19 @@
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.9.0-pre5",
"@welshman/content": "^0.9.0-pre5",
"@welshman/domain": "^0.9.0-pre5",
"@welshman/editor": "^0.9.0-pre5",
"@welshman/feeds": "^0.9.0-pre5",
"@welshman/lib": "^0.9.0-pre5",
"@welshman/net": "^0.9.0-pre5",
"@welshman/signer": "^0.9.0-pre5",
"@welshman/store": "^0.9.0-pre5",
"@welshman/util": "^0.9.0-pre5",
"@welshman/app": "^0.9.4",
"@welshman/content": "^0.9.4",
"@welshman/domain": "^0.9.4",
"@welshman/editor": "^0.9.4",
"@welshman/feeds": "^0.9.4",
"@welshman/lib": "^0.9.4",
"@welshman/net": "^0.9.4",
"@welshman/signer": "^0.9.4",
"@welshman/store": "^0.9.4",
"@welshman/util": "^0.9.4",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"dompurify": "^3.4.13",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0",
@ -96,6 +99,7 @@
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
"marked": "^18.0.9",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.7.2",

Binary file not shown.

View file

@ -1,4 +1,4 @@
# Allow link_deps to change stuff in the working tree
# Allow link-deps.mjs to change stuff in the working tree
verifyDepsBeforeRun: warn
allowBuilds:
@ -7,6 +7,7 @@ allowBuilds:
esbuild: false
sharp: true
nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47: true
yarn: false
minimumReleaseAgeExclude:
- '@pomade/core'
- '@welshman/app'

50
scripts/bump-version.mjs Executable file
View file

@ -0,0 +1,50 @@
#!/usr/bin/env node
// Bumps the version in package.json and syncs it to android and ios via
// capacitor-set-version. Android and iOS build numbers have diverged
// historically, so each platform keeps its own counter, incremented only when
// its marketing version actually changes (re-running with the same version is
// a no-op).
import {readFileSync, writeFileSync} from "node:fs"
import {setAndroidVersionAndBuild} from "capacitor-set-version/dist/common/utils-android.js"
import {setIOSVersionAndBuild} from "capacitor-set-version/dist/common/utils-ios.js"
const arg = process.argv[2] || "patch"
const pkg = JSON.parse(readFileSync("package.json", "utf-8"))
let version
if (/^\d+\.\d+\.\d+$/.test(arg)) {
version = arg
} else {
const [major, minor, patch] = pkg.version.split(".").map(Number)
if (arg === "major") {
version = `${major + 1}.0.0`
} else if (arg === "minor") {
version = `${major}.${minor + 1}.0`
} else if (arg === "patch") {
version = `${major}.${minor}.${patch + 1}`
} else {
console.error(`Usage: pnpm bump [major|minor|patch|x.y.z] (got "${arg}")`)
process.exit(1)
}
}
const gradle = readFileSync("android/app/build.gradle", "utf-8")
const pbxproj = readFileSync("ios/App/App.xcodeproj/project.pbxproj", "utf-8")
const androidVersion = gradle.match(/versionName "(.+)"/)[1]
const androidBuild = Number(gradle.match(/versionCode (\d+)/)[1])
const iosVersion = pbxproj.match(/MARKETING_VERSION = (.+);/)[1]
const iosBuild = Number(pbxproj.match(/CURRENT_PROJECT_VERSION = (\d+);/)[1])
pkg.version = version
writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n")
const nextAndroidBuild = version === androidVersion ? androidBuild : androidBuild + 1
const nextIosBuild = version === iosVersion ? iosBuild : iosBuild + 1
setAndroidVersionAndBuild(".", version, nextAndroidBuild)
setIOSVersionAndBuild(".", version, nextIosBuild)
console.log(`android: ${version} (${nextAndroidBuild})`)
console.log(`ios: ${version} (${nextIosBuild})`)

View file

@ -5,8 +5,8 @@ import {fileURLToPath} from "node:url"
import "dotenv/config"
import {serve} from "@hono/node-server"
import {serveStatic} from "@hono/node-server/serve-static"
import {loadRelay} from "@welshman/app"
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {App, Relays} from "@welshman/app"
import {normalizeRelayUrl} from "@welshman/util"
import {load} from "cheerio"
import {Hono} from "hono"
@ -55,10 +55,12 @@ const requestUrlFromContext = context => {
return requestUrl
}
const relays = new App().use(Relays)
const fetchRelayMeta = async relayUrl => {
if (!relayUrl) return undefined
try {
return await loadRelay(normalizeRelayUrl(relayUrl))
return await relays.load(normalizeRelayUrl(relayUrl))
} catch (err) {
console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err)
return undefined
@ -76,7 +78,7 @@ const getMetadataForInvite = async (url, match) => {
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const relayDisplay = displayRelayUrl(relayParam)
const relayDisplay = relayMetadata.displayUrl()
const spaceName = relayMetadata.name
const relayDescription = relayMetadata.description
@ -97,10 +99,7 @@ const getMetadataForInvite = async (url, match) => {
const description = parts.join(" ")
const image =
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url)
relayMetadata.icon || relayMetadata.picture || relayMetadata.image || buildDefaultImage(url)
return {
title,
@ -118,16 +117,13 @@ const getMetadataForSpace = async (url, match) => {
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const spaceName = relayMetadata.name || displayRelayUrl(relayParam)
const spaceName = relayMetadata.display()
return {
title: `${spaceName} on ${PLATFORM_NAME}`,
description: relayMetadata.description || PLATFORM_DESCRIPTION,
image:
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url),
relayMetadata.icon || relayMetadata.picture || relayMetadata.image || buildDefaultImage(url),
url: url.toString(),
site: url.origin,
}
@ -170,8 +166,14 @@ const getMetadataForRoom = async (url, match) => {
const routes = [
[/^\/join\/?$/, getMetadataForInvite],
[/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection],
[/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem],
[
/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/,
getMetadataForSpaceSection,
],
[
/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/,
getMetadataForSpaceItem,
],
[/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom],
[/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace],
]

View file

@ -25,6 +25,19 @@
<link rel="icon" href="/pwa-192x192.png" sizes="192x192" type="image/png" />
<link rel="icon" href="/pwa-512x512.png" sizes="512x512" type="image/png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
<!-- Android's WebView can go arbitrarily long without an idle period, and both
emoji-picker-element and our own storage writes hand work to requestIdleCallback
without a timeout, so on a phone that work never ran. Giving one to every caller
only ever makes a callback run sooner. Must precede sveltekit's module scripts:
emoji-picker-element reads the global once, when it is imported. -->
<script>
const idle = window.requestIdleCallback
if (idle) {
window.requestIdleCallback = (callback, options) =>
idle.call(window, callback, {timeout: 200, ...options})
}
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" data-sveltekit-preload-code="eager">

View file

@ -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
// 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]}],
try {
const management = relayManagement.get().forUrl(this.url)
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),
])
if (events[0]) {
const eventReader = reader(RelayInvite)
const invite = await eventReader(events[0])
// 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()
this.claim.set(invite.claim() ?? "")
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)
}
}
}
this.claimFailed.set(events.length === 0)
if (h) {
this.roomCode.set(roomInviteResult.code)
this.roomInviteError.set(roomInviteResult.error)
}
} finally {
this.loading.set(false)
}
}
}

View file

@ -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<string>()
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]),
)

View file

@ -642,6 +642,11 @@ export const participantKey = (p: CallParticipant) => p.pubkey ?? p.liveKitIdent
let activeRoom: LiveKitRoom | undefined
let reconnectTimeout: ReturnType<typeof setTimeout> | 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) {

View file

@ -0,0 +1,56 @@
<script lang="ts">
import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {reader} from "@app/core"
import {makeArticlePath, makeSpacePath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
}
const {url, event, showRoom, showActivity}: Props = $props()
const article = reader(Article)(event)
const h = article.room()
const topics = article.topics()
const path = makeArticlePath(url, getAddress(event))
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<div class="flex min-w-0 flex-wrap gap-2">
{#each uniq(topics) as topic (topic)}
<button type="button" class="button button-xs rounded-full font-normal">
#{topic}
</button>
{/each}
</div>
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Article" />
</ThunkStatusOrDeleted>
</div>

View file

@ -0,0 +1,215 @@
<script lang="ts">
import {writable} from "svelte/store"
import {now, randomId} from "@welshman/lib"
import {Article} from "@welshman/domain"
import {publish} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {command, relays, writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {publishRoomQuote} from "@app/rooms"
import {pushToast} from "@app/toast"
type Values = {
d?: string
content?: string | object
title?: string
summary?: string
topics?: string[]
}
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`article:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = $relays.hasNip(url, 70)
const uploading = writable(false)
const back = () => history.back()
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title for your article.",
})
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content) {
return pushToast({
theme: "error",
message: "Please write something for your article.",
})
}
loading = true
try {
const protect = await shouldProtect
const eventWriter = writer(Article)
.setIdentifier(d)
.setContent(content)
.setTitle(title)
.setTopics(topics)
.setPublishedAt(now())
.setProtected(protect)
.addTags(...ed.storage.nostr.getEditorTags())
.forceRelays(url)
if (summary) {
eventWriter.setSummary(summary)
}
if (h) {
eventWriter.setRoom(url, h)
}
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: thunk.event, protect})
}
} finally {
loading = false
}
}
let loading = $state(false)
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let summary = $state(initialValues?.summary ?? "")
let topics = $state(initialValues?.topics ?? [])
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "Write your article...",
content,
})
$effect(() => {
draftKey.update({d, title, summary, topics, content})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Write an Article</ModalTitle>
<ModalSubtitle>Publish a long-form post to this space.</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-8 relative">
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
class="grow"
type="text"
placeholder="What is this article about?" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<label class="input flex w-full items-center gap-2">
<input
bind:value={summary}
class="grow"
type="text"
placeholder="A one line teaser for the article list" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Topics</p>
{/snippet}
{#snippet input()}
<TopicMultiSelect bind:value={topics} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Content*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
</Field>
<Button
data-tip="Add an image"
class="tip tip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="button button-primary" disabled={$uploading || loading}>
<Spinner {loading}>Publish Article</Spinner>
</Button>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,59 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ArticleActions from "@app/components/ArticleActions.svelte"
import {reader} from "@app/core"
import {makeArticlePath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const article = reader(Article)(event)
const title = article.title()
const summary = article.summary()
const image = article.image()
</script>
<div data-component="ArticleItem" class="cv relative w-full card card-interactive">
<!-- An overlay rather than a wrapper: the card carries a profile button and the room and action
links, and none of those may sit inside an anchor. -->
<Link
class="absolute inset-0 rounded-2xl"
href={makeArticlePath(url, getAddress(event))}
aria-label={title || "Untitled"} />
<div class="pointer-events-none relative flex flex-col gap-2">
{#if image}
<img src={image} alt="" class="h-40 w-full rounded-2xl object-cover" />
{/if}
<div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">{title || "Untitled"}</p>
<p class="whitespace-nowrap text-sm opacity-75">
{formatTimestamp(article.publishedAt())}
</p>
</div>
<Content
event={{content: summary || event.content, tags: event.tags}}
{url}
expandMode="inline"
minLength={100}
maxLength={300} />
<div
class="pointer-events-auto flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Written by
<ProfileLink pubkey={event.pubkey} {url} />
</span>
<ArticleActions showRoom showActivity {url} {event} />
</div>
</div>
</div>

View file

@ -0,0 +1,80 @@
<script lang="ts">
import cx from "classnames"
import {countBy, identity, sortBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {Article} from "@welshman/domain"
import Button from "@lib/components/Button.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {reader} from "@app/core"
type Props = {
url: string
events: TrustedEvent[]
author?: string
topic?: string
}
let {url, events, author = $bindable(), topic = $bindable()}: Props = $props()
const byCount = <T,>(counts: Map<T, number>) =>
sortBy(([, count]) => -count, Array.from(counts.entries()))
const authors = $derived(byCount(countBy(e => e.pubkey, events)))
const topics = $derived(
byCount(
countBy(
identity,
events.flatMap(e => reader(Article)(e).topics()),
),
),
)
const selectAuthor = (pubkey: string) => {
author = author === pubkey ? undefined : pubkey
}
const selectTopic = (name: string) => {
topic = topic === name ? undefined : name
}
</script>
<aside
class="hidden w-64 shrink-0 flex-col gap-4 py-2 pr-2 sm:py-4 sm:pr-4 lg:sticky lg:top-0 lg:flex lg:max-h-full lg:overflow-y-auto">
<section class="card card-sm flex flex-col gap-2">
<h2 class="text-lg">Authors</h2>
{#each authors as [pubkey, count] (pubkey)}
<Button
onclick={() => selectAuthor(pubkey)}
class={cx("button button-xs w-full rounded-full font-normal", {
"button-primary": pubkey === author,
})}>
<ProfileCircle {pubkey} {url} size={5} />
<span class="min-w-0 grow truncate text-left">
<ProfileName {pubkey} {url} />
</span>
<span class="opacity-75">{count}</span>
</Button>
{:else}
<p class="text-sm opacity-75">No authors yet.</p>
{/each}
</section>
{#if topics.length > 0}
<section class="card card-sm flex flex-col gap-2">
<h2 class="text-lg">Topics</h2>
<div class="flex flex-wrap gap-2">
{#each topics as [name, count] (name)}
<Button
onclick={() => selectTopic(name)}
class={cx("button button-xs rounded-full font-normal", {
"button-primary": name === topic,
})}>
#{name}
<span class="opacity-75">{count}</span>
</Button>
{/each}
</div>
</section>
{/if}
</aside>

View file

@ -41,8 +41,8 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
@ -58,4 +58,5 @@
{/if}
{/snippet}
</EventActions>
</ThunkStatusOrDeleted>
</div>

View file

@ -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())
</script>
{#if start}

View file

@ -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())
</script>
<div class="flex flex-col justify-between gap-1">

View file

@ -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())
</script>
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">

View file

@ -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)}
<ProfileLink {pubkey} />
{/each}
to enable direct messaging by opening this conversation in their $app.
to enable direct messaging by opening this conversation in their client.
</p>
</div>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import {removeUndefined} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {slide} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
@ -8,14 +9,15 @@
import {profiles} from "@app/core"
type Props = {
url?: string
verb: string
event: TrustedEvent
clear: () => void
}
const {verb, event, clear}: Props = $props()
const {url, verb, event, clear}: Props = $props()
const display = $profiles.display(event.pubkey).$
const display = $profiles.display(event.pubkey, removeUndefined([url])).$
</script>
<div

View file

@ -55,10 +55,11 @@
</button>
{/each}
</div>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event}>
{#snippet status()}
<ClassifiedStatus {event} />
</ThunkStatusOrDeleted>
{/snippet}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
@ -74,4 +75,5 @@
{/if}
{/snippet}
</EventActions>
</ThunkStatusOrDeleted>
</div>

View file

@ -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 @@
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
{#if showActivity && path}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Comment" />
</ThunkStatusOrDeleted>
</div>
</div>

View file

@ -0,0 +1,121 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util"
import {publishToRelays} from "@welshman/app"
import {Comment} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {command, relays, writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
type Values = {
content?: string | object
}
type Props = {
url: string
event: TrustedEvent
parent?: TrustedEvent
onCancel: () => void
onSubmit: () => void
}
const {url, event, parent, onCancel, onSubmit}: Props = $props()
const draftKey = new DraftKey<Values>(`comment:${event.id}:${parent?.id ?? ""}`)
const initialValues = draftKey.get()
const uploading = writable(false)
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content) {
return pushToast({theme: "error", message: "Please write something first."})
}
loading = true
try {
const eventWriter = writer(Comment)
.setContent(content)
.addTags(...ed.storage.nostr.getEditorTags())
.setRootFromEvent(event)
.setParentFromEvent(parent ?? event)
.setProtected(await $relays.hasNip(url, 70))
const thunk = await command(eventWriter).then(publishToRelays([url]))
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
onSubmit()
} finally {
loading = false
}
}
let loading = $state(false)
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
content,
placeholder: parent ? "Write a reply..." : "Write a comment...",
})
$effect(() => {
draftKey.set({content})
})
</script>
<form onsubmit={preventDefault(submit)} class="card card-sm flex flex-col gap-2">
<div class="relative">
<div class="note-editor grow overflow-hidden">
<EditorContent autofocus={!isMobile} {editor} />
</div>
<Button
data-tip="Add an image"
class="tip tip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
<div class="flex justify-end gap-2">
<Button class="button button-link" onclick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" class="button button-primary button-sm" disabled={$uploading || loading}>
<Spinner {loading}>
{#if parent}
Reply
{:else}
Comment
{/if}
</Spinner>
</Button>
</div>
</form>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import CommentTree from "@app/components/CommentTree.svelte"
import CommentCompose from "@app/components/CommentCompose.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import type {CommentNode} from "@app/social"
type Props = {
node: CommentNode
root: TrustedEvent
replyTo?: TrustedEvent
setReplyTo: (comment?: TrustedEvent) => void
url?: string
}
const {node, root, replyTo, setReplyTo, url}: Props = $props()
const composing = $derived(replyTo?.id === node.comment.id)
const reply = () => setReplyTo(node.comment)
const clearReplyTo = () => setReplyTo(undefined)
</script>
<div class="flex flex-col gap-3">
<NoteCard event={node.comment} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={node.comment} {url} />
{#if url}
<div class="flex flex-wrap items-center justify-between gap-2">
<Button class="button button-neutral button-sm" onclick={reply}>
<Icon icon={Reply} />
Reply
</Button>
<CommentActions event={node.comment} {url} />
</div>
{/if}
</div>
</NoteCard>
{#if composing && url}
<div class="ml-4 pl-4">
<CommentCompose
{url}
event={root}
parent={node.comment}
onCancel={clearReplyTo}
onSubmit={clearReplyTo} />
</div>
{/if}
{#if node.children.length > 0}
<div class="flex flex-col gap-3 border-l border-solid border-line ml-4 pl-4">
{#each node.children as child (child.comment.id)}
<CommentTree node={child} {root} {replyTo} {setReplyTo} {url} />
{/each}
</div>
{/if}
</div>

View file

@ -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
</Button>
</li>
<li>
<Button onclick={createArticle}>
<Icon size={4} icon={DocumentText} />
Write an Article
</Button>
</li>
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />

View file

@ -13,6 +13,7 @@
isInvoice,
isLink,
isProfile,
isRoom,
isEvent,
isEllipsis,
isAddress,
@ -35,7 +36,9 @@
import ContentQuote from "@app/components/ContentQuote.svelte"
import ContentTopic from "@app/components/ContentTopic.svelte"
import ContentMention from "@app/components/ContentMention.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {entityLink} from "@app/env"
import {makeRoomPath} from "@app/routes"
import {userSettingsValues} from "@app/settings"
type Props = {
@ -159,7 +162,7 @@
<div
class="overflow-hidden text-ellipsis wrap-break-word"
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
{#each shortContent as parsed, i}
{#each shortContent as parsed, i (i)}
{#if isNewline(parsed) && !isBlock(i - 1)}
<ContentNewline value={parsed.value} />
{:else if isTopic(parsed)}
@ -182,6 +185,10 @@
{/if}
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} {url} />
{:else if isRoom(parsed)}
<Link href={makeRoomPath(parsed.value.url, parsed.value.room)} class="link-content">
#<RoomName url={parsed.value.url} h={parsed.value.room} />
</Link>
{:else if isQuote(parsed)}
{#if isBlock(i)}
<ContentQuote {url} value={parsed.value} {event} />
@ -197,7 +204,7 @@
{@html renderAsHtml(parsed)}
<button
type="button"
class="text-sm underline"
class="pointer-events-auto text-sm underline"
onclick={stopPropagation(preventDefault(expand))}>
Read more
</button>

View file

@ -0,0 +1,123 @@
<script lang="ts" module>
import DOMPurify from "dompurify"
// Only the entity links linkEntities builds are internal. Testing for a leading slash would
// also match a protocol-relative `//evil.com`, which is very much not.
const isEntityPath = (href: string) =>
Boolean(href.match(/^\/n(event|ote|pub|profile|addr)1\w+$/))
// DOMPurify's defaults are wider than markdown needs — they keep `style` (overlay and
// click-jacking surface) and `form`. Allow only what marked actually emits.
const SANITIZE_OPTIONS = {
ALLOWED_TAGS: [
"a",
"blockquote",
"br",
"code",
"del",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
"li",
"ol",
"p",
"pre",
"strong",
"sub",
"sup",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
],
ALLOWED_ATTR: ["align", "alt", "href", "rel", "src", "start", "target", "title"],
}
// DOMPurify has no option for target, so its docs point at this hook. It runs once at module
// load rather than per render, since hooks live on the DOMPurify instance.
DOMPurify.addHook("afterSanitizeAttributes", node => {
if (node.tagName === "A" && !isEntityPath(node.getAttribute("href") ?? "")) {
node.setAttribute("target", "_blank")
node.setAttribute("rel", "noopener noreferrer")
}
})
</script>
<script lang="ts">
import {marked} from "marked"
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {removeUndefined} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {fromNostrURI} from "@welshman/util"
import {profiles} from "@app/core"
type Props = {
event: TrustedEvent
url?: string
}
const {event, url}: Props = $props()
const entityPattern = /(nostr:)?n(event|ote|pub|profile|addr)\w{10,1000}/g
// A display name is attacker-controlled, and it goes into markdown link syntax. Without this,
// a name containing `](` re-points its own link (phishing) and a name containing `<` injects
// raw html. Escaping also means a name renders as written rather than as markdown.
const escapeMarkdown = (text: string) => text.replace(/[\\[\]<`*_]/g, "\\$&")
// Bech32 entities aren't markdown, so swap them for links before parsing. Profiles get their
// display name, so an article reads as prose rather than a wall of bech32.
const linkEntities = (markdown: string) =>
markdown.replace(entityPattern, match => {
const entity = fromNostrURI(match)
const hints = removeUndefined([url])
let display = entity.slice(0, 16) + "…"
try {
const {type, data} = nip19.decode(entity)
if (type === "npub") {
display = "@" + $profiles.display(data, hints).get()
}
if (type === "nprofile") {
display = "@" + $profiles.display(data.pubkey, hints).get()
}
} catch {
// An entity we can't decode still reads better truncated than as raw bech32
}
return `[${escapeMarkdown(display)}](/${entity})`
})
const html = $derived(
DOMPurify.sanitize(marked.parse(linkEntities(event.content), {async: false}), SANITIZE_OPTIONS),
)
// Entity links point at the app's own bech32 route, so route them rather than reloading.
const onclick = (clickEvent: MouseEvent) => {
const href = (clickEvent.target as HTMLElement).closest("a")?.getAttribute("href") ?? undefined
if (href && isEntityPath(href)) {
clickEvent.preventDefault()
goto(href)
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div {onclick} class="content-markdown flex flex-col gap-4 overflow-hidden leading-6">
{@html html}
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import {removeUndefined} from "@welshman/lib"
import type {ProfilePointer} from "@welshman/content"
import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
@ -12,7 +13,7 @@
const {value, url}: Props = $props()
const display = $profiles.display(value.pubkey).$
const display = $profiles.display(value.pubkey, removeUndefined([url])).$
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
</script>

View file

@ -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}
<!-- eslint-disable-next-line svelte/no-useless-mustaches -->
{" "}
{:else}
<ContentNewline value={parsed.value} />

View file

@ -2,6 +2,6 @@
const {value} = $props()
</script>
{#each value as _}
{#each value as _, i (i)}
<br />
{/each}

View file

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

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {max, gt, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -12,7 +12,7 @@
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
const checked = deriveChecked(path)
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const filters = getCommentFiltersForRoot([event])
const replies = deriveEvents(filters)
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))

View file

@ -0,0 +1,66 @@
<script lang="ts">
import {onMount} from "svelte"
import {removeUndefined} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util"
import {deriveEventsAsc} from "@welshman/store"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import CommentTree from "@app/components/CommentTree.svelte"
import CommentCompose from "@app/components/CommentCompose.svelte"
import {network} from "@app/core"
import {deriveEventsById} from "@app/repository"
import {buildCommentTree} from "@app/social"
type Props = {
event: TrustedEvent
url?: string
}
const {event, url}: Props = $props()
const relays = removeUndefined([url])
const filters = getCommentFiltersForRoot([event])
const comments = deriveEventsAsc(deriveEventsById(filters))
const nodes = $derived(buildCommentTree(event, $comments))
const setReplyTo = (comment?: TrustedEvent) => {
replyTo = comment
}
const commentOnRoot = () => setReplyTo(event)
const clearReplyTo = () => setReplyTo(undefined)
let replyTo: TrustedEvent | undefined = $state()
onMount(() => {
if (relays.length > 0) {
const controller = new AbortController()
$network.request({relays, filters, signal: controller.signal})
return () => controller.abort()
}
})
</script>
<div class="flex flex-col gap-3">
{#each nodes as node (node.comment.id)}
<CommentTree {node} root={event} {replyTo} {setReplyTo} {url} />
{/each}
</div>
{#if url}
{#if replyTo?.id === event.id}
<CommentCompose {url} {event} onCancel={clearReplyTo} onSubmit={clearReplyTo} />
{:else}
<div class="flex justify-end">
<Button class="button button-primary" onclick={commentOnRoot}>
<Icon icon={Reply} />
Add a comment
</Button>
</div>
{/if}
{/if}

View file

@ -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()
}

View file

@ -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">
<div class="card mx-2 my-2 shadow-md">
{#if parent}
<ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" />
<ChatComposeParent {url} event={parent} clear={() => onClearParent?.()} verb="Replying to" />
{/if}
<div class="relative">
<div class="note-editor grow overflow-hidden">

View file

@ -36,10 +36,11 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
</ThunkStatusOrDeleted>
</div>

View file

@ -49,7 +49,7 @@
</label>
<div class="mt-2 max-h-80 overflow-y-auto">
<div class="grid grid-cols-8 gap-2 p-2">
{#each filteredIcons as icon}
{#each filteredIcons as icon (icon.url)}
<button
type="button"
title={icon.name}

View file

@ -114,7 +114,7 @@
Log in with Extension
</Button>
{/if}
{#each signers as signerApp}
{#each signers as signerApp (signerApp.name)}
<Button {disabled} class="button button-primary" onclick={() => loginWithNip55(signerApp)}>
{#if loading === "nip55"}
<Spinner size="sm" class="mr-3" />

View file

@ -1,9 +1,19 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL, PIN, PINBOARD} from "@welshman/util"
import {
EVENT_TIME,
ZAP_GOAL,
THREAD,
CLASSIFIED,
LONG_FORM,
POLL,
PIN,
PINBOARD,
} from "@welshman/util"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
import NoteContentArticle from "@app/components/NoteContentArticle.svelte"
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
import NoteContentPin from "@app/components/NoteContentPin.svelte"
@ -20,6 +30,8 @@
<NoteContentThread {...props} />
{:else if props.event.kind === CLASSIFIED}
<NoteContentClassified {...props} />
{:else if props.event.kind === LONG_FORM}
<NoteContentArticle {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentGoal {...props} />
{:else if props.event.kind === POLL}

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {Article} from "@welshman/domain"
import Content from "@app/components/Content.svelte"
import {reader} from "@app/core"
const props: ComponentProps<typeof Content> = $props()
const article = reader(Article)(props.event)
const title = article.title()
const image = article.image()
</script>
<div class="flex flex-col gap-2">
{#if image}
<img src={image} alt="" class="h-40 w-full rounded-2xl object-cover" />
{/if}
{#if title}
<p class="text-xl">{title}</p>
{/if}
{#if props.event.content}
<Content {...props} />
{/if}
</div>

View file

@ -1,9 +1,19 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL, PINBOARD, PIN} from "@welshman/util"
import {
EVENT_TIME,
ZAP_GOAL,
THREAD,
CLASSIFIED,
LONG_FORM,
POLL,
PINBOARD,
PIN,
} from "@welshman/util"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
import NoteContentMinimalArticle from "@app/components/NoteContentMinimalArticle.svelte"
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
import NoteContentMinimalBoard from "@app/components/NoteContentMinimalBoard.svelte"
@ -20,6 +30,8 @@
<NoteContentMinimalThread {...props} />
{:else if props.event.kind === CLASSIFIED}
<NoteContentMinimalClassified {...props} />
{:else if props.event.kind === LONG_FORM}
<NoteContentMinimalArticle {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentMinimalGoal {...props} />
{:else if props.event.kind === POLL}

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {Article} from "@welshman/domain"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {reader} from "@app/core"
const props: ComponentProps<typeof ContentMinimal> = $props()
const title = reader(Article)(props.event).title()
</script>
{#if title}
<span class="text-sm">{title}</span>
{/if}
{#if props.event.content}
<ContentMinimal {...props} />
{/if}

View file

@ -9,6 +9,7 @@
<script lang="ts">
import {writable} from "svelte/store"
import {slide} from "@lib/transition"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
@ -83,6 +84,19 @@
<ModalTitle>{heading}</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<Field>
{#snippet label()}
Link
{/snippet}
{#snippet info()}
<p>A URL or nostr link (note, nevent, naddr, npub, or nprofile).</p>
{/snippet}
{#snippet input()}
<input bind:value class="input w-full" placeholder="URL or nevent..." />
{/snippet}
</Field>
{#if value.trim()}
<div transition:slide class="card card-sm flex flex-col gap-4 opacity-75">
<Field>
{#snippet label()}
Title
@ -122,17 +136,8 @@
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
{/snippet}
</Field>
<Field>
{#snippet label()}
Link
{/snippet}
{#snippet info()}
<p>A URL or nostr link (note, nevent, naddr, npub, or nprofile).</p>
{/snippet}
{#snippet input()}
<input bind:value class="input w-full" placeholder="URL or nevent..." />
{/snippet}
</Field>
</div>
{/if}
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>

View file

@ -33,10 +33,11 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Poll" />
</ThunkStatusOrDeleted>
</div>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {removeUndefined} from "@welshman/lib"
import {displayHandle} from "@welshman/util"
import {displayPubkey} from "@welshman/domain"
import Icon from "@lib/components/Icon.svelte"
@ -22,7 +23,7 @@
const {pubkey, url, showPubkey, inert, avatarSize = 10}: Props = $props()
const profileDisplay = $profiles.display(pubkey).$
const profileDisplay = $profiles.display(pubkey, removeUndefined([url])).$
const handle = $handles.forPubkey(pubkey).$
const openProfile = () => {
@ -35,11 +36,11 @@
<div class="flex max-w-full items-start gap-2">
{#if inert}
<span class="py-1">
<ProfileCircle {pubkey} size={avatarSize} />
<ProfileCircle {pubkey} {url} size={avatarSize} />
</span>
{:else}
<Button onclick={openProfile} class="py-1">
<ProfileCircle {pubkey} size={avatarSize} />
<ProfileCircle {pubkey} {url} size={avatarSize} />
</Button>
{/if}
<div class="flex min-w-0 flex-col">

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {spec} from "@welshman/lib"
import {removeUndefined, spec} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
@ -48,7 +48,7 @@
const banMember = () =>
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${$profiles.display(pubkey).get()} from the space?`,
message: `Are you sure you want to ban @${$profiles.display(pubkey, removeUndefined([url])).get()} from the space?`,
confirm: async () => {
const {error} = await $relayManagement.forUrl(url!).banPubkey(pubkey)

View file

@ -1,6 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {removeUndefined} from "@welshman/lib"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -19,7 +20,7 @@
const {pubkey, url, onClick, customActions}: Props = $props()
const profile = $profiles.one(pubkey)
const profile = $profiles.one(pubkey, removeUndefined([url]))
const showInfo = () => pushModal(ProfileInfo, {event: $profile!.event, url})

View file

@ -63,7 +63,8 @@
<ProfileCircles pubkeys={$followers} limit={5} />
<p class="text-sm opacity-75">
{$followers.length}
{$followers.length === 1 ? "person" : "people"} you follow also follow
{$followers.length === 1 ? "person" : "people"} you follow also
{$followers.length === 1 ? "follows" : "follow"}
{$profileDisplay}.
</p>
</div>

View file

@ -3,7 +3,7 @@
</script>
<div class="flex w-full">
{#each Array(total) as _, i}
{#each Array(total) as _, i (i)}
<div
class="h-1 flex-1 transition-colors {i < current
? 'bg-primary text-primary-content'

View file

@ -18,7 +18,7 @@
{:else if event.content === "-"}
<Icon icon={ThumbsDown} />
{:else}
{#each parse(event) as parsed}
{#each parse(event) as parsed, i (i)}
{#if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else}

View file

@ -59,7 +59,7 @@
event,
deleteReaction,
createReaction,
url = "",
url,
reactionClass = "",
noTooltip = false,
innerEvent = undefined,
@ -110,13 +110,12 @@
pushModal(ProfileList, {title, subtitle, pubkeys, url})
const onReactionClick = (events: TrustedEvent[], pubkeys: string[], info: string) => {
if (isMobile) {
if (isMobile && !pubkeys.includes($user.pubkey)) {
showReactors(pubkeys, info.replace(" reacted", ""), "Reacted to this message")
return
}
} else {
toggleReaction(events)
}
}
const onZapClick = (pubkeys: string[], info: string) => {
if (isMobile) {
@ -193,11 +192,13 @@
<span>{$reports.length}</span>
</Button>
{/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)}
<Button
@ -216,10 +217,12 @@
<span>{amount}</span>
</Button>
{/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)}
<Button

View file

@ -1,8 +1,9 @@
<script lang="ts">
import {THREAD, CLASSIFIED, ZAP_GOAL, EVENT_TIME, POLL} from "@welshman/util"
import {THREAD, CLASSIFIED, LONG_FORM, ZAP_GOAL, EVENT_TIME, POLL} from "@welshman/util"
import NoteItem from "@app/components/NoteItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import ArticleItem from "@app/components/ArticleItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import PollItem from "@app/components/PollItem.svelte"
@ -23,6 +24,8 @@
<ThreadItem {url} event={item.event} />
{:else if item.event.kind === CLASSIFIED}
<ClassifiedItem {url} event={item.event} />
{:else if item.event.kind === LONG_FORM}
<ArticleItem {url} event={item.event} />
{:else if item.event.kind === ZAP_GOAL}
<GoalItem {url} event={item.event} />
{:else if item.event.kind === EVENT_TIME}

View file

@ -18,7 +18,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RelayItem from "@app/components/RelayItem.svelte"
import {pushToast} from "@app/toast"
import {app} from "@app/core"
import {app, blockedRelayLists, user} from "@app/core"
interface Props {
relays: Readable<string[]>
@ -54,8 +54,9 @@
let limit = $state(20)
let element: Element | undefined = $state()
const loading = $state(new SvelteSet<string>())
const loading = new SvelteSet<string>()
const relaySearch = $app.use(Relays).relaySearch
const blockedRelayUrls = $blockedRelayLists.urls($user.pubkey).$
const searchResults = $derived(
$relaySearch
@ -64,6 +65,7 @@
if (matchRelay?.(url) === false) return false
if ($relays.includes(url)) return false
if (isIPAddress(url)) return false
if ($blockedRelayUrls.includes(url)) return false
return true
})

View file

@ -51,7 +51,7 @@
}
}
const loading = $state(new SvelteSet<string>())
const loading = new SvelteSet<string>()
</script>
<Modal>

View file

@ -33,7 +33,7 @@
{PLATFORM_NAME} actively checks your connection to the network in the background to discover relays
that are offline, that you don't have access to, or are otherwise causing trouble.
</p>
{#each $pending as healthCheck}
{#each $pending as healthCheck (healthCheck.title)}
<RelaySettingsHealthCheck {healthCheck} />
{/each}
{#if $pending.length > 0}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import {Report} from "@welshman/domain"
import {publishToRelays} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@ -37,9 +36,15 @@
.setReason(reason.toLowerCase())
.setContent(content)
await command(eventWriter).then(publishToRelays([url]))
const reportCommand = await command(eventWriter)
const error = await reportCommand.publishToRelays([url]).waitForError()
loading = false
if (error) {
return pushToast({theme: "error", message: error})
}
history.back()
return pushToast({message: "Your report has been sent!"})

View file

@ -41,7 +41,7 @@
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
pushToast({message: "Report has successfully been dismissed!"})
$app.repository.removeEvent(event.id)
onResolved?.()
}
@ -74,7 +74,7 @@
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${$profiles.display(reported).get()} from the space?`,
message: `Are you sure you want to ban @${$profiles.display(reported, [url]).get()} from the space?`,
confirm: async () => {
const {error} = await $relayManagement.forUrl(url).banPubkey(reported, reason)

View file

@ -26,7 +26,7 @@
try {
const {error} = await $relayManagement
.forUrl(url)
.createRole(randomId(), label, description, String(color), 1)
.createRole(randomId(), label, description, color, 1)
if (error) {
pushToast({theme: "error", message: error})

View file

@ -34,7 +34,7 @@
try {
const {error} = await $relayManagement
.forUrl(url)
.editRole(id, label, description, String(color), role.order())
.editRole(id, label, description, color, role.order())
if (error) {
pushToast({theme: "error", message: error})

View file

@ -10,7 +10,7 @@
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
import {publish} from "@welshman/app"
import {MembershipStatus} from "@welshman/app"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
@ -31,13 +31,12 @@
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte"
import {deletes, relays, rooms, thunks, user} from "@app/core"
import {publishRoomJoinRequest} from "@app/access"
import {joinRoom, leaveRoom} from "@app/access"
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
import {
PROTECTED,
RoomType,
deriveUserRoomMembershipStatus,
MembershipStatus,
getRoomType,
prependParent,
} from "@app/rooms"
@ -131,18 +130,14 @@
joining = true
try {
const thunk = await (inviteCode
? publishRoomJoinRequest(url, h, inviteCode)
: $rooms.joinRoom(url, {h}).then(publish))
const message = await thunk.waitForError()
if (message && !message.startsWith("duplicate:")) {
return pushToast({theme: "error", message})
}
const message = await joinRoom(url, h, inviteCode)
if (message) {
pushToast({theme: "error", message})
} else {
// Restart the feed now that we're a member
start()
}
} finally {
joining = false
}
@ -152,12 +147,14 @@
const leave = async () => {
if (h) {
leaving = true
try {
const thunk = await $rooms.leaveRoom(url, {h}).then(publish)
const message = await thunk.waitForError()
if (message && !message.startsWith("duplicate:")) {
try {
const message = await leaveRoom(url, h)
if (message) {
pushToast({theme: "error", message})
} else {
pushToast({message: "You have left the room."})
}
} finally {
leaving = false
@ -642,10 +639,10 @@
{:else}
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
<RoomComposeParent {url} event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if sharedEvent}
<RoomComposeParent event={sharedEvent} clear={clearShare} verb="Sharing" />
<RoomComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />

View file

@ -28,7 +28,8 @@
const {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
const draftKey = url || h ? new DraftKey<Draft>(`room:${url ?? ""}:${h ?? ""}`) : undefined
const draftKey =
(url || h) && !initialValues ? new DraftKey<Draft>(`room:${url ?? ""}:${h ?? ""}`) : undefined
const autofocus = !isMobile

Some files were not shown because too many files have changed in this diff Show more