Compare commits
No commits in common. "c0f545350c5083d3cabb592cb636718128c61bb8" and "5cbc1e459ce76ecdfb349e4bb2e691aa22ffe1ba" have entirely different histories.
c0f545350c
...
5cbc1e459c
165 changed files with 923 additions and 13471 deletions
|
|
@ -1,35 +0,0 @@
|
|||
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
|
||||
|
|
@ -14,30 +14,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ pnpm run check # Type check
|
|||
**Welshman Development:**
|
||||
|
||||
- Clone welshman to parent directory
|
||||
- Use `./scripts/link-deps.mjs` to link local welshman packages
|
||||
- Use `./link_deps` script to link local welshman packages
|
||||
- Avoid committing `pnpm.overrides` changes
|
||||
|
||||
**Git Workflow:**
|
||||
|
|
|
|||
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -1,20 +1,5 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -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 scripts/build.sh for branding config.
|
||||
# A .env in the build context is picked up by build.sh for branding config.
|
||||
|
||||
# https://pnpm.io/docker#example-3-build-on-cicd
|
||||
FROM node:24-slim AS builder
|
||||
|
|
|
|||
|
|
@ -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 `scripts/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 `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.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ android {
|
|||
applicationId "social.flotilla"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 51
|
||||
versionName "1.9.1"
|
||||
versionCode 50
|
||||
versionName "1.9.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# 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 opens a connection to a host the test did not create.
|
||||
test's control. Nothing in this directory may ever open a connection to a host the test did not
|
||||
create. That is the single invariant everything else is arranged around.
|
||||
|
||||
## The relay
|
||||
|
||||
|
|
@ -16,17 +17,13 @@ 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. `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.
|
||||
harness.
|
||||
|
||||
### Why the relays are called `<name>.test`
|
||||
|
||||
The container listens on plaintext loopback, but a url that is local or insecure is dropped from
|
||||
every relay selection unless the caller opts in — see `isLocalUrl` and `RelaySelection.getUrls` in
|
||||
`@welshman/util` — and the app never opts in, since in production neither belongs in a routing
|
||||
`@welshman/util` — and Flotilla never opts in, since in production neither belongs in a routing
|
||||
decision. Handed `ws://localhost:3334/`, the client would load a space by its explicit url and
|
||||
resolve nothing else: no outbox loads for profiles or relay lists, no relay hints.
|
||||
|
||||
|
|
@ -62,29 +59,30 @@ 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 in the node process, and the only one that leaves
|
||||
Every socket the browser opens is terminated here, in this process, and the only one that leaves it
|
||||
is the loopback connection `zooid/transport.ts` makes to the container the test started.
|
||||
`assertNoLeaks()` fails a test that touched a url the scenario never declared.
|
||||
|
||||
Interception is installed by `as()` 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
|
||||
Interception is installed by `as()`, on a context it creates, so a page that came from anywhere else
|
||||
has none of it. Playwright's own `context` fixture — and the `page` fixture built on it — is
|
||||
therefore overridden to throw, so a spec written the ordinary way fails immediately with a message
|
||||
saying so rather than quietly dialling the relays in `.env`. The built-in `request` fixture goes the
|
||||
same way: an `APIRequestContext` is an http client in the node process that belongs to no browser
|
||||
context, so the block-all below cannot see it and nothing records what it sent.
|
||||
|
||||
`playwright` is the one fixture left alone, because it is where the run's own browser comes
|
||||
`playwright` is the one fixture left alone, and it has to be: it is where the run's own browser comes
|
||||
from, so every test would fail if it threw. A spec that goes around `as()` through
|
||||
`playwright.request` or `playwright.chromium.launch()` reaches the network unwatched, and no fixture
|
||||
can refuse that without refusing the suite.
|
||||
|
||||
Interception in Node rather than in the page enables multi-user testing. Three browser
|
||||
Interception in Node rather than in the page is what makes multi-user testing work. Three browser
|
||||
contexts logged in as three different users all dispatch into the _same_ relay, so one user
|
||||
genuinely observes another user's writes, over the wire, through the client's real socket stack.
|
||||
|
||||
### Why not an `AdapterFactory`
|
||||
|
||||
An `AdapterFactory` backed by a `Repository`-backed adapter cannot test authentication. NIP-42 lives on `Socket`: `AuthState` listens to `SocketEvent.Receiving`/
|
||||
The obvious alternative — `new App({getAdapter})` returning a `Repository`-backed adapter — cannot
|
||||
test authentication. NIP-42 lives on `Socket`: `AuthState` listens to `SocketEvent.Receiving`/
|
||||
`Sending`, and `socketPolicyAuthBuffer` replays messages that were rejected with `auth-required:`.
|
||||
An `AbstractAdapter` whose `sockets` getter returns `[]` never constructs any of that. Patching the
|
||||
transport instead leaves `Pool → Socket → SocketAdapter` untouched, so auth, message buffering,
|
||||
|
|
@ -94,7 +92,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 matched — and aborts what it catches. Two origins get past it: the dev server
|
||||
in dev are never even matched — and aborts what it catches. Two origins get past it: the dev server
|
||||
on `localhost:1847`, which is left unrouted, and each relay's own origin, which is forwarded to the
|
||||
container by the same transport carrying the same two headers, so the NIP-11 document and the NIP-86
|
||||
management API the app reads are the real relay's answers, signed against the url the client used.
|
||||
|
|
@ -107,21 +105,13 @@ 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 mocked per-scenario, so a blocked request is always a bug
|
||||
Services the app talks to are then mocked back in per-scenario, so a blocked request is always a bug
|
||||
rather than ambient noise: Dufflepud (`dufflepud.coracle.social`), Blossom uploads, the push server
|
||||
(`nps.flotilla.social`), the hosting API, LiveKit token endpoints, and image/thumbnail fetches. The
|
||||
analytics script hard-coded in `src/app.html` is mocked with an empty body to avoid making
|
||||
analytics script hard-coded in `src/app.html` is mocked with an empty body: it is requested on every
|
||||
navigation whatever the scenario is doing, so leaving it to the block-all would make
|
||||
`assertNoBlockedRequests()` a statement about the page shell rather than about the test.
|
||||
|
||||
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.
|
||||
|
|
@ -157,7 +147,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.
|
||||
configuration, which is the weaker of the two guarantees.
|
||||
|
||||
Grepping the repo for hostnames accounts for all of them, and there are only three kinds. Most are an
|
||||
`href` in help text — nostr.com, nostrapps.com, nsec.app, nosta.me, nostr.how, github.com,
|
||||
|
|
@ -209,8 +199,6 @@ 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
|
||||
|
|
@ -244,24 +232,6 @@ 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
|
||||
|
||||
```
|
||||
|
|
@ -286,11 +256,9 @@ 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,
|
||||
direct messages, and anything a domain writer renders
|
||||
space.ts one space's fixtures: rooms, members, messages, replies, profiles
|
||||
specs/
|
||||
*.spec.ts
|
||||
```
|
||||
|
|
@ -304,23 +272,13 @@ 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. 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.
|
||||
fresh database. The configs are bind-mounted read-only and survive, so every test starts against the
|
||||
same relays with nothing in them. The container is a worker fixture, so the docker start-up cost is
|
||||
paid once per worker rather than once per test.
|
||||
|
||||
Seeding then writes over a real authenticated socket, one per identity per relay, held open for the
|
||||
rest of the test. A fixture must therefore be signed by 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.
|
||||
rest of the test. A fixture must therefore be signed by one of the identities in `harness/keys.ts`,
|
||||
since zooid authenticates every write.
|
||||
|
||||
## Running
|
||||
|
||||
|
|
@ -334,9 +292,9 @@ pnpm test # starts and stops the con
|
|||
|
||||
Every test skips when docker is unavailable, rather than failing.
|
||||
|
||||
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
|
||||
One engine per run rather than a project per engine: these specs exercise sockets, auth and sync, so
|
||||
a third copy of each buys much less than it costs. `E2E_BROWSER=webkit pnpm test` runs the whole
|
||||
suite under another one. The container listens on a fixed port and cannot be sharded, which is why
|
||||
`workers` is 1.
|
||||
|
||||
`src/app/env.ts` resolves every `VITE_` value through a DEV-only hook that prefers
|
||||
|
|
|
|||
1570
e2e/USER_STORIES.md
1570
e2e/USER_STORIES.md
File diff suppressed because it is too large
Load diff
|
|
@ -20,14 +20,11 @@ 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 = "/", env = {}}: BootOptions,
|
||||
{relays, spaces = [], user, events = [], path = "/"}: BootOptions,
|
||||
) => {
|
||||
const urls = relays.join(",")
|
||||
|
||||
|
|
@ -58,7 +55,6 @@ 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -10,32 +10,22 @@ 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 {makeTestUser, users} from "./keys"
|
||||
export {users} from "./keys"
|
||||
export type {TestUser} from "./keys"
|
||||
export type {Scenario} from "./seed/scenario"
|
||||
export type {SeededRumor, SeededSpace} from "./seed/space"
|
||||
export type {SeededSpace} from "./seed/space"
|
||||
export type {TenantName} from "./zooid/config"
|
||||
export type {TranscriptEntry} from "./net/websocket"
|
||||
export {formatTranscript, getTranscript} from "./net/websocket"
|
||||
export {
|
||||
assertNoBlockedRequests,
|
||||
getHosting,
|
||||
mockBlossom,
|
||||
mockDufflepud,
|
||||
mockLivekit,
|
||||
} from "./net/http"
|
||||
export type {DufflepudFixtures, HostingFixtures, HostingHandle, HostingRecord} from "./net/http"
|
||||
export {assertNoBlockedRequests, mockBlossom, mockLivekit} from "./net/http"
|
||||
|
||||
// Mirrors encodeRelay in src/app/relays.ts, which can't be imported here — it reaches the app's
|
||||
// module graph, and with it sveltekit.
|
||||
|
|
@ -50,40 +40,19 @@ 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.
|
||||
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>
|
||||
// the relays every other user is talking to. `options` overrides the project's context options,
|
||||
// for a spec that needs a viewport or colour scheme of its own.
|
||||
as(user: TestUser, path?: string, options?: BrowserContextOptions): Promise<Page>
|
||||
}
|
||||
|
||||
export type HarnessFixtures = {
|
||||
harness: Harness
|
||||
seed: Harness["seed"]
|
||||
as: Harness["as"]
|
||||
visit: Harness["visit"]
|
||||
}
|
||||
|
||||
export type HarnessWorkerFixtures = {
|
||||
|
|
@ -94,13 +63,11 @@ 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()`/`visit()` are the only ways to get a
|
||||
// page.
|
||||
// exists to prevent, so it is refused outright and `as()` is the only way to get a page.
|
||||
context: async () => {
|
||||
throw new Error(
|
||||
"The built-in `context` and `page` fixtures reach the real network. Open a page with the " +
|
||||
"harness's `as(user, path)` or `visit(path)` fixture, which install interception before " +
|
||||
"they navigate.",
|
||||
"harness's `as(user, path)` fixture, which installs interception before it navigates.",
|
||||
)
|
||||
},
|
||||
// Playwright builds this one with `playwright.request.newContext()`, so it is an http client in
|
||||
|
|
@ -144,7 +111,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
throw new Error("Seed a scenario before opening a page for a user")
|
||||
}
|
||||
|
||||
const open = async (path: string, options: PageOptions, user?: TestUser) => {
|
||||
const as = async (user: TestUser, path = "/", options: BrowserContextOptions = {}) => {
|
||||
const {urls, cache} = requireScenario()
|
||||
// The project's own `use` first, so a viewport, colour scheme or device descriptor set in
|
||||
// playwright.config.ts reaches the context rather than being silently dropped.
|
||||
|
|
@ -156,7 +123,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
const context = await browser.newContext({
|
||||
...testInfo.project.use,
|
||||
serviceWorkers: "block",
|
||||
...options.context,
|
||||
...options,
|
||||
})
|
||||
|
||||
contexts.push(context)
|
||||
|
|
@ -166,31 +133,18 @@ 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, options.hosting)
|
||||
await mockHosting(context)
|
||||
await mockPushServer(context)
|
||||
await mockImages(context)
|
||||
|
||||
if (options.nip07) {
|
||||
await injectNip07(context, options.nip07)
|
||||
}
|
||||
|
||||
return boot(context, {
|
||||
user,
|
||||
path,
|
||||
env: options.env,
|
||||
relays: urls,
|
||||
spaces: urls,
|
||||
events: user ? cache(user) : [],
|
||||
})
|
||||
return boot(context, {user, path, relays: urls, spaces: urls, events: cache(user)})
|
||||
}
|
||||
|
||||
await use({
|
||||
zooid,
|
||||
as: (user, path = "/", options = {}) => open(path, options, user),
|
||||
visit: (path = "/", options = {}) => open(path, options),
|
||||
as,
|
||||
seed: async build => {
|
||||
scenario = await seed(zooid, build)
|
||||
|
||||
|
|
@ -208,5 +162,4 @@ 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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import {createHash} from "node:crypto"
|
||||
import {getPubkey} from "@welshman/util"
|
||||
import {Nip01Signer} from "@welshman/signer"
|
||||
|
||||
|
|
@ -9,17 +8,12 @@ export type TestUser = {
|
|||
signer: Nip01Signer
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
const makeUser = (name: string, secret: string): TestUser => ({
|
||||
name,
|
||||
secret,
|
||||
pubkey: getPubkey(secret),
|
||||
signer: new Nip01Signer(secret),
|
||||
})
|
||||
|
||||
// The secrets are near-zero entropy on purpose: they never leave the test process, they are
|
||||
// stable across runs so a pubkey can be asserted on directly, and the leading nibbles make an
|
||||
|
|
@ -30,19 +24,3 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {createHash} from "node:crypto"
|
||||
import type {BrowserContext} from "@playwright/test"
|
||||
import {HOUR, int, now, omit} from "@welshman/lib"
|
||||
import {now} from "@welshman/lib"
|
||||
import type {Handle} from "@welshman/util"
|
||||
import type {ZapperValues} from "@welshman/domain"
|
||||
import {tenantByUrl} from "../zooid/config"
|
||||
|
|
@ -16,10 +16,6 @@ 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"
|
||||
|
||||
|
|
@ -35,13 +31,6 @@ 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
|
||||
|
|
@ -68,6 +57,12 @@ 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)
|
||||
|
||||
|
|
@ -119,56 +114,6 @@ 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
|
||||
|
|
@ -190,12 +135,6 @@ 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())
|
||||
|
|
@ -293,211 +232,48 @@ export const mockPushServer = (context: BrowserContext) =>
|
|||
return route.fallback()
|
||||
})
|
||||
|
||||
// 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.
|
||||
// Records straight off the hosting api, whose shapes live in src/app/hosting.ts. They're typed as
|
||||
// plain objects here so that mocking a payment flow doesn't pull the app's module graph — and
|
||||
// with it import.meta.env — into the node process.
|
||||
export type HostingFixtures = {
|
||||
plans?: HostingRecord[]
|
||||
plans?: object[]
|
||||
// Provisioning runs on every login and ignores what it gets back, so the empty default carries
|
||||
// any spec that isn't actually exercising the hosting ui.
|
||||
tenant?: HostingRecord
|
||||
relays?: HostingRecord[]
|
||||
invoices?: HostingRecord[]
|
||||
draftInvoice?: HostingRecord
|
||||
tenant?: object
|
||||
relays?: object[]
|
||||
invoices?: object[]
|
||||
draftInvoice?: object
|
||||
}
|
||||
|
||||
// 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}`}})
|
||||
export const mockHosting = (context: BrowserContext, fixtures: HostingFixtures = {}) =>
|
||||
context.route(`${HOSTING_ORIGIN}/**`, route => {
|
||||
const [resource, id, sub, detail] = new URL(route.request().url()).pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
|
||||
if (resource === "plans") {
|
||||
return route.fulfill({json: {data: plans}})
|
||||
return route.fulfill({json: {data: fixtures.plans ?? []}})
|
||||
}
|
||||
|
||||
if (resource === "tenants") {
|
||||
// Provisioning is the one tenant route with no pubkey in the path.
|
||||
if (id) {
|
||||
tenant = {...tenant, pubkey: id}
|
||||
if (sub) {
|
||||
if (sub === "relays") {
|
||||
return route.fulfill({json: {data: fixtures.relays ?? []}})
|
||||
}
|
||||
|
||||
if (sub === "invoices") {
|
||||
const data = detail === "draft" ? fixtures.draftInvoice : (fixtures.invoices ?? [])
|
||||
|
||||
return route.fulfill({json: {data}})
|
||||
}
|
||||
} else {
|
||||
tenant = {...tenant, ...body}
|
||||
|
||||
return route.fulfill({json: {data: tenant}})
|
||||
return route.fulfill({json: {data: {pubkey: id, ...fixtures.tenant}}})
|
||||
}
|
||||
|
||||
if (sub === "relays") {
|
||||
return route.fulfill({json: {data: Array.from(relays.values())}})
|
||||
}
|
||||
|
||||
if (sub === "invoices") {
|
||||
const data = detail === "draft" ? fixtures.draftInvoice : Array.from(invoices.values())
|
||||
|
||||
return route.fulfill({json: {data}})
|
||||
}
|
||||
|
||||
if (sub === "stripe") {
|
||||
return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/portal/${id}`}}})
|
||||
}
|
||||
|
||||
// A wallet is the only thing the app updates a tenant with, so saving one is what sets it.
|
||||
if (method === "PUT") {
|
||||
tenant = {...tenant, ...body, nwc_is_set: Boolean(body.nwc_url)}
|
||||
}
|
||||
|
||||
// GET and reconcile both answer with the tenant as it now stands.
|
||||
return route.fulfill({json: {data: tenant}})
|
||||
}
|
||||
|
||||
if (resource === "relays") {
|
||||
if (!id) {
|
||||
const relay = {
|
||||
id: `relay-${relays.size + 1}`,
|
||||
status: "active",
|
||||
sync_error: "",
|
||||
synced: now(),
|
||||
custom_domain: "",
|
||||
custom_domain_verified: 0,
|
||||
...body,
|
||||
// A created space is opened straight away, so its url has to be one the container
|
||||
// serves. The client names the domain from VITE_HOSTING_RELAY_DOMAIN.
|
||||
zooid_domain: body.zooid_domain || "test",
|
||||
}
|
||||
|
||||
relays.set(String(relay.id), relay)
|
||||
|
||||
return route.fulfill({json: {data: relay}})
|
||||
}
|
||||
|
||||
const relay = relays.get(id)
|
||||
|
||||
if (!relay) {
|
||||
return missing(`relay ${id}`)
|
||||
}
|
||||
|
||||
if (sub === "members") {
|
||||
return route.fulfill({json: {data: {members: relay.members ?? []}}})
|
||||
}
|
||||
|
||||
if (sub === "activity") {
|
||||
return route.fulfill({json: {data: {activity: relay.activity ?? []}}})
|
||||
}
|
||||
|
||||
const updated = {...relay, ...(method === "PUT" ? body : {})}
|
||||
|
||||
if (sub === "deactivate") {
|
||||
updated.status = "inactive"
|
||||
}
|
||||
|
||||
if (sub === "reactivate") {
|
||||
updated.status = "active"
|
||||
}
|
||||
|
||||
relays.set(id, updated)
|
||||
|
||||
return route.fulfill({json: {data: updated}})
|
||||
}
|
||||
|
||||
if (resource === "invoices") {
|
||||
const invoice = invoices.get(id)
|
||||
|
||||
if (!invoice) {
|
||||
return missing(`invoice ${id}`)
|
||||
}
|
||||
|
||||
if (sub === "items") {
|
||||
return route.fulfill({json: {data: invoice.items ?? []}})
|
||||
}
|
||||
|
||||
if (sub === "bolt11") {
|
||||
const bolt11 = invoice.bolt11 ?? {
|
||||
id: `bolt11-${id}`,
|
||||
invoice_id: id,
|
||||
lnbc: `lnbc${id}`,
|
||||
msats: 0,
|
||||
created_at: now(),
|
||||
expires_at: now() + int(1, HOUR),
|
||||
}
|
||||
|
||||
return route.fulfill({json: {data: bolt11}})
|
||||
}
|
||||
|
||||
if (sub === "checkout") {
|
||||
return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/invoices/${id}`}}})
|
||||
}
|
||||
|
||||
// GET and reconcile both answer with the invoice as it now stands, which is how a spec
|
||||
// marks one paid: flip `paid_at` with the handle and let the dialog's next poll find it.
|
||||
return route.fulfill({json: {data: invoice}})
|
||||
}
|
||||
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
export type LivekitOptions = {
|
||||
// Where the client is told to connect. Point it at something the test owns — the token this
|
||||
// hands out is accepted by nothing else.
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@ import {
|
|||
ROOM_ADD_MEMBER,
|
||||
Resolver,
|
||||
makeEvent,
|
||||
prep,
|
||||
tagSpec,
|
||||
tagValue,
|
||||
toNostrURI,
|
||||
} from "@welshman/util"
|
||||
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 {SignedEvent, StampedEvent} from "@welshman/util"
|
||||
import {EventWriter, Profile} from "@welshman/domain"
|
||||
import type {BaseEventReader} from "@welshman/domain"
|
||||
import type {RoomOptions, TestRelay} from "../zooid/types"
|
||||
import type {Zooid} from "../zooid/relay"
|
||||
import type {TenantName} from "../zooid/config"
|
||||
|
|
@ -32,18 +30,6 @@ 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
|
||||
|
|
@ -75,16 +61,7 @@ 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: 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>
|
||||
event(user: TestUser, template: StampedEvent): SeededEvent
|
||||
}
|
||||
|
||||
export type SeedSpaceOptions = {
|
||||
|
|
@ -114,24 +91,18 @@ 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])}
|
||||
|
||||
// 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 publish = (write: () => Promise<SignedEvent>): SeededEvent => {
|
||||
let signed: Maybe<SignedEvent>
|
||||
|
||||
enqueue(async () => {
|
||||
value = await write()
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (value) return value
|
||||
const event = () => {
|
||||
if (signed) return signed
|
||||
|
||||
throw new Error(`An event seeded into "${name}" was read before seed() published it`)
|
||||
}
|
||||
}
|
||||
|
||||
const publish = (write: () => Promise<SignedEvent>): SeededEvent => {
|
||||
const event = seeded(write)
|
||||
enqueue(async () => {
|
||||
signed = await write()
|
||||
})
|
||||
|
||||
return {
|
||||
get event() {
|
||||
|
|
@ -151,14 +122,7 @@ 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: SeededTemplate, createdAt = startedAt) =>
|
||||
publishTemplate(user, async () => {
|
||||
if (typeof template === "function") {
|
||||
return {...(await template()), created_at: createdAt}
|
||||
}
|
||||
|
||||
return template
|
||||
})
|
||||
const event = (user: TestUser, template: StampedEvent) => publishTemplate(user, () => template)
|
||||
|
||||
const join = (user: TestUser, ...roomIds: string[]) => {
|
||||
memberships.push({user, rooms: roomIds})
|
||||
|
|
@ -210,41 +174,6 @@ 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() {
|
||||
|
|
@ -258,7 +187,5 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
|||
reply,
|
||||
profile,
|
||||
event,
|
||||
dm,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -21,14 +21,10 @@ 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: "${ZOOID_CONFIG:?staged by e2e/harness/zooid/relay.ts, which starts this container}"
|
||||
source: ./config
|
||||
target: /app/config
|
||||
read_only: true
|
||||
- type: tmpfs
|
||||
target: /app/data
|
||||
tmpfs:
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,21 +1,16 @@
|
|||
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 {MINUTE, int, ms, sleep} from "@welshman/lib"
|
||||
import {ms, sleep} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {makeRelayAuth} from "@welshman/util"
|
||||
import type {SignedEvent} from "@welshman/util"
|
||||
import {ClientMessageType, isRelayAuth, isRelayOk} from "@welshman/net"
|
||||
import type {ClientMessage} from "@welshman/net"
|
||||
import {testUsersByPubkey} from "../keys"
|
||||
import type {TestUser} from "../keys"
|
||||
import {users} 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"
|
||||
|
||||
|
|
@ -23,14 +18,7 @@ const image = "gitea.coracle.social/coracle/zooid:latest"
|
|||
|
||||
const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url))
|
||||
|
||||
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 testUsersByPubkey = new Map(Object.values(users).map(user => [user.pubkey, user]))
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
|
|
@ -39,11 +27,7 @@ const execFileAsync = promisify(execFile)
|
|||
// typed. Name the executable to drive with E2E_DOCKER in that case.
|
||||
const dockerCommand = process.env.E2E_DOCKER ?? "docker"
|
||||
|
||||
// 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 docker = (...args: string[]) => execFileAsync(dockerCommand, args)
|
||||
|
||||
const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args)
|
||||
|
||||
|
|
@ -74,49 +58,6 @@ 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
|
||||
|
|
@ -184,17 +125,17 @@ export class Zooid {
|
|||
makeTestRelay({
|
||||
name,
|
||||
url: tenantUrl(name),
|
||||
publish: (event, options) => this.publish(tenantUrl(name), event, options),
|
||||
publish: event => this.publish(tenantUrl(name), event),
|
||||
})
|
||||
|
||||
publish = async (url: string, event: SignedEvent, {as}: PublishOptions = {}) => {
|
||||
publish = async (url: string, event: SignedEvent) => {
|
||||
const relay = this.relays.get(url)
|
||||
|
||||
if (!relay) {
|
||||
throw new Error(`Attempted to publish to ${url}, which is not one of this container's relays`)
|
||||
}
|
||||
|
||||
const connection = await this.authenticate(relay.host, as ?? getTestUser(event.pubkey))
|
||||
const connection = await this.authenticate(relay.host, event.pubkey)
|
||||
const {ok, detail} = await this.send(connection, [ClientMessageType.Event, event], event.id)
|
||||
|
||||
if (!ok) {
|
||||
|
|
@ -235,19 +176,6 @@ 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) {
|
||||
|
|
@ -271,12 +199,6 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -293,12 +215,21 @@ 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, user: TestUser) => {
|
||||
const key = `${host} ${user.pubkey}`
|
||||
private authenticate = async (host: string, pubkey: string) => {
|
||||
const key = `${host} ${pubkey}`
|
||||
const session = this.sessions.get(key)
|
||||
|
||||
if (session) return session
|
||||
|
||||
const user = testUsersByPubkey.get(pubkey)
|
||||
|
||||
if (!user) {
|
||||
throw new Error(
|
||||
`Cannot publish as ${pubkey}: zooid authenticates every write, so seeded events must be ` +
|
||||
"signed by one of the test identities in e2e/harness/keys.ts",
|
||||
)
|
||||
}
|
||||
|
||||
const connection = connectToZooid(host)
|
||||
const [, challenge] = await connection.wait(isRelayAuth)
|
||||
const event = await user.signer.sign(makeRelayAuth(`wss://${host}/`, challenge))
|
||||
|
|
@ -310,16 +241,7 @@ export class Zooid {
|
|||
return connection
|
||||
}
|
||||
|
||||
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)
|
||||
throw new Error(`Failed to authenticate as ${user.name} on ${host}: ${detail}`)
|
||||
}
|
||||
|
||||
// Both halves of seeding — the auth that opens a connection and every event written over it —
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ import {
|
|||
import type {SignedEvent, StampedEvent} from "@welshman/util"
|
||||
import {users} from "../keys"
|
||||
import type {TestUser} from "../keys"
|
||||
import type {PublishOptions, TestRelay} from "./types"
|
||||
import type {TestRelay} from "./types"
|
||||
|
||||
export type TestRelayOptions = {
|
||||
name: string
|
||||
url: string
|
||||
publish: (event: SignedEvent, options?: PublishOptions) => Promise<void>
|
||||
publish: (event: SignedEvent) => Promise<void>
|
||||
}
|
||||
|
||||
// Seeding signs the events a real client would send and publishes them over a socket. Room
|
||||
|
|
@ -36,7 +36,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -2,13 +2,6 @@ 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
|
||||
|
|
@ -30,9 +23,6 @@ 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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,947 +0,0 @@
|
|||
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()
|
||||
})
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,496 +0,0 @@
|
|||
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)
|
||||
})
|
||||
|
|
@ -1,496 +0,0 @@
|
|||
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)
|
||||
})
|
||||
|
|
@ -1,608 +0,0 @@
|
|||
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)
|
||||
})
|
||||
|
|
@ -1,613 +0,0 @@
|
|||
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()
|
||||
})
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
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$/)
|
||||
})
|
||||
|
|
@ -1,413 +0,0 @@
|
|||
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()
|
||||
})
|
||||
|
|
@ -1,771 +0,0 @@
|
|||
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)
|
||||
})
|
||||
|
|
@ -1,753 +0,0 @@
|
|||
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")
|
||||
})
|
||||
|
|
@ -1,589 +0,0 @@
|
|||
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")
|
||||
})
|
||||
|
|
@ -1,545 +0,0 @@
|
|||
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=/)
|
||||
})
|
||||
|
|
@ -29,9 +29,6 @@ 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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -58,8 +55,6 @@ 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",
|
||||
|
|
|
|||
|
|
@ -350,7 +350,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 42;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
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.1;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
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 = 42;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
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.1;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
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 = 42;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
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.1;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
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 = 42;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
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.1;
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
40
package.json
40
package.json
|
|
@ -1,14 +1,13 @@
|
|||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "./scripts/build.sh",
|
||||
"build": "./build.sh",
|
||||
"build:server": "vite build --config vite.config.server.ts",
|
||||
"start": "node server.js",
|
||||
"release:android": "./scripts/build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||
"bump": "node scripts/bump-version.mjs",
|
||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check src e2e playwright.config.ts && eslint src e2e",
|
||||
|
|
@ -20,7 +19,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
|
|
@ -29,11 +28,10 @@
|
|||
"@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": "^10.9.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"globals": "^15.15.0",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.1",
|
||||
|
|
@ -43,7 +41,7 @@
|
|||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vite": "^6.4.2",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
|
|
@ -78,19 +76,18 @@
|
|||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@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",
|
||||
"@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",
|
||||
"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",
|
||||
|
|
@ -99,7 +96,6 @@
|
|||
"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",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -1,4 +1,4 @@
|
|||
# Allow link-deps.mjs to change stuff in the working tree
|
||||
# Allow link_deps to change stuff in the working tree
|
||||
verifyDepsBeforeRun: warn
|
||||
|
||||
allowBuilds:
|
||||
|
|
@ -7,7 +7,6 @@ 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'
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
#!/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})`)
|
||||
32
server.js
32
server.js
|
|
@ -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 {App, Relays} from "@welshman/app"
|
||||
import {normalizeRelayUrl} from "@welshman/util"
|
||||
import {loadRelay} from "@welshman/app"
|
||||
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||
import {load} from "cheerio"
|
||||
import {Hono} from "hono"
|
||||
|
||||
|
|
@ -55,12 +55,10 @@ const requestUrlFromContext = context => {
|
|||
return requestUrl
|
||||
}
|
||||
|
||||
const relays = new App().use(Relays)
|
||||
|
||||
const fetchRelayMeta = async relayUrl => {
|
||||
if (!relayUrl) return undefined
|
||||
try {
|
||||
return await relays.load(normalizeRelayUrl(relayUrl))
|
||||
return await loadRelay(normalizeRelayUrl(relayUrl))
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err)
|
||||
return undefined
|
||||
|
|
@ -78,7 +76,7 @@ const getMetadataForInvite = async (url, match) => {
|
|||
const relayMetadata = await fetchRelayMeta(relayParam)
|
||||
if (!relayMetadata) return undefined
|
||||
|
||||
const relayDisplay = relayMetadata.displayUrl()
|
||||
const relayDisplay = displayRelayUrl(relayParam)
|
||||
const spaceName = relayMetadata.name
|
||||
const relayDescription = relayMetadata.description
|
||||
|
||||
|
|
@ -99,7 +97,10 @@ 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,
|
||||
|
|
@ -117,13 +118,16 @@ const getMetadataForSpace = async (url, match) => {
|
|||
const relayMetadata = await fetchRelayMeta(relayParam)
|
||||
if (!relayMetadata) return undefined
|
||||
|
||||
const spaceName = relayMetadata.display()
|
||||
const spaceName = relayMetadata.name || displayRelayUrl(relayParam)
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
@ -166,14 +170,8 @@ 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],
|
||||
]
|
||||
|
|
|
|||
13
src/app.html
13
src/app.html
|
|
@ -25,19 +25,6 @@
|
|||
<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">
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ import {
|
|||
FOLLOWS,
|
||||
MESSAGING_RELAYS,
|
||||
PROFILE,
|
||||
RELAY_INVITE,
|
||||
RELAYS,
|
||||
type ManagementResponse,
|
||||
} from "@welshman/util"
|
||||
import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain"
|
||||
import {RelayInvite, RelayJoin, RelayLeave, RoomJoin} from "@welshman/domain"
|
||||
import {Sync, User, publish} from "@welshman/app"
|
||||
import {stripPrefix} from "@lib/util"
|
||||
import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core"
|
||||
import {app, command, network, reader, roomLists, thunks, writer} from "@app/core"
|
||||
import {PLATFORM_URL} from "@app/env"
|
||||
import {relaysMostlyRestricted} from "@app/policies"
|
||||
import {Push} from "@app/push"
|
||||
|
|
@ -100,52 +100,20 @@ export const deriveRelayAuthError = (url: string) =>
|
|||
}
|
||||
})
|
||||
|
||||
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 publishJoinRequest = (url: string, claim = "") =>
|
||||
command(writer(RelayJoin).forceRelays(url).setClaim(claim)).then(publish)
|
||||
|
||||
export const publishLeaveRequest = (url: string) =>
|
||||
command(writer(RelayLeave).forceRelays(url)).then(publish)
|
||||
|
||||
// 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) => {
|
||||
export const publishRoomJoinRequest = (url: string, h: string, code?: string) => {
|
||||
const eventWriter = writer(RoomJoin).setRoom(url, h)
|
||||
|
||||
if (code) {
|
||||
eventWriter.setClaim(code)
|
||||
}
|
||||
|
||||
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)
|
||||
return command(eventWriter).then(publish)
|
||||
}
|
||||
|
||||
export const publishRoomInvite = async (url: string, h: string) => {
|
||||
|
|
@ -230,13 +198,12 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
|
|||
|
||||
if (shouldIgnoreError(error)) return
|
||||
|
||||
if (error.includes("invite code")) {
|
||||
return "join request rejected"
|
||||
}
|
||||
|
||||
// A space that isn't open to the public refuses a join carrying no claim at all
|
||||
if (error.includes("claim")) {
|
||||
return "This space requires an invite code"
|
||||
if (claim) {
|
||||
if (error.includes("invite code")) {
|
||||
return "join request rejected"
|
||||
}
|
||||
} else if (error.includes("invite code")) {
|
||||
return
|
||||
}
|
||||
|
||||
return stripPrefix(error)
|
||||
|
|
@ -349,6 +316,17 @@ 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({
|
||||
|
|
@ -362,51 +340,41 @@ export class Access {
|
|||
}
|
||||
|
||||
if (data.h && data.code) {
|
||||
return joinRoom(this.url, data.h, data.code)
|
||||
return this.joinRoom(data.h, data.code)
|
||||
}
|
||||
}
|
||||
|
||||
async prepareInvite(h?: string) {
|
||||
this.loading.set(true)
|
||||
|
||||
try {
|
||||
const management = relayManagement.get().forUrl(this.url)
|
||||
// A request that times out or hits a closed socket resolves with no events, so an
|
||||
// empty result is the only signal we get that the relay never answered.
|
||||
const [events, roomInviteResult] = await Promise.all([
|
||||
network.get().request({
|
||||
relays: [this.url],
|
||||
autoClose: true,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
filters: [{kinds: [RELAY_INVITE]}],
|
||||
}),
|
||||
h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}),
|
||||
// Keep the spinner up long enough that a fast relay doesn't make it flash
|
||||
sleep(300),
|
||||
])
|
||||
|
||||
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()
|
||||
|
||||
if (claims?.[0]) {
|
||||
this.claim.set(claims[0])
|
||||
} else {
|
||||
const claim = randomId()
|
||||
const {error} = await management.createClaim(claim)
|
||||
|
||||
if (error) {
|
||||
this.claimFailed.set(true)
|
||||
} else {
|
||||
this.claim.set(claim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (h) {
|
||||
this.roomCode.set(roomInviteResult.code)
|
||||
this.roomInviteError.set(roomInviteResult.error)
|
||||
}
|
||||
} finally {
|
||||
this.loading.set(false)
|
||||
this.claim.set(invite.claim() ?? "")
|
||||
}
|
||||
|
||||
this.claimFailed.set(events.length === 0)
|
||||
|
||||
if (h) {
|
||||
this.roomCode.set(roomInviteResult.code)
|
||||
this.roomInviteError.set(roomInviteResult.error)
|
||||
}
|
||||
|
||||
this.loading.set(false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,111 @@
|
|||
import {derived} from "svelte/store"
|
||||
import {REPORT, sortEventsDesc} from "@welshman/util"
|
||||
import {rooms} from "@app/core"
|
||||
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 {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]}]), rooms.get().pendingJoins(url).$],
|
||||
([$reports, $pendingJoins]) => sortEventsDesc([...$reports, ...$pendingJoins]),
|
||||
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])
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -642,11 +642,6 @@ 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
|
||||
|
||||
|
|
@ -819,7 +814,7 @@ const attemptReconnect = async () => {
|
|||
if (!target) return
|
||||
|
||||
try {
|
||||
await joinVoiceRoom(target.url, target.h, reconnectMicMuted, reconnectMicDeviceId)
|
||||
await joinVoiceRoom(target.url, target.h)
|
||||
} catch {
|
||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
|
|
@ -860,12 +855,6 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -41,22 +41,21 @@
|
|||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ThunkStatusOrDeleted {event}>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Event">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
<li>
|
||||
<Button onclick={editEvent}>
|
||||
<Icon size={4} icon={Pen2} />
|
||||
Edit Event
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EventActions>
|
||||
</ThunkStatusOrDeleted>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Event">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
<li>
|
||||
<Button onclick={editEvent}>
|
||||
<Icon size={4} icon={Pen2} />
|
||||
Edit Event
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EventActions>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@
|
|||
|
||||
const {event}: Props = $props()
|
||||
|
||||
const timeEvent = $derived(reader(TimeEvent)(event))
|
||||
const timeEvent = reader(TimeEvent)(event)
|
||||
|
||||
const start = $derived(timeEvent.start())
|
||||
const start = timeEvent.start()
|
||||
</script>
|
||||
|
||||
{#if start}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@
|
|||
|
||||
const {event}: Props = $props()
|
||||
|
||||
const timeEvent = $derived(reader(TimeEvent)(event))
|
||||
const timeEvent = 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 = $derived(timeEvent.start())
|
||||
const end = $derived(timeEvent.end())
|
||||
const start = timeEvent.start()
|
||||
const end = timeEvent.end()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-between gap-1">
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
|
||||
const {event, url}: Props = $props()
|
||||
|
||||
const timeEvent = $derived(reader(TimeEvent)(event))
|
||||
const timeEvent = reader(TimeEvent)(event)
|
||||
|
||||
const location = $derived(timeEvent.location())
|
||||
const location = timeEvent.location()
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@
|
|||
eventToEdit = event
|
||||
}
|
||||
|
||||
const onEditPrevious = () => ifLet($chat?.messages.find(canEditEvent), onEditEvent)
|
||||
const onEditPrevious = () => ifLet($chat?.messages.toReversed().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 client.
|
||||
to enable direct messaging by opening this conversation in their $app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<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"
|
||||
|
|
@ -9,15 +8,14 @@
|
|||
import {profiles} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
url?: string
|
||||
verb: string
|
||||
event: TrustedEvent
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const {url, verb, event, clear}: Props = $props()
|
||||
const {verb, event, clear}: Props = $props()
|
||||
|
||||
const display = $profiles.display(event.pubkey, removeUndefined([url])).$
|
||||
const display = $profiles.display(event.pubkey).$
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -55,25 +55,23 @@
|
|||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
<ThunkStatusOrDeleted {event}>
|
||||
{#snippet status()}
|
||||
<ClassifiedStatus {event} />
|
||||
{/snippet}
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Listing">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
<li>
|
||||
<Button onclick={editClassified}>
|
||||
<Icon size={4} icon={Pen2} />
|
||||
Edit Listing
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EventActions>
|
||||
<ClassifiedStatus {event} />
|
||||
</ThunkStatusOrDeleted>
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Listing">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
<li>
|
||||
<Button onclick={editClassified}>
|
||||
<Icon size={4} icon={Pen2} />
|
||||
Edit Listing
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EventActions>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 = segment && makeSpacePath(url, segment, event.id)
|
||||
const path = makeSpacePath(url, segment, event.id)
|
||||
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||
|
||||
|
|
@ -28,12 +28,11 @@
|
|||
|
||||
<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" />
|
||||
{#if showActivity && path}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Comment" />
|
||||
</ThunkStatusOrDeleted>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Comment" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -4,14 +4,12 @@
|
|||
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"
|
||||
|
|
@ -34,8 +32,6 @@
|
|||
|
||||
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
|
||||
|
||||
const createArticle = () => pushModal(ArticleCreate, {url, h, shareToChat: true})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -68,12 +64,6 @@
|
|||
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} />
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
isInvoice,
|
||||
isLink,
|
||||
isProfile,
|
||||
isRoom,
|
||||
isEvent,
|
||||
isEllipsis,
|
||||
isAddress,
|
||||
|
|
@ -36,9 +35,7 @@
|
|||
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 = {
|
||||
|
|
@ -162,7 +159,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 (i)}
|
||||
{#each shortContent as parsed, i}
|
||||
{#if isNewline(parsed) && !isBlock(i - 1)}
|
||||
<ContentNewline value={parsed.value} />
|
||||
{:else if isTopic(parsed)}
|
||||
|
|
@ -185,10 +182,6 @@
|
|||
{/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} />
|
||||
|
|
@ -204,7 +197,7 @@
|
|||
{@html renderAsHtml(parsed)}
|
||||
<button
|
||||
type="button"
|
||||
class="pointer-events-auto text-sm underline"
|
||||
class="text-sm underline"
|
||||
onclick={stopPropagation(preventDefault(expand))}>
|
||||
Read more
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<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"
|
||||
|
|
@ -13,7 +12,7 @@
|
|||
|
||||
const {value, url}: Props = $props()
|
||||
|
||||
const display = $profiles.display(value.pubkey, removeUndefined([url])).$
|
||||
const display = $profiles.display(value.pubkey).$
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -114,10 +114,9 @@
|
|||
"overflow-hidden text-ellipsis",
|
||||
singleLine ? "whitespace-nowrap" : "wrap-break-word",
|
||||
)}>
|
||||
{#each shortContent as parsed, i (i)}
|
||||
{#each shortContent as parsed, i}
|
||||
{#if isNewline(parsed)}
|
||||
{#if singleLine}
|
||||
<!-- eslint-disable-next-line svelte/no-useless-mustaches -->
|
||||
{" "}
|
||||
{:else}
|
||||
<ContentNewline value={parsed.value} />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
const {value} = $props()
|
||||
</script>
|
||||
|
||||
{#each value as _, i (i)}
|
||||
{#each value as _}
|
||||
<br />
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
const {value, event, url}: Props = $props()
|
||||
|
||||
const {id, identifier, kind, pubkey = value.author, relays = []} = value
|
||||
const {id, identifier, kind, pubkey, relays = []} = value
|
||||
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
||||
const ref = {id, pubkey, kind, identifier, relays}
|
||||
const hints = removeUndefined([...relays, url])
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {max, gt, formatTimestampRelative} from "@welshman/lib"
|
||||
import {getCommentFiltersForRoot} from "@welshman/util"
|
||||
import {COMMENT} 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 = getCommentFiltersForRoot([event])
|
||||
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
||||
const replies = deriveEvents(filters)
|
||||
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
<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}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
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
|
||||
|
|
@ -17,11 +16,8 @@
|
|||
const confirm = async () => {
|
||||
const protect = await shouldProtect
|
||||
const command = await $deletes.deleteEvent(event, writer => writer.setProtected(protect))
|
||||
const error = await command.publishToRelays([url]).waitForError()
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
command.publishToRelays([url])
|
||||
|
||||
clearModals()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {url} event={parent} clear={() => onClearParent?.()} verb="Replying to" />
|
||||
<ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" />
|
||||
{/if}
|
||||
<div class="relative">
|
||||
<div class="note-editor grow overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -36,11 +36,10 @@
|
|||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ThunkStatusOrDeleted {event}>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} hideZap noun="Goal" />
|
||||
</ThunkStatusOrDeleted>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} hideZap noun="Goal" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (icon.url)}
|
||||
{#each filteredIcons as icon}
|
||||
<button
|
||||
type="button"
|
||||
title={icon.name}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@
|
|||
Log in with Extension
|
||||
</Button>
|
||||
{/if}
|
||||
{#each signers as signerApp (signerApp.name)}
|
||||
{#each signers as signerApp}
|
||||
<Button {disabled} class="button button-primary" onclick={() => loginWithNip55(signerApp)}>
|
||||
{#if loading === "nip55"}
|
||||
<Spinner size="sm" class="mr-3" />
|
||||
|
|
|
|||
|
|
@ -1,19 +1,9 @@
|
|||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {
|
||||
EVENT_TIME,
|
||||
ZAP_GOAL,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
LONG_FORM,
|
||||
POLL,
|
||||
PIN,
|
||||
PINBOARD,
|
||||
} from "@welshman/util"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, 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"
|
||||
|
|
@ -30,8 +20,6 @@
|
|||
<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}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,19 +1,9 @@
|
|||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {
|
||||
EVENT_TIME,
|
||||
ZAP_GOAL,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
LONG_FORM,
|
||||
POLL,
|
||||
PINBOARD,
|
||||
PIN,
|
||||
} from "@welshman/util"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, 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"
|
||||
|
|
@ -30,8 +20,6 @@
|
|||
<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}
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
<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}
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
<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"
|
||||
|
|
@ -84,6 +83,45 @@
|
|||
<ModalTitle>{heading}</ModalTitle>
|
||||
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Title
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<input bind:value={title} class="input w-full" placeholder="Optional title" />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Description
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="relative z-feature flex gap-2">
|
||||
<div class="input-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="button button-neutral button-input tip"
|
||||
onclick={selectFiles}
|
||||
disabled={loading}>
|
||||
{#if $uploading}
|
||||
<Spinner size="xs" />
|
||||
{:else}
|
||||
<Icon icon={GallerySend} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Topics
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Link
|
||||
|
|
@ -95,49 +133,6 @@
|
|||
<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
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<input bind:value={title} class="input w-full" placeholder="Optional title" />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Description
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="relative z-feature flex gap-2">
|
||||
<div class="input-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="button button-neutral button-input tip"
|
||||
onclick={selectFiles}
|
||||
disabled={loading}>
|
||||
{#if $uploading}
|
||||
<Spinner size="xs" />
|
||||
{:else}
|
||||
<Icon icon={GallerySend} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Topics
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
|
||||
{/snippet}
|
||||
</Field>
|
||||
</div>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
|
|
|
|||
|
|
@ -33,11 +33,10 @@
|
|||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ThunkStatusOrDeleted {event}>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Poll" />
|
||||
</ThunkStatusOrDeleted>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Poll" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<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"
|
||||
|
|
@ -23,7 +22,7 @@
|
|||
|
||||
const {pubkey, url, showPubkey, inert, avatarSize = 10}: Props = $props()
|
||||
|
||||
const profileDisplay = $profiles.display(pubkey, removeUndefined([url])).$
|
||||
const profileDisplay = $profiles.display(pubkey).$
|
||||
const handle = $handles.forPubkey(pubkey).$
|
||||
|
||||
const openProfile = () => {
|
||||
|
|
@ -36,11 +35,11 @@
|
|||
<div class="flex max-w-full items-start gap-2">
|
||||
{#if inert}
|
||||
<span class="py-1">
|
||||
<ProfileCircle {pubkey} {url} size={avatarSize} />
|
||||
<ProfileCircle {pubkey} size={avatarSize} />
|
||||
</span>
|
||||
{:else}
|
||||
<Button onclick={openProfile} class="py-1">
|
||||
<ProfileCircle {pubkey} {url} size={avatarSize} />
|
||||
<ProfileCircle {pubkey} size={avatarSize} />
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-col">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import {removeUndefined, spec} from "@welshman/lib"
|
||||
import {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, removeUndefined([url])).get()} from the space?`,
|
||||
message: `Are you sure you want to ban @${$profiles.display(pubkey).get()} from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await $relayManagement.forUrl(url!).banPubkey(pubkey)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<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"
|
||||
|
|
@ -20,7 +19,7 @@
|
|||
|
||||
const {pubkey, url, onClick, customActions}: Props = $props()
|
||||
|
||||
const profile = $profiles.one(pubkey, removeUndefined([url]))
|
||||
const profile = $profiles.one(pubkey)
|
||||
|
||||
const showInfo = () => pushModal(ProfileInfo, {event: $profile!.event, url})
|
||||
|
||||
|
|
|
|||
|
|
@ -63,8 +63,7 @@
|
|||
<ProfileCircles pubkeys={$followers} limit={5} />
|
||||
<p class="text-sm opacity-75">
|
||||
{$followers.length}
|
||||
{$followers.length === 1 ? "person" : "people"} you follow also
|
||||
{$followers.length === 1 ? "follows" : "follow"}
|
||||
{$followers.length === 1 ? "person" : "people"} you follow also follow
|
||||
{$profileDisplay}.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
</script>
|
||||
|
||||
<div class="flex w-full">
|
||||
{#each Array(total) as _, i (i)}
|
||||
{#each Array(total) as _, i}
|
||||
<div
|
||||
class="h-1 flex-1 transition-colors {i < current
|
||||
? 'bg-primary text-primary-content'
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
{:else if event.content === "-"}
|
||||
<Icon icon={ThumbsDown} />
|
||||
{:else}
|
||||
{#each parse(event) as parsed, i (i)}
|
||||
{#each parse(event) as parsed}
|
||||
{#if isEmoji(parsed)}
|
||||
<ContentEmoji value={parsed.value} />
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@
|
|||
event,
|
||||
deleteReaction,
|
||||
createReaction,
|
||||
url,
|
||||
url = "",
|
||||
reactionClass = "",
|
||||
noTooltip = false,
|
||||
innerEvent = undefined,
|
||||
|
|
@ -110,11 +110,12 @@
|
|||
pushModal(ProfileList, {title, subtitle, pubkeys, url})
|
||||
|
||||
const onReactionClick = (events: TrustedEvent[], pubkeys: string[], info: string) => {
|
||||
if (isMobile && !pubkeys.includes($user.pubkey)) {
|
||||
if (isMobile) {
|
||||
showReactors(pubkeys, info.replace(" reacted", ""), "Reacted to this message")
|
||||
} else {
|
||||
toggleReaction(events)
|
||||
return
|
||||
}
|
||||
|
||||
toggleReaction(events)
|
||||
}
|
||||
|
||||
const onZapClick = (pubkeys: string[], info: string) => {
|
||||
|
|
@ -192,13 +193,11 @@
|
|||
<span>{$reports.length}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{#each groupedZaps.entries() as [key, zaps] (key)}
|
||||
{#each groupedZaps.entries() as [key, zaps]}
|
||||
{@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, removeUndefined([url])).get()),
|
||||
)}
|
||||
{@const info = displayList(pubkeys.map(pubkey => $profiles.display(pubkey).get()))}
|
||||
{@const tooltip = `${info} zapped`}
|
||||
{@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)}
|
||||
<Button
|
||||
|
|
@ -217,12 +216,10 @@
|
|||
<span>{amount}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
{#each groupedReactions.entries() as [key, events] (key)}
|
||||
{#each groupedReactions.entries() as [key, events]}
|
||||
{@const pubkeys = events.map(e => e.pubkey)}
|
||||
{@const isOwn = pubkeys.includes($user.pubkey)}
|
||||
{@const info = displayList(
|
||||
pubkeys.map(pubkey => $profiles.display(pubkey, removeUndefined([url])).get()),
|
||||
)}
|
||||
{@const info = displayList(pubkeys.map(pubkey => $profiles.display(pubkey).get()))}
|
||||
{@const tooltip = `${info} reacted`}
|
||||
{@const onClick = () => onReactionClick(events, pubkeys, info)}
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
<script lang="ts">
|
||||
import {THREAD, CLASSIFIED, LONG_FORM, ZAP_GOAL, EVENT_TIME, POLL} from "@welshman/util"
|
||||
import {THREAD, CLASSIFIED, 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"
|
||||
|
|
@ -24,8 +23,6 @@
|
|||
<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}
|
||||
|
|
|
|||
|
|
@ -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, blockedRelayLists, user} from "@app/core"
|
||||
import {app} from "@app/core"
|
||||
|
||||
interface Props {
|
||||
relays: Readable<string[]>
|
||||
|
|
@ -54,9 +54,8 @@
|
|||
let limit = $state(20)
|
||||
let element: Element | undefined = $state()
|
||||
|
||||
const loading = new SvelteSet<string>()
|
||||
const loading = $state(new SvelteSet<string>())
|
||||
const relaySearch = $app.use(Relays).relaySearch
|
||||
const blockedRelayUrls = $blockedRelayLists.urls($user.pubkey).$
|
||||
|
||||
const searchResults = $derived(
|
||||
$relaySearch
|
||||
|
|
@ -65,7 +64,6 @@
|
|||
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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
const loading = new SvelteSet<string>()
|
||||
const loading = $state(new SvelteSet<string>())
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
|
|
|
|||
|
|
@ -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 (healthCheck.title)}
|
||||
{#each $pending as healthCheck}
|
||||
<RelaySettingsHealthCheck {healthCheck} />
|
||||
{/each}
|
||||
{#if $pending.length > 0}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<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"
|
||||
|
|
@ -36,15 +37,9 @@
|
|||
.setReason(reason.toLowerCase())
|
||||
.setContent(content)
|
||||
|
||||
const reportCommand = await command(eventWriter)
|
||||
const error = await reportCommand.publishToRelays([url]).waitForError()
|
||||
await command(eventWriter).then(publishToRelays([url]))
|
||||
|
||||
loading = false
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
history.back()
|
||||
|
||||
return pushToast({message: "Your report has been sent!"})
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Report has successfully been dismissed!"})
|
||||
pushToast({message: "Content has successfully been deleted!"})
|
||||
$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, [url]).get()} from the space?`,
|
||||
message: `Are you sure you want to ban @${$profiles.display(reported).get()} from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await $relayManagement.forUrl(url).banPubkey(reported, reason)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
try {
|
||||
const {error} = await $relayManagement
|
||||
.forUrl(url)
|
||||
.createRole(randomId(), label, description, color, 1)
|
||||
.createRole(randomId(), label, description, String(color), 1)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
try {
|
||||
const {error} = await $relayManagement
|
||||
.forUrl(url)
|
||||
.editRole(id, label, description, color, role.order())
|
||||
.editRole(id, label, description, String(color), role.order())
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
|
|
|
|||
|
|
@ -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 {MembershipStatus} from "@welshman/app"
|
||||
import {publish} 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,12 +31,13 @@
|
|||
import VideoCallContent from "@app/components/VideoCallContent.svelte"
|
||||
import CallControlBar from "@app/components/CallControlBar.svelte"
|
||||
import {deletes, relays, rooms, thunks, user} from "@app/core"
|
||||
import {joinRoom, leaveRoom} from "@app/access"
|
||||
import {publishRoomJoinRequest} from "@app/access"
|
||||
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
|
||||
import {
|
||||
PROTECTED,
|
||||
RoomType,
|
||||
deriveUserRoomMembershipStatus,
|
||||
MembershipStatus,
|
||||
getRoomType,
|
||||
prependParent,
|
||||
} from "@app/rooms"
|
||||
|
|
@ -130,14 +131,18 @@
|
|||
joining = true
|
||||
|
||||
try {
|
||||
const message = await joinRoom(url, h, inviteCode)
|
||||
const thunk = await (inviteCode
|
||||
? publishRoomJoinRequest(url, h, inviteCode)
|
||||
: $rooms.joinRoom(url, {h}).then(publish))
|
||||
|
||||
if (message) {
|
||||
pushToast({theme: "error", message})
|
||||
} else {
|
||||
// Restart the feed now that we're a member
|
||||
start()
|
||||
const message = await thunk.waitForError()
|
||||
|
||||
if (message && !message.startsWith("duplicate:")) {
|
||||
return pushToast({theme: "error", message})
|
||||
}
|
||||
|
||||
// Restart the feed now that we're a member
|
||||
start()
|
||||
} finally {
|
||||
joining = false
|
||||
}
|
||||
|
|
@ -147,14 +152,12 @@
|
|||
const leave = async () => {
|
||||
if (h) {
|
||||
leaving = true
|
||||
|
||||
try {
|
||||
const message = await leaveRoom(url, h)
|
||||
const thunk = await $rooms.leaveRoom(url, {h}).then(publish)
|
||||
const message = await thunk.waitForError()
|
||||
|
||||
if (message) {
|
||||
if (message && !message.startsWith("duplicate:")) {
|
||||
pushToast({theme: "error", message})
|
||||
} else {
|
||||
pushToast({message: "You have left the room."})
|
||||
}
|
||||
} finally {
|
||||
leaving = false
|
||||
|
|
@ -639,10 +642,10 @@
|
|||
{:else}
|
||||
<div>
|
||||
{#if parent}
|
||||
<RoomComposeParent {url} event={parent} clear={clearParent} verb="Replying to" />
|
||||
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
{/if}
|
||||
{#if sharedEvent}
|
||||
<RoomComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" />
|
||||
<RoomComposeParent event={sharedEvent} clear={clearShare} verb="Sharing" />
|
||||
{/if}
|
||||
{#if eventToEdit}
|
||||
<RoomComposeEdit clear={clearEventToEdit} />
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@
|
|||
|
||||
const {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
|
||||
|
||||
const draftKey =
|
||||
(url || h) && !initialValues ? new DraftKey<Draft>(`room:${url ?? ""}:${h ?? ""}`) : undefined
|
||||
const draftKey = url || h ? 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
Loading…
Reference in a new issue